1use std::collections::BTreeMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use mold_core::{
6 build_model_catalog, Config, GenerateRequest, GenerationMemoryEstimate, ModelComponentOption,
7 ModelComponentStatus, ModelComponentsResponse, ModelDefaults, ModelInfo, ModelInfoExtended,
8 ModelPaths,
9};
10#[cfg(test)]
11use mold_inference::device::ActivationFamily;
12
13use mold_catalog::resolve::{
14 installed_intent_from_sidecar, looks_like_catalog_id, resolve_intent_to_model_config,
15 MissingCompanionPolicy, ResolveError, ResolveOptions,
16};
17
18use crate::model_cache::ModelResidency;
19use crate::{routes::ApiError, state::AppState};
20
21pub(crate) type EngineProgressCallback = Arc<dyn Fn(mold_inference::ProgressEvent) + Send + Sync>;
22
23pub use crate::memory_preflight::ActivationHint;
24#[cfg(test)]
25pub(crate) use crate::memory_preflight::{
26 check_model_memory_budget, preflight_memory_guard_with_available, rejection_suggestion,
27};
28pub(crate) use crate::memory_preflight::{
29 effective_load_available_bytes, estimate_generation_memory_for_request, preflight_memory_guard,
30 request_requires_fresh_engine_for_offload_policy, select_server_load_strategy_for_budget,
31 select_server_load_strategy_for_device, server_offload_enabled_for_paths,
32};
33
34pub(crate) fn request_has_effective_lora(req: &GenerateRequest) -> bool {
35 const ZERO_SCALE_EPS: f64 = 1e-8;
36 if let Some(loras) = &req.loras {
37 if !loras.is_empty() {
38 return loras.iter().any(|lora| lora.scale.abs() > ZERO_SCALE_EPS);
39 }
40 }
41 req.lora
42 .as_ref()
43 .is_some_and(|lora| lora.scale.abs() > ZERO_SCALE_EPS)
44}
45
46pub(crate) fn resolve_installed_catalog_paths_for_worker(
47 model_name: &str,
48 config: &Config,
49) -> Result<Option<(ModelPaths, Config)>, ApiError> {
50 if !looks_like_catalog_id(model_name) {
51 return Ok(None);
52 }
53
54 let Some(intent) = installed_intent_from_sidecar(&config.resolved_models_dir(), model_name)
55 else {
56 return Ok(None);
57 };
58 let model_cfg = resolve_intent_to_paths(model_name, &intent, config)
59 .map_err(|e| resolve_error_to_api_error(&e))?;
60 let mut resolved_config = config.clone();
61 resolved_config
62 .models
63 .insert(model_name.to_string(), model_cfg);
64 let paths = ModelPaths::resolve(model_name, &resolved_config).ok_or_else(|| {
65 ApiError::not_found(format!(
66 "catalog model '{model_name}' resolved to a config that ModelPaths \
67 could not turn into runtime paths — internal mismatch, please file an issue."
68 ))
69 })?;
70
71 Ok(Some((paths, resolved_config)))
72}
73
74pub(crate) type DownloadProgressCallback =
75 Arc<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>;
76
77pub(crate) enum PullStatus {
78 AlreadyAvailable,
79 Pulled,
80}
81
82pub(crate) async fn refresh_config(state: &AppState) -> mold_core::Config {
83 let fresh = {
84 let current = state.config.read().await;
85 current.reload_from_disk_preserving_runtime()
86 };
87
88 let mut config = state.config.write().await;
89 *config = fresh.clone();
90 fresh
91}
92
93pub(crate) async fn list_models(state: &AppState) -> Vec<ModelInfoExtended> {
94 let config = refresh_config(state).await;
95 let models_dir = config.resolved_models_dir();
96
97 if state.gpu_pool.worker_count() > 0 {
100 let loaded_models = loaded_models_across_pool(state);
101 let primary = loaded_models.first().cloned();
102 let mut catalog = build_model_catalog(&config, primary.as_deref(), primary.is_some());
103 for entry in catalog.iter_mut() {
105 if loaded_models.contains(&entry.info.name) {
106 entry.info.is_loaded = true;
107 }
108 }
109 catalog.extend(installed_catalog_models(
110 state,
111 &config,
112 &models_dir,
113 primary.as_deref(),
114 primary.is_some(),
115 ));
116 return catalog;
117 }
118
119 let snapshot = state.model_cache.lock().await.snapshot();
120 let mut catalog =
121 build_model_catalog(&config, snapshot.model_name.as_deref(), snapshot.is_loaded);
122 catalog.extend(installed_catalog_models(
123 state,
124 &config,
125 &models_dir,
126 snapshot.model_name.as_deref(),
127 snapshot.is_loaded,
128 ));
129 catalog
130}
131
132fn loaded_models_across_pool(state: &AppState) -> Vec<String> {
133 let mut names = Vec::new();
134 for worker in &state.gpu_pool.workers {
135 let active = worker
138 .active_generation
139 .read()
140 .ok()
141 .and_then(|g| g.as_ref().map(|g| g.model.clone()));
142 let loaded = active.or_else(|| {
143 let cache = worker.model_cache.lock().ok()?;
144 cache.active_model().map(|s| s.to_string())
145 });
146 if let Some(name) = loaded {
147 if !names.contains(&name) {
148 names.push(name);
149 }
150 }
151 }
152 names
153}
154
155pub(crate) async fn catalog_family_for(state: &AppState, model_name: &str) -> Option<String> {
171 if !looks_like_catalog_id(model_name) {
172 return None;
173 }
174 {
175 let intents = state.catalog_intents.read().await;
176 if let Some(intent) = intents.get(model_name) {
177 return Some(intent.family.clone());
178 }
179 }
180 let config = state.config.read().await;
181 if let Some(family) = config.models.get(model_name).and_then(|m| m.family.clone()) {
182 return Some(family);
183 }
184 installed_intent_from_sidecar(&config.resolved_models_dir(), model_name)
185 .map(|intent| intent.family)
186}
187
188pub(crate) async fn family_for_model(state: &AppState, model_name: &str) -> Option<String> {
193 if let Some(manifest) = mold_core::manifest::find_manifest(model_name) {
194 return Some(manifest.family.clone());
195 }
196 catalog_family_for(state, model_name).await
197}
198
199pub(crate) fn family_for_model_sync(
203 model_name: &str,
204 config: &mold_core::Config,
205) -> Option<String> {
206 if let Some(manifest) = mold_core::manifest::find_manifest(model_name) {
207 return Some(manifest.family.clone());
208 }
209 config.models.get(model_name).and_then(|m| m.family.clone())
210}
211
212pub(crate) fn activation_hint_for_request_sync(
215 config: &mold_core::Config,
216 req: &GenerateRequest,
217) -> Option<ActivationHint> {
218 let family = family_for_model_sync(&req.model, config)?;
219 Some(ActivationHint::from_request(req, &family))
220}
221
222pub(crate) async fn activation_hint_for_request(
226 state: &AppState,
227 req: &GenerateRequest,
228) -> Option<ActivationHint> {
229 let family = family_for_model(state, &req.model).await?;
230 Some(ActivationHint::from_request(req, &family))
231}
232
233pub(crate) fn resolve_intent_to_paths(
240 model_name: &str,
241 intent: &mold_catalog::synthesis::CatalogModelIntent,
242 config: &mold_core::Config,
243) -> Result<mold_core::ModelConfig, ResolveError> {
244 resolve_intent_to_model_config(
245 model_name,
246 intent,
247 config,
248 ResolveOptions {
249 missing_companions: MissingCompanionPolicy::Fail,
250 require_primary_present: true,
251 },
252 )
253}
254
255pub(crate) async fn install_catalog_model(
265 state: &AppState,
266 model_name: &str,
267) -> Result<(), mold_core::InstallError> {
268 if !looks_like_catalog_id(model_name) {
269 return Ok(());
272 }
273
274 {
276 let intents = state.catalog_intents.read().await;
277 if intents.contains_key(model_name) {
278 return Ok(());
279 }
280 }
281
282 let models_dir = state.config.read().await.resolved_models_dir();
283 if let Some(intent) = installed_intent_from_sidecar(&models_dir, model_name) {
284 let mut intents = state.catalog_intents.write().await;
285 intents.insert(model_name.to_string(), intent);
286 return Ok(());
287 }
288
289 let entry = mold_catalog::live::fetch_entry_by_id(
293 model_name,
294 state.catalog_live_civitai_base.as_str(),
295 "https://huggingface.co",
296 std::env::var("CIVITAI_TOKEN").ok().as_deref(),
297 std::env::var("HF_TOKEN").ok().as_deref(),
298 )
299 .await
300 .map_err(|e| live_error_to_install_error(model_name, &e))?;
301
302 let intent = mold_catalog::synthesis::synthesize_intent(&entry, &models_dir).map_err(|e| {
303 mold_core::InstallError::RecipeMalformed(format!("synthesize intent for {model_name}: {e}"))
304 })?;
305 if model_name.starts_with("cv:") {
306 write_catalog_sidecar_from_intent(&models_dir, &entry, &intent);
307 }
308
309 let mut intents = state.catalog_intents.write().await;
310 intents.insert(model_name.to_string(), intent);
311 Ok(())
312}
313
314fn write_catalog_sidecar_from_intent(
315 models_dir: &std::path::Path,
316 entry: &mold_catalog::entry::CatalogEntry,
317 intent: &mold_catalog::synthesis::CatalogModelIntent,
318) {
319 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(models_dir, entry.id.as_str());
320 let Some(sidecar_dir) = sc_path.parent() else {
321 return;
322 };
323 let Ok(primary_rel) = intent.primary_recipe_path.strip_prefix(sidecar_dir) else {
324 return;
325 };
326 let Some(primary_rel) = primary_rel.to_str() else {
327 return;
328 };
329 let sidecar = mold_catalog::sidecar::sidecar_from_entry(entry, primary_rel.to_string());
330 if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
331 tracing::warn!(
332 target: "catalog.sidecar",
333 catalog_id = %entry.id.as_str(),
334 error = %e,
335 "sidecar write failed after live catalog install",
336 );
337 }
338}
339
340fn live_error_to_install_error(
346 model_name: &str,
347 err: &mold_catalog::live::LiveSearchError,
348) -> mold_core::InstallError {
349 use mold_catalog::live::LiveSearchError;
350 match err {
351 LiveSearchError::Network(e) => {
352 mold_core::InstallError::Network(format!("{model_name}: {e}"))
353 }
354 LiveSearchError::Decode(e) => mold_core::InstallError::RecipeMalformed(format!(
355 "{model_name}: decode upstream payload: {e}"
356 )),
357 LiveSearchError::Upstream { status, body, .. } if *status == 404 => {
358 mold_core::InstallError::NotFound(format!(
359 "{model_name}: upstream returned 404 ({})",
360 truncate_body(body)
361 ))
362 }
363 LiveSearchError::Upstream { status, body, .. } => {
364 mold_core::InstallError::RecipeMalformed(format!(
365 "{model_name}: upstream HTTP {status}: {}",
366 truncate_body(body)
367 ))
368 }
369 }
370}
371
372fn truncate_body(body: &str) -> String {
373 let trimmed = body.trim();
374 if trimmed.len() > 160 {
375 format!("{}…", &trimmed[..160])
376 } else {
377 trimmed.to_string()
378 }
379}
380
381pub(crate) fn install_error_to_api_error(err: &mold_core::InstallError) -> ApiError {
385 use mold_core::InstallError;
386 match err {
387 InstallError::Network(msg) => {
388 ApiError::internal_with_status(
391 format!("network unreachable: {msg}"),
392 axum::http::StatusCode::BAD_GATEWAY,
393 )
394 }
395 InstallError::NotFound(msg) => ApiError::not_found(msg.to_string()),
396 InstallError::RecipeMalformed(msg) => ApiError::internal(msg.to_string()),
397 }
398}
399
400pub(crate) fn resolve_error_to_api_error(err: &ResolveError) -> ApiError {
405 if matches!(err, ResolveError::UnknownFamily { .. }) {
406 return ApiError::internal(err.to_string());
407 }
408 ApiError::not_found(err.to_string())
409}
410
411fn installed_catalog_models(
415 _state: &AppState,
416 config: &mold_core::Config,
417 models_dir: &std::path::Path,
418 loaded_model: Option<&str>,
419 engine_is_loaded: bool,
420) -> Vec<ModelInfoExtended> {
421 let walked = mold_catalog::sidecar::walk_sidecars(models_dir);
422 let mut out = Vec::new();
423 for (sidecar_dir, sidecar) in walked {
424 if sidecar.kind != "checkpoint" {
425 continue;
426 }
427 if mold_catalog::sidecar::primary_looks_like_auxiliary(&sidecar) {
428 continue;
429 }
430 if mold_catalog::sidecar::primary_path_if_present(&sidecar_dir, &sidecar).is_none() {
433 continue;
434 }
435 if mold_core::manifest::find_manifest_by_hf_repo(&sidecar.source_id).is_some() {
437 continue;
438 }
439
440 let size_gb = sidecar
441 .size_bytes
442 .map(|b| b as f32 / 1_000_000_000.0)
443 .unwrap_or(0.0);
444
445 let defaults = mold_catalog::defaults::runtime_defaults_for_family(
446 &sidecar.family,
447 sidecar.sub_family.as_deref(),
448 );
449 let user_cfg = config.lookup_model_config(&sidecar.id);
450 let w = user_cfg
451 .as_ref()
452 .and_then(|cfg| cfg.default_width)
453 .unwrap_or(defaults.width);
454 let h = user_cfg
455 .as_ref()
456 .and_then(|cfg| cfg.default_height)
457 .unwrap_or(defaults.height);
458 let steps = user_cfg
459 .as_ref()
460 .and_then(|cfg| cfg.default_steps)
461 .unwrap_or(defaults.steps);
462 let guidance = user_cfg
463 .as_ref()
464 .and_then(|cfg| cfg.default_guidance)
465 .unwrap_or(defaults.guidance);
466
467 let description = match &sidecar.author {
468 Some(a) if !a.is_empty() => format!("{} by {a}", sidecar.name),
469 _ => sidecar.name.clone(),
470 };
471
472 out.push(ModelInfoExtended {
473 downloaded: true,
474 defaults: ModelDefaults {
475 default_width: w,
476 default_height: h,
477 default_steps: steps,
478 default_guidance: guidance,
479 description,
480 },
481 info: ModelInfo {
482 name: sidecar.id.clone(),
483 family: sidecar.family.clone(),
484 size_gb,
485 is_loaded: loaded_model.is_some_and(|n| engine_is_loaded && n == sidecar.id),
486 last_used: None,
487 hf_repo: String::new(),
488 },
489 disk_usage_bytes: sidecar.size_bytes,
490 remaining_download_bytes: Some(0),
491 });
492 }
493 out
494}
495
496pub(crate) async fn check_model_available(
500 state: &AppState,
501 model_name: &str,
502) -> Result<Option<ModelPaths>, ApiError> {
503 {
505 let cache = state.model_cache.lock().await;
506 if cache.contains(model_name) {
507 return Ok(None);
508 }
509 }
510
511 let paths = {
512 let config = state.config.read().await;
513 if config.manifest_model_needs_download(model_name) {
514 None
515 } else {
516 ModelPaths::resolve(model_name, &config)
517 }
518 };
519 if let Some(paths) = paths {
520 return Ok(Some(paths));
521 }
522
523 {
524 let current = state.config.read().await.clone();
525 let fresh_config = current.reload_from_disk_preserving_runtime();
526 let needs_download = fresh_config.manifest_model_needs_download(model_name);
527 let paths = if needs_download {
528 None
529 } else {
530 ModelPaths::resolve(model_name, &fresh_config)
531 };
532 {
533 let mut config = state.config.write().await;
534 *config = fresh_config;
535 }
536 if let Some(paths) = paths {
537 return Ok(Some(paths));
538 }
539 }
540
541 if looks_like_catalog_id(model_name) {
547 if let Err(install_err) = install_catalog_model(state, model_name).await {
548 return Err(install_error_to_api_error(&install_err));
549 }
550 let intents = state.catalog_intents.read().await;
551 let intent = intents
552 .get(model_name)
553 .ok_or_else(|| {
554 ApiError::not_found(format!(
555 "catalog model '{model_name}' is not installed. Download it from \
556 the catalog first."
557 ))
558 })?
559 .clone();
560 drop(intents);
561
562 let resolved = {
563 let config = state.config.read().await;
564 resolve_intent_to_paths(model_name, &intent, &config)
565 };
566 match resolved {
567 Ok(model_cfg) => {
568 {
572 let mut config = state.config.write().await;
573 config.models.insert(model_name.to_string(), model_cfg);
574 }
575 let config = state.config.read().await;
576 if let Some(paths) = ModelPaths::resolve(model_name, &config) {
577 return Ok(Some(paths));
578 }
579 return Err(ApiError::not_found(format!(
583 "catalog model '{model_name}' resolved to a config that ModelPaths \
584 could not turn into runtime paths — internal mismatch, please file an issue."
585 )));
586 }
587 Err(e) => return Err(resolve_error_to_api_error(&e)),
588 }
589 }
590
591 if mold_core::manifest::find_manifest(model_name).is_some() {
592 return Err(ApiError::not_found(format!(
593 "model '{model_name}' is not downloaded. Run: mold pull {model_name}"
594 )));
595 }
596 Err(ApiError::unknown_model(format!(
597 "unknown model '{model_name}'. Run 'mold list' to see available models."
598 )))
599}
600
601pub(crate) async fn estimate_generation_memory(
602 state: &AppState,
603 req: &GenerateRequest,
604) -> Result<GenerationMemoryEstimate, ApiError> {
605 let paths = match check_model_available(state, &req.model).await? {
606 Some(paths) => paths,
607 None => {
608 let config = state.config.read().await;
609 ModelPaths::resolve(&req.model, &config).ok_or_else(|| {
610 ApiError::not_found(format!(
611 "model '{}' is loaded but runtime paths are not available for estimation",
612 req.model
613 ))
614 })?
615 }
616 };
617 let hint = activation_hint_for_request(state, req).await;
618 let estimate = estimate_generation_memory_for_request(req, &paths, hint);
619
620 Ok(GenerationMemoryEstimate {
621 model: req.model.clone(),
622 peak_memory_bytes: estimate.peak_memory_bytes,
623 activation_memory_bytes: estimate.activation_memory_bytes,
624 available_memory_bytes: estimate.available_memory_bytes,
625 load_strategy: format!("{:?}", estimate.load_strategy).to_ascii_lowercase(),
626 fits_available_memory: estimate.fits_available_memory,
627 })
628}
629
630pub(crate) async fn model_component_status(
631 state: &AppState,
632 model_name: &str,
633) -> Result<ModelComponentsResponse, ApiError> {
634 let resolved = mold_core::manifest::resolve_model_name(model_name);
635 if let Some(manifest) = mold_core::manifest::find_manifest(&resolved) {
636 let config = state.config.read().await;
637 let models_dir = config.resolved_models_dir();
638 let components = manifest
639 .files
640 .iter()
641 .map(|file| {
642 let kind = manifest_component_kind(file.component);
643 let path = models_dir.join(mold_core::manifest::storage_path(manifest, file));
644 ModelComponentStatus {
645 kind: kind.to_string(),
646 name: manifest_component_name(file.component, &file.hf_filename).to_string(),
647 present: path.is_file(),
648 path: Some(path.to_string_lossy().to_string()),
649 repair_model: Some(resolved.clone()),
650 options: component_options_for_kind(&config, kind, Some(&path)),
651 }
652 })
653 .collect();
654 return Ok(ModelComponentsResponse {
655 model: resolved,
656 components,
657 });
658 }
659
660 let config = state.config.read().await;
661 let Some(paths) = ModelPaths::resolve(model_name, &config) else {
662 return Err(ApiError::unknown_model(format!(
663 "unknown model '{model_name}'. Run 'mold list' to see available models."
664 )));
665 };
666 Ok(ModelComponentsResponse {
667 model: model_name.to_string(),
668 components: component_status_from_paths(&config, model_name, &paths),
669 })
670}
671
672fn manifest_component_kind(component: mold_core::manifest::ModelComponent) -> &'static str {
673 use mold_core::manifest::ModelComponent;
674 match component {
675 ModelComponent::Transformer | ModelComponent::TransformerShard => "transformer",
676 ModelComponent::Vae => "vae",
677 ModelComponent::SpatialUpscaler => "spatial_upscaler",
678 ModelComponent::TemporalUpscaler => "temporal_upscaler",
679 ModelComponent::DistilledLora => "distilled_lora",
680 ModelComponent::T5Encoder | ModelComponent::TextEncoder => "text_encoder",
681 ModelComponent::ClipEncoder | ModelComponent::ClipEncoder2 => "clip",
682 ModelComponent::T5Tokenizer
683 | ModelComponent::ClipTokenizer
684 | ModelComponent::ClipTokenizer2
685 | ModelComponent::TextTokenizer => "tokenizer",
686 ModelComponent::Decoder => "decoder",
687 ModelComponent::Upscaler => "upscaler",
688 }
689}
690
691fn manifest_component_name(component: mold_core::manifest::ModelComponent, filename: &str) -> &str {
692 use mold_core::manifest::ModelComponent;
693 match component {
694 ModelComponent::Transformer => "transformer",
695 ModelComponent::TransformerShard => "transformer shard",
696 ModelComponent::Vae => "vae",
697 ModelComponent::SpatialUpscaler => "spatial upscaler",
698 ModelComponent::TemporalUpscaler => "temporal upscaler",
699 ModelComponent::DistilledLora => "distilled lora",
700 ModelComponent::T5Encoder => "t5 encoder",
701 ModelComponent::ClipEncoder => "clip encoder",
702 ModelComponent::T5Tokenizer => "t5 tokenizer",
703 ModelComponent::ClipTokenizer => "clip tokenizer",
704 ModelComponent::ClipEncoder2 => "clip-g encoder",
705 ModelComponent::ClipTokenizer2 => "clip-g tokenizer",
706 ModelComponent::TextEncoder => "text encoder",
707 ModelComponent::TextTokenizer => "text tokenizer",
708 ModelComponent::Decoder => "decoder",
709 ModelComponent::Upscaler => filename,
710 }
711}
712
713fn component_status_from_paths(
714 config: &Config,
715 model_name: &str,
716 paths: &ModelPaths,
717) -> Vec<ModelComponentStatus> {
718 let mut components = Vec::new();
719 let mut push_path = |kind: &str, name: &str, path: &std::path::Path| {
720 components.push(ModelComponentStatus {
721 kind: kind.to_string(),
722 name: name.to_string(),
723 present: path.is_file(),
724 path: Some(path.to_string_lossy().to_string()),
725 repair_model: Some(model_name.to_string()),
726 options: component_options_for_kind(config, kind, Some(path)),
727 });
728 };
729 push_path("transformer", "transformer", &paths.transformer);
730 for shard in &paths.transformer_shards {
731 push_path("transformer", "transformer shard", shard);
732 }
733 push_path("vae", "vae", &paths.vae);
734 if let Some(path) = &paths.spatial_upscaler {
735 push_path("spatial_upscaler", "spatial upscaler", path);
736 }
737 if let Some(path) = &paths.temporal_upscaler {
738 push_path("temporal_upscaler", "temporal upscaler", path);
739 }
740 if let Some(path) = &paths.distilled_lora {
741 push_path("distilled_lora", "distilled lora", path);
742 }
743 if let Some(path) = &paths.t5_encoder {
744 push_path("text_encoder", "t5 encoder", path);
745 }
746 if let Some(path) = &paths.clip_encoder {
747 push_path("clip", "clip encoder", path);
748 }
749 if let Some(path) = &paths.clip_encoder_2 {
750 push_path("clip", "clip-g encoder", path);
751 }
752 for path in &paths.text_encoder_files {
753 push_path("text_encoder", "text encoder", path);
754 }
755 if let Some(path) = &paths.decoder {
756 push_path("decoder", "decoder", path);
757 }
758 components
759}
760
761fn component_options_for_kind(
762 config: &Config,
763 kind: &str,
764 current_path: Option<&Path>,
765) -> Vec<ModelComponentOption> {
766 let mut options = BTreeMap::<String, ModelComponentOption>::new();
767 if let Some(path) = current_path {
768 add_component_option(&mut options, path);
769 }
770 for model_cfg in config.models.values() {
771 for path in config_component_paths_for_kind(model_cfg, kind) {
772 add_component_option(&mut options, Path::new(path));
773 }
774 }
775 let models_dir = config.resolved_models_dir();
776 for manifest in mold_core::manifest::known_manifests() {
777 for file in &manifest.files {
778 if manifest_component_kind(file.component) != kind {
779 continue;
780 }
781 let path = models_dir.join(mold_core::manifest::storage_path(manifest, file));
782 if path.is_file() {
783 add_component_option(&mut options, &path);
784 }
785 }
786 }
787 options.into_values().collect()
788}
789
790fn config_component_paths_for_kind<'a>(
791 model_cfg: &'a mold_core::config::ModelConfig,
792 kind: &str,
793) -> Vec<&'a str> {
794 let mut paths = Vec::new();
795 match kind {
796 "transformer" => {
797 if let Some(path) = model_cfg.transformer.as_deref() {
798 paths.push(path);
799 }
800 if let Some(shards) = &model_cfg.transformer_shards {
801 paths.extend(shards.iter().map(String::as_str));
802 }
803 }
804 "vae" => {
805 if let Some(path) = model_cfg.vae.as_deref() {
806 paths.push(path);
807 }
808 }
809 "text_encoder" => {
810 if let Some(path) = model_cfg.t5_encoder.as_deref() {
811 paths.push(path);
812 }
813 if let Some(files) = &model_cfg.text_encoder_files {
814 paths.extend(files.iter().map(String::as_str));
815 }
816 }
817 "clip" => {
818 if let Some(path) = model_cfg.clip_encoder.as_deref() {
819 paths.push(path);
820 }
821 if let Some(path) = model_cfg.clip_encoder_2.as_deref() {
822 paths.push(path);
823 }
824 }
825 "tokenizer" => {
826 for path in [
827 model_cfg.t5_tokenizer.as_deref(),
828 model_cfg.clip_tokenizer.as_deref(),
829 model_cfg.clip_tokenizer_2.as_deref(),
830 model_cfg.text_tokenizer.as_deref(),
831 ]
832 .into_iter()
833 .flatten()
834 {
835 paths.push(path);
836 }
837 }
838 "spatial_upscaler" => {
839 if let Some(path) = model_cfg.spatial_upscaler.as_deref() {
840 paths.push(path);
841 }
842 }
843 "temporal_upscaler" => {
844 if let Some(path) = model_cfg.temporal_upscaler.as_deref() {
845 paths.push(path);
846 }
847 }
848 "distilled_lora" => {
849 if let Some(path) = model_cfg.distilled_lora.as_deref() {
850 paths.push(path);
851 }
852 }
853 "decoder" => {
854 if let Some(path) = model_cfg.decoder.as_deref() {
855 paths.push(path);
856 }
857 }
858 _ => {}
859 }
860 paths
861}
862
863fn add_component_option(options: &mut BTreeMap<String, ModelComponentOption>, path: &Path) {
864 let path_str = path.to_string_lossy().to_string();
865 options.entry(path_str.clone()).or_insert_with(|| {
866 let label = path
867 .file_name()
868 .and_then(|name| name.to_str())
869 .unwrap_or(path_str.as_str())
870 .to_string();
871 ModelComponentOption {
872 label,
873 path: path_str,
874 present: path.is_file(),
875 }
876 });
877}
878
879pub(crate) async fn ensure_model_ready(
889 state: &AppState,
890 model_name: &str,
891 progress: Option<EngineProgressCallback>,
892 hint: Option<ActivationHint>,
893 request_has_lora: bool,
894) -> Result<(), ApiError> {
895 let _guard = state.model_load_lock.lock().await;
896
897 {
899 let mut cache = state.model_cache.lock().await;
900 let active_vram = cache.active_vram_bytes();
902 if let Some(entry) = cache.get_mut(model_name) {
903 if entry.residency == ModelResidency::Gpu {
904 let must_recreate = entry.engine.model_paths().is_some_and(|paths| {
905 request_requires_fresh_engine_for_offload_policy(paths, hint, request_has_lora)
906 });
907 if must_recreate {
908 tracing::info!(
909 model = %model_name,
910 "recreating loaded engine for request-specific offload policy"
911 );
912 } else {
913 if let Some(callback) = progress.clone() {
915 entry.engine.set_on_progress(Box::new(move |event| {
916 callback(event);
917 }));
918 } else {
919 entry.engine.clear_on_progress();
920 }
921 return Ok(());
922 }
923 }
924
925 let cached_paths = entry.engine.model_paths().cloned();
929 if let Some(paths) = cached_paths.as_ref() {
930 preflight_memory_guard(model_name, paths, active_vram, 0, hint)?;
931 }
932 let load_strategy = cached_paths
933 .as_ref()
934 .map(|paths| {
935 select_server_load_strategy_for_budget(
936 paths,
937 effective_load_available_bytes(active_vram, 0),
938 hint,
939 )
940 })
941 .unwrap_or(mold_inference::LoadStrategy::Eager);
942 if load_strategy == mold_inference::LoadStrategy::Sequential {
943 tracing::info!(
944 model = %model_name,
945 "server load strategy degraded to sequential to fit memory budget"
946 );
947 }
948
949 if let Some(active_name) = cache.unload_active() {
952 #[cfg(feature = "metrics")]
953 crate::metrics::clear_model_loaded(&active_name);
954 tracing::info!(
955 from = %active_name,
956 to = %model_name,
957 "unloaded active model to reload cached model"
958 );
959 mold_inference::reclaim_gpu_memory(0);
965 }
966
967 let cached = cache.take(model_name).ok_or_else(|| {
972 ApiError::internal(format!("cache race: model '{model_name}' vanished"))
973 })?;
974 drop(cache);
975
976 let mut engine = cached.engine;
977 if load_strategy == mold_inference::LoadStrategy::Sequential {
978 let Some(paths) = cached_paths else {
979 let evicted = {
980 let mut cache = state.model_cache.lock().await;
981 cache.insert(engine, 0)
982 };
983 drop(evicted);
984 return Err(ApiError::internal(format!(
985 "cached engine for '{model_name}' does not expose model paths"
986 )));
987 };
988 let config = state.config.read().await;
989 let offload = server_offload_enabled_for_paths(&paths, hint, request_has_lora);
990 let resolved_catalog_config =
991 resolve_installed_catalog_paths_for_worker(model_name, &config)?
992 .map(|(_, config)| config);
993 let engine_config = resolved_catalog_config.as_ref().unwrap_or(&config);
994 match mold_inference::create_engine_with_pool(
995 model_name.to_string(),
996 paths,
997 engine_config,
998 load_strategy,
999 0,
1000 offload,
1001 Some(state.shared_pool.clone()),
1002 ) {
1003 Ok(new_engine) => {
1004 drop(config);
1005 drop(engine);
1006 engine = new_engine;
1007 }
1008 Err(e) => {
1009 drop(config);
1010 let evicted = {
1011 let mut cache = state.model_cache.lock().await;
1012 cache.insert(engine, 0)
1013 };
1014 drop(evicted);
1015 return Err(ApiError::internal(format!(
1016 "failed to recreate cached engine for '{model_name}': {e}"
1017 )));
1018 }
1019 }
1020 }
1021
1022 if let Some(callback) = progress.clone() {
1023 engine.set_on_progress(Box::new(move |event| {
1024 callback(event);
1025 }));
1026 } else {
1027 engine.clear_on_progress();
1028 }
1029
1030 let model_log = model_name.to_string();
1031 #[cfg(feature = "metrics")]
1032 let load_start = std::time::Instant::now();
1033 let vram_baseline = mold_inference::device::vram_in_use_bytes(0);
1036 let join_result = tokio::task::spawn_blocking(move || {
1037 tracing::info!(model = %model_log, "reloading cached engine...");
1038 if let Err(e) = engine.load() {
1039 tracing::error!("model reload failed: {e:#}");
1040 return Err((
1041 ApiError::internal(format!("model reload error: {e}")),
1042 engine,
1043 ));
1044 }
1045 Ok(engine)
1046 })
1047 .await;
1048
1049 match join_result {
1050 Ok(Ok(loaded_engine)) => {
1051 #[cfg(feature = "metrics")]
1052 {
1053 let duration = load_start.elapsed().as_secs_f64();
1054 crate::metrics::record_model_load(model_name, duration);
1055 crate::metrics::set_model_loaded(model_name);
1056 let vram_est = mold_inference::device::vram_in_use_bytes(0);
1057 crate::metrics::record_gpu_memory(vram_est);
1058 }
1059 let vram = mold_inference::device::vram_load_delta(0, vram_baseline);
1060 let evicted = {
1065 let mut cache = state.model_cache.lock().await;
1066 cache.insert(loaded_engine, vram)
1067 };
1068 drop(evicted);
1069 }
1070 Ok(Err((api_err, unloaded_engine))) => {
1071 let evicted = {
1077 let mut cache = state.model_cache.lock().await;
1078 cache.insert(unloaded_engine, 0)
1079 };
1080 drop(evicted);
1081 return Err(api_err);
1082 }
1083 Err(join_err) => {
1084 {
1095 let mut cache = state.model_cache.lock().await;
1096 cache.clear_in_flight(model_name);
1097 }
1098 return Err(ApiError::internal(format!(
1099 "model reload task failed: {join_err}"
1100 )));
1101 }
1102 }
1103 return Ok(());
1104 }
1105 }
1106
1107 match check_model_available(state, model_name).await? {
1109 Some(paths) => create_and_load_engine(state, model_name, paths, progress, hint).await,
1110 None => Ok(()),
1111 }
1112}
1113
1114pub(crate) async fn pull_model(
1115 state: &AppState,
1116 model: &str,
1117 progress: Option<DownloadProgressCallback>,
1118) -> Result<PullStatus, ApiError> {
1119 if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(model)).is_none()
1120 {
1121 return Err(ApiError::unknown_model(format!(
1122 "unknown model '{model}'. Run 'mold list' to see available models."
1123 )));
1124 }
1125
1126 let _guard = state.pull_lock.lock().await;
1127
1128 {
1129 let config = refresh_config(state).await;
1130 if config.manifest_model_is_downloaded(model) {
1131 return Ok(PullStatus::AlreadyAvailable);
1132 }
1133 }
1134
1135 tracing::info!(model = %model, "pulling model via API");
1136
1137 let opts = mold_core::download::PullOptions::default();
1138 let new_config = match progress {
1139 Some(callback) => {
1140 mold_core::download::pull_and_configure_with_callback(model, callback, &opts)
1141 .await
1142 .map(|(config, _)| config)
1143 }
1144 None => mold_core::download::pull_and_configure(model, &opts)
1145 .await
1146 .map(|(config, _)| config),
1147 }
1148 .map_err(|e| {
1149 tracing::error!("pull failed for {}: {e}", model);
1150 ApiError::internal(format!("failed to pull model '{}': {e}", model))
1151 })?;
1152
1153 {
1154 let mut config = state.config.write().await;
1155 *config = new_config;
1156 }
1157
1158 tracing::info!(model = %model, "pull complete");
1159 Ok(PullStatus::Pulled)
1160}
1161
1162pub(crate) async fn unload_model(state: &AppState) -> String {
1165 if let Ok(mut upscaler) = state.upscaler_cache.try_lock() {
1170 if upscaler.is_some() {
1171 *upscaler = None;
1172 tracing::info!("upscaler cache cleared");
1173 }
1174 }
1175
1176 let mut cache = state.model_cache.lock().await;
1177 match cache.unload_active() {
1178 Some(name) => {
1179 #[cfg(feature = "metrics")]
1180 {
1181 crate::metrics::clear_model_loaded(&name);
1182 crate::metrics::record_gpu_memory(0);
1183 }
1184 drop(cache);
1185 mold_inference::reclaim_gpu_memory(0);
1191 tracing::info!(model = %name, "model unloaded via API");
1192 format!("unloaded {name}")
1193 }
1194 None => "no model loaded".to_string(),
1195 }
1196}
1197
1198async fn create_and_load_engine(
1199 state: &AppState,
1200 model_name: &str,
1201 paths: ModelPaths,
1202 progress: Option<EngineProgressCallback>,
1203 hint: Option<ActivationHint>,
1204) -> Result<(), ApiError> {
1205 let active_vram = {
1208 let cache = state.model_cache.lock().await;
1209 cache.active_vram_bytes()
1210 };
1211 preflight_memory_guard(model_name, &paths, active_vram, 0, hint)?;
1212 let load_strategy = select_server_load_strategy_for_device(
1213 &paths,
1214 effective_load_available_bytes(active_vram, 0),
1215 mold_inference::device::total_vram_bytes(0),
1216 hint,
1217 );
1218 if load_strategy == mold_inference::LoadStrategy::Sequential {
1219 tracing::info!(
1220 model = %model_name,
1221 "server load strategy degraded to sequential to fit memory budget"
1222 );
1223 }
1224
1225 let had_active = {
1230 let mut cache = state.model_cache.lock().await;
1231 let result = cache.unload_active();
1232 if let Some(ref name) = result {
1233 #[cfg(feature = "metrics")]
1234 crate::metrics::clear_model_loaded(name);
1235 tracing::info!(
1236 from = %name,
1237 to = %model_name,
1238 "unloading active model before loading new one"
1239 );
1240 }
1241 result.is_some()
1242 };
1243 if had_active {
1244 mold_inference::reclaim_gpu_memory(0);
1250 }
1251
1252 let config = state.config.read().await;
1253 let offload = server_offload_enabled_for_paths(&paths, hint, false);
1254 let mut new_engine = mold_inference::create_engine_with_pool(
1255 model_name.to_string(),
1256 paths,
1257 &config,
1258 load_strategy,
1259 0,
1260 offload,
1261 Some(state.shared_pool.clone()),
1262 )
1263 .map_err(|e| ApiError::internal(format!("failed to create engine for '{model_name}': {e}")))?;
1264 drop(config);
1265
1266 if let Some(callback) = progress {
1267 new_engine.set_on_progress(Box::new(move |event| {
1268 callback(event);
1269 }));
1270 } else {
1271 new_engine.clear_on_progress();
1272 }
1273
1274 let model_log = model_name.to_string();
1275 #[cfg(feature = "metrics")]
1276 let load_start = std::time::Instant::now();
1277 let vram_baseline = mold_inference::device::vram_in_use_bytes(0);
1280 new_engine = tokio::task::spawn_blocking(move || {
1281 tracing::info!(model = %model_log, "loading model...");
1282 new_engine.load().map_err(|e| {
1283 tracing::error!("model load failed: {e:#}");
1284 ApiError::internal(format!("model load error: {e}"))
1285 })?;
1286 Ok::<_, ApiError>(new_engine)
1287 })
1288 .await
1289 .map_err(|e| ApiError::internal(format!("model load task failed: {e}")))??;
1290
1291 #[cfg(feature = "metrics")]
1292 {
1293 let duration = load_start.elapsed().as_secs_f64();
1294 crate::metrics::record_model_load(model_name, duration);
1295 crate::metrics::set_model_loaded(model_name);
1296 }
1297
1298 let vram = mold_inference::device::vram_load_delta(0, vram_baseline);
1299 #[cfg(feature = "metrics")]
1300 crate::metrics::record_gpu_memory(mold_inference::device::vram_in_use_bytes(0));
1301
1302 let evicted = {
1306 let mut cache = state.model_cache.lock().await;
1307 cache.insert(new_engine, vram)
1308 };
1309 drop(evicted);
1310
1311 Ok(())
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use std::path::PathBuf;
1318
1319 const GB: u64 = 1_000_000_000;
1320
1321 struct Ltx2GemmaEnvGuard {
1326 _lock: std::sync::MutexGuard<'static, ()>,
1327 prior_main: Option<std::ffi::OsString>,
1328 prior_legacy: Option<std::ffi::OsString>,
1329 }
1330
1331 impl Drop for Ltx2GemmaEnvGuard {
1332 fn drop(&mut self) {
1333 unsafe {
1334 std::env::remove_var("MOLD_LTX2_GEMMA_DEVICE");
1335 std::env::remove_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
1336 if let Some(v) = self.prior_main.take() {
1337 std::env::set_var("MOLD_LTX2_GEMMA_DEVICE", v);
1338 }
1339 if let Some(v) = self.prior_legacy.take() {
1340 std::env::set_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER", v);
1341 }
1342 }
1343 }
1344 }
1345
1346 fn ltx2_gemma_env_guard(value: &str) -> Ltx2GemmaEnvGuard {
1347 use std::sync::{Mutex, OnceLock};
1348 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1349 let lock = LOCK
1350 .get_or_init(|| Mutex::new(()))
1351 .lock()
1352 .unwrap_or_else(|p| p.into_inner());
1353 let prior_main = std::env::var_os("MOLD_LTX2_GEMMA_DEVICE");
1354 let prior_legacy = std::env::var_os("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
1355 unsafe {
1356 std::env::remove_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
1357 std::env::set_var("MOLD_LTX2_GEMMA_DEVICE", value);
1358 }
1359 Ltx2GemmaEnvGuard {
1360 _lock: lock,
1361 prior_main,
1362 prior_legacy,
1363 }
1364 }
1365
1366 struct OffloadEnvGuard {
1367 _lock: std::sync::MutexGuard<'static, ()>,
1368 prior: Option<std::ffi::OsString>,
1369 }
1370
1371 impl Drop for OffloadEnvGuard {
1372 fn drop(&mut self) {
1373 unsafe {
1374 std::env::remove_var("MOLD_OFFLOAD");
1375 if let Some(v) = self.prior.take() {
1376 std::env::set_var("MOLD_OFFLOAD", v);
1377 }
1378 }
1379 }
1380 }
1381
1382 fn offload_env_guard(value: &str) -> OffloadEnvGuard {
1383 use std::sync::{Mutex, OnceLock};
1384 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1385 let lock = LOCK
1386 .get_or_init(|| Mutex::new(()))
1387 .lock()
1388 .unwrap_or_else(|p| p.into_inner());
1389 let prior = std::env::var_os("MOLD_OFFLOAD");
1390 unsafe {
1391 std::env::set_var("MOLD_OFFLOAD", value);
1392 }
1393 OffloadEnvGuard { _lock: lock, prior }
1394 }
1395
1396 fn test_paths_with_total_size(total_bytes: u64) -> (tempfile::TempDir, ModelPaths) {
1401 let dir = tempfile::tempdir().expect("tempdir");
1402 let transformer = dir.path().join("transformer.safetensors");
1403 let vae = dir.path().join("vae.safetensors");
1404 let half = total_bytes / 2;
1408 let rest = total_bytes - half;
1409 let f1 = std::fs::File::create(&transformer).expect("create transformer");
1410 f1.set_len(half).expect("set transformer len");
1411 let f2 = std::fs::File::create(&vae).expect("create vae");
1412 f2.set_len(rest).expect("set vae len");
1413
1414 let paths = ModelPaths {
1415 transformer,
1416 transformer_shards: Vec::new(),
1417 vae,
1418 spatial_upscaler: None,
1419 temporal_upscaler: None,
1420 distilled_lora: None,
1421 t5_encoder: None,
1422 clip_encoder: None,
1423 t5_tokenizer: None,
1424 clip_tokenizer: None,
1425 clip_encoder_2: None,
1426 clip_tokenizer_2: None,
1427 text_encoder_files: Vec::new(),
1428 text_tokenizer: None,
1429 decoder: None,
1430 };
1431 (dir, paths)
1432 }
1433
1434 #[test]
1439 fn test_paths_helper_sets_file_sizes() {
1440 let (_dir, paths) = test_paths_with_total_size(10 * GB);
1441 let peak = mold_inference::device::estimate_peak_memory(
1442 &paths,
1443 mold_inference::LoadStrategy::Eager,
1444 );
1445 assert!(
1446 peak >= 10 * GB,
1447 "expected peak >= 10 GB component sum, got {peak}"
1448 );
1449 assert!(PathBuf::from(&paths.transformer).exists());
1451 assert!(PathBuf::from(&paths.vae).exists());
1452 }
1453
1454 #[test]
1457 fn preflight_uses_active_vram_as_reclaimable() {
1458 let (_dir, paths) = test_paths_with_total_size(10 * GB);
1462 let result =
1463 preflight_memory_guard_with_available("swap-test", &paths, 10 * GB, 8 * GB, None);
1464 assert!(
1465 result.is_ok(),
1466 "expected swap to succeed with reclaimable VRAM, got {result:?}"
1467 );
1468 }
1469
1470 #[test]
1473 fn preflight_rejects_when_peak_exceeds_effective_available() {
1474 let (_dir, paths) = test_paths_with_total_size(20 * GB);
1477 let result = preflight_memory_guard_with_available("too-big", &paths, 5 * GB, 8 * GB, None);
1478 assert!(
1479 result.is_err(),
1480 "expected oversized model to be rejected, got Ok"
1481 );
1482 }
1483
1484 #[test]
1485 fn memory_guard_ok_when_plenty_of_memory() {
1486 assert!(check_model_memory_budget("test-model", 5 * GB, 20 * GB, "").is_ok());
1487 }
1488
1489 #[test]
1490 fn memory_guard_rejects_over_90pct() {
1491 let result = check_model_memory_budget("flux-dev:bf16", 19 * GB, 20 * GB, "Try --offload.");
1492 assert!(result.is_err());
1493 let err = result.unwrap_err();
1494 assert_eq!(err.code, "INSUFFICIENT_MEMORY");
1495 assert!(err.error.contains("flux-dev:bf16"));
1496 assert!(err.error.contains("budget cap"));
1497 }
1498
1499 #[test]
1500 fn memory_guard_ok_at_90pct_boundary() {
1501 assert!(check_model_memory_budget("test", 18 * GB, 20 * GB, "").is_ok());
1503 }
1504
1505 #[test]
1506 fn memory_guard_ok_in_warn_zone() {
1507 assert!(check_model_memory_budget("test", 17 * GB, 20 * GB, "").is_ok());
1509 }
1510
1511 #[test]
1512 fn memory_guard_ok_below_warn_zone() {
1513 assert!(check_model_memory_budget("test", 15 * GB, 20 * GB, "").is_ok());
1515 }
1516
1517 #[test]
1518 fn memory_guard_rejects_tiny_available() {
1519 let result = check_model_memory_budget("huge-model", 30 * GB, 16 * GB, "");
1521 assert!(result.is_err());
1522 }
1523
1524 #[test]
1529 fn memory_guard_swap_uses_active_vram_as_reclaimable() {
1530 let free_vram = 2 * GB;
1534 let active_vram = 18 * GB;
1535 let effective = free_vram + active_vram;
1536 assert!(check_model_memory_budget("swap-target", 15 * GB, effective, "").is_ok());
1537 }
1538
1539 #[test]
1542 fn memory_guard_swap_still_rejects_when_oversized() {
1543 let free_vram = GB;
1546 let active_vram = 8 * GB;
1547 let effective = free_vram + active_vram;
1548 assert!(check_model_memory_budget("too-large", 15 * GB, effective, "").is_err());
1549 }
1550
1551 fn flux_shaped_paths_with_sizes(
1555 transformer_gb: u64,
1556 vae_gb: u64,
1557 t5_gb: u64,
1558 clip_gb: u64,
1559 ) -> (tempfile::TempDir, ModelPaths) {
1560 let dir = tempfile::tempdir().expect("tempdir");
1561 let mk = |name: &str, sz: u64| {
1562 let p = dir.path().join(name);
1563 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1564 let f = std::fs::File::create(&p).unwrap();
1565 f.set_len(sz * GB).unwrap();
1566 p
1567 };
1568 let transformer = mk("transformer.safetensors", transformer_gb);
1569 let vae = mk("vae.safetensors", vae_gb);
1570 let t5 = mk("t5.safetensors", t5_gb);
1571 let clip = mk("clip.safetensors", clip_gb);
1572 let paths = ModelPaths {
1573 transformer,
1574 transformer_shards: Vec::new(),
1575 vae,
1576 spatial_upscaler: None,
1577 temporal_upscaler: None,
1578 distilled_lora: None,
1579 t5_encoder: Some(t5),
1580 clip_encoder: Some(clip),
1581 t5_tokenizer: None,
1582 clip_tokenizer: None,
1583 clip_encoder_2: None,
1584 clip_tokenizer_2: None,
1585 text_encoder_files: Vec::new(),
1586 text_tokenizer: None,
1587 decoder: None,
1588 };
1589 (dir, paths)
1590 }
1591
1592 #[test]
1602 fn preflight_passes_for_quantized_flux_on_24gb_card_with_swap() {
1603 let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
1607 let result = preflight_memory_guard_with_available("flux-dev:q8", &paths, 0, 24 * GB, None);
1612 assert!(
1613 result.is_ok(),
1614 "quantized FLUX must fit on a 24 GB card under the Sequential \
1615 peak estimate (drop-and-reload encoders), got {result:?}"
1616 );
1617 }
1618
1619 #[test]
1620 fn preflight_accepts_forced_flux_offload_bf16_layout_on_24gb() {
1621 let _guard = offload_env_guard("1");
1622 let (_dir, paths) = flux_shaped_paths_with_sizes(24, 1, 10, 1);
1623 let hint = ActivationHint {
1624 width: 1024,
1625 height: 1024,
1626 batch: 1,
1627 dtype_bytes: 2,
1628 family: ActivationFamily::FluxDit,
1629 };
1630
1631 let result =
1632 preflight_memory_guard_with_available("flux-dev:bf16", &paths, 0, 24 * GB, Some(hint));
1633
1634 assert!(
1635 result.is_ok(),
1636 "forced FLUX offload should use streaming-aware peak instead of \
1637 full BF16 transformer residency, got {result:?}"
1638 );
1639 }
1640
1641 #[test]
1642 fn preflight_accepts_large_flux_bf16_auto_offload_on_24gb() {
1643 let _guard = offload_env_guard("0");
1644 let (_dir, paths) = flux_shaped_paths_with_sizes(23, 1, 9, 1);
1645 let hint = ActivationHint {
1646 width: 1024,
1647 height: 1024,
1648 batch: 1,
1649 dtype_bytes: 2,
1650 family: ActivationFamily::FluxDit,
1651 };
1652
1653 let result = preflight_memory_guard_with_available(
1654 "cv:2319074",
1655 &paths,
1656 0,
1657 24_500_000_000,
1658 Some(hint),
1659 );
1660
1661 assert!(
1662 result.is_ok(),
1663 "large FLUX BF16 checkpoints should be admitted on 24 GB cards via \
1664 automatic block offload instead of being rejected by resident \
1665 transformer peak math, got {result:?}"
1666 );
1667 }
1668
1669 #[test]
1670 fn server_auto_enables_offload_for_large_flux_bf16_without_env() {
1671 let _guard = offload_env_guard("0");
1672 let (_dir, paths) = flux_shaped_paths_with_sizes(23, 1, 9, 1);
1673 let hint = ActivationHint {
1674 width: 1024,
1675 height: 1024,
1676 batch: 1,
1677 dtype_bytes: 2,
1678 family: ActivationFamily::FluxDit,
1679 };
1680
1681 assert!(
1682 server_offload_enabled_for_paths(&paths, Some(hint), false),
1683 "large FLUX BF16 checkpoints should load with block offload even \
1684 when MOLD_OFFLOAD is not globally forced"
1685 );
1686 }
1687
1688 fn sd3_gguf_paths_with_monolithic_vae(
1689 transformer_gb: u64,
1690 vae_gb: u64,
1691 t5_gb: u64,
1692 clip_l_gb: u64,
1693 clip_g_gb: u64,
1694 ) -> (tempfile::TempDir, ModelPaths) {
1695 let dir = tempfile::tempdir().expect("tempdir");
1696 let mk = |name: &str, sz: u64| {
1697 let p = dir.path().join(name);
1698 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1699 let f = std::fs::File::create(&p).unwrap();
1700 f.set_len(sz * GB).unwrap();
1701 p
1702 };
1703 let transformer = mk("sd3.5_large-Q8_0.gguf", transformer_gb);
1704 let vae = mk("sd3.5_large.safetensors", vae_gb);
1705 let t5 = mk("t5xxl_fp16.safetensors", t5_gb);
1706 let clip_l = mk("clip_l.safetensors", clip_l_gb);
1707 let clip_g = mk("clip_g.safetensors", clip_g_gb);
1708 let paths = ModelPaths {
1709 transformer,
1710 transformer_shards: Vec::new(),
1711 vae,
1712 spatial_upscaler: None,
1713 temporal_upscaler: None,
1714 distilled_lora: None,
1715 t5_encoder: Some(t5),
1716 clip_encoder: Some(clip_l),
1717 t5_tokenizer: None,
1718 clip_tokenizer: None,
1719 clip_encoder_2: Some(clip_g),
1720 clip_tokenizer_2: None,
1721 text_encoder_files: Vec::new(),
1722 text_tokenizer: None,
1723 decoder: None,
1724 };
1725 (dir, paths)
1726 }
1727
1728 #[test]
1729 fn preflight_accepts_sd3_gguf_with_monolithic_vae_on_24gb() {
1730 let (_dir, paths) = sd3_gguf_paths_with_monolithic_vae(9, 16, 10, 1, 1);
1731 let hint = ActivationHint {
1732 width: 1024,
1733 height: 1024,
1734 batch: 2,
1735 dtype_bytes: 2,
1736 family: ActivationFamily::Sd3Mmdit,
1737 };
1738
1739 let result =
1740 preflight_memory_guard_with_available("sd3.5-large:q8", &paths, 0, 24 * GB, Some(hint));
1741
1742 assert!(
1743 result.is_ok(),
1744 "SD3 GGUF should not count the monolithic VAE checkpoint as \
1745 co-resident with the transformer, got {result:?}"
1746 );
1747 }
1748
1749 #[test]
1750 fn server_load_strategy_keeps_sd3_gguf_eager() {
1751 let (_dir, paths) = sd3_gguf_paths_with_monolithic_vae(9, 16, 10, 1, 1);
1752 let hint = ActivationHint {
1753 width: 1024,
1754 height: 1024,
1755 batch: 2,
1756 dtype_bytes: 2,
1757 family: ActivationFamily::Sd3Mmdit,
1758 };
1759
1760 let strategy = select_server_load_strategy_for_budget(&paths, Some(32 * GB), Some(hint));
1761
1762 assert_eq!(
1763 strategy,
1764 mold_inference::LoadStrategy::Eager,
1765 "SD3 GGUF has its own quantized runtime path; selecting Sequential \
1766 asks the runtime for unsupported block offload"
1767 );
1768 }
1769
1770 fn zimage_gguf_paths(
1771 transformer_gb: u64,
1772 vae_gb: u64,
1773 text_encoder_gb: u64,
1774 ) -> (tempfile::TempDir, ModelPaths) {
1775 let dir = tempfile::tempdir().expect("tempdir");
1776 let mk = |name: &str, sz: u64| {
1777 let p = dir.path().join(name);
1778 let f = std::fs::File::create(&p).unwrap();
1779 f.set_len(sz * GB).unwrap();
1780 p
1781 };
1782 let transformer = mk("z-image-turbo-Q8_0.gguf", transformer_gb);
1783 let vae = mk("vae.safetensors", vae_gb);
1784 let text_encoder = mk("qwen3.safetensors", text_encoder_gb);
1785 let paths = ModelPaths {
1786 transformer,
1787 transformer_shards: Vec::new(),
1788 vae,
1789 spatial_upscaler: None,
1790 temporal_upscaler: None,
1791 distilled_lora: None,
1792 t5_encoder: None,
1793 clip_encoder: None,
1794 t5_tokenizer: None,
1795 clip_tokenizer: None,
1796 clip_encoder_2: None,
1797 clip_tokenizer_2: None,
1798 text_encoder_files: vec![text_encoder],
1799 text_tokenizer: None,
1800 decoder: None,
1801 };
1802 (dir, paths)
1803 }
1804
1805 #[test]
1806 fn server_load_strategy_keeps_zimage_gguf_eager() {
1807 let (_dir, paths) = zimage_gguf_paths(12, 1, 8);
1808 let hint = ActivationHint {
1809 width: 1024,
1810 height: 1024,
1811 batch: 1,
1812 dtype_bytes: 2,
1813 family: ActivationFamily::ZImageDit,
1814 };
1815
1816 let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), Some(hint));
1817
1818 assert_eq!(
1819 strategy,
1820 mold_inference::LoadStrategy::Eager,
1821 "Z-Image GGUF has a quantized/dense runtime path; selecting Sequential \
1822 asks the runtime for unsupported block offload"
1823 );
1824 }
1825
1826 #[test]
1827 fn offload_env_is_ignored_for_sd3_gguf() {
1828 let _guard = offload_env_guard("1");
1829 let (_dir, paths) = sd3_gguf_paths_with_monolithic_vae(9, 16, 10, 1, 1);
1830 let hint = ActivationHint {
1831 width: 1024,
1832 height: 1024,
1833 batch: 2,
1834 dtype_bytes: 2,
1835 family: ActivationFamily::Sd3Mmdit,
1836 };
1837
1838 assert!(
1839 !server_offload_enabled_for_paths(&paths, Some(hint), false),
1840 "global MOLD_OFFLOAD must not force unsupported SD3 GGUF block offload"
1841 );
1842 }
1843
1844 #[test]
1845 fn offload_env_is_ignored_for_zimage_gguf() {
1846 let _guard = offload_env_guard("1");
1847 let (_dir, paths) = zimage_gguf_paths(12, 1, 8);
1848 let hint = ActivationHint {
1849 width: 1024,
1850 height: 1024,
1851 batch: 1,
1852 dtype_bytes: 2,
1853 family: ActivationFamily::ZImageDit,
1854 };
1855
1856 assert!(
1857 !server_offload_enabled_for_paths(&paths, Some(hint), false),
1858 "global MOLD_OFFLOAD must not force unsupported Z-Image GGUF block offload"
1859 );
1860 }
1861
1862 #[test]
1863 fn offload_env_is_preserved_for_zimage_bf16() {
1864 let _guard = offload_env_guard("1");
1865 let (_dir, paths) = flux_shaped_paths_with_sizes(6, 1, 8, 0);
1866 let hint = ActivationHint {
1867 width: 1024,
1868 height: 1024,
1869 batch: 1,
1870 dtype_bytes: 2,
1871 family: ActivationFamily::ZImageDit,
1872 };
1873
1874 assert!(
1875 server_offload_enabled_for_paths(&paths, Some(hint), false),
1876 "BF16/FP Z-Image paths should still receive explicit offload"
1877 );
1878 }
1879
1880 #[test]
1881 fn offload_env_is_ignored_for_zimage_lora_with_ambiguous_family_hint() {
1882 let _guard = offload_env_guard("1");
1883 let dir = tempfile::tempdir().expect("tempdir");
1884 let mk = |name: &str, sz: u64| {
1885 let p = dir.path().join(name);
1886 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1887 let f = std::fs::File::create(&p).unwrap();
1888 f.set_len(sz * GB).unwrap();
1889 p
1890 };
1891 let paths = ModelPaths {
1892 transformer: mk("z-image/civitai/2442439/zImageTurbo_turbo.safetensors", 12),
1893 transformer_shards: Vec::new(),
1894 vae: mk("z-image/civitai/2442439/ae_zimgturbo.safetensors", 1),
1895 spatial_upscaler: None,
1896 temporal_upscaler: None,
1897 distilled_lora: None,
1898 t5_encoder: None,
1899 clip_encoder: None,
1900 t5_tokenizer: None,
1901 clip_tokenizer: None,
1902 clip_encoder_2: None,
1903 clip_tokenizer_2: None,
1904 text_encoder_files: vec![mk(
1905 "z-image/civitai/2442439/zImageTurbo_turbo_txt.safetensors",
1906 8,
1907 )],
1908 text_tokenizer: None,
1909 decoder: None,
1910 };
1911 let hint = ActivationHint {
1912 width: 1024,
1913 height: 1024,
1914 batch: 1,
1915 dtype_bytes: 2,
1916 family: ActivationFamily::FluxDit,
1917 };
1918
1919 assert!(
1920 !server_offload_enabled_for_paths(&paths, Some(hint), true),
1921 "Z-Image LoRA requests must not receive global MOLD_OFFLOAD even \
1922 when duplicate catalog rows provide an ambiguous Flux hint"
1923 );
1924 }
1925
1926 #[test]
1927 fn offload_env_is_ignored_for_flux2_lora_request() {
1928 let _guard = offload_env_guard("1");
1929 let (_dir, paths) = flux2_klein9b_bf16_paths();
1930 let hint = ActivationHint {
1931 width: 1024,
1932 height: 1024,
1933 batch: 1,
1934 dtype_bytes: 2,
1935 family: ActivationFamily::Flux2Dit,
1936 };
1937
1938 assert!(
1939 !server_offload_enabled_for_paths(&paths, Some(hint), true),
1940 "global MOLD_OFFLOAD must not force Flux.2 block offload for LoRA \
1941 requests because Flux.2 offload+LoRA is not supported"
1942 );
1943 }
1944
1945 #[test]
1946 fn offload_env_is_ignored_for_flux2_lora_with_ambiguous_family_hint() {
1947 let _guard = offload_env_guard("1");
1948 let dir = tempfile::tempdir().expect("tempdir");
1949 let mk = |name: &str, sz: u64| {
1950 let p = dir.path().join(name);
1951 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1952 let f = std::fs::File::create(&p).unwrap();
1953 f.set_len(sz * GB).unwrap();
1954 p
1955 };
1956 let transformer = mk(
1957 "flux2/civitai/2669986/darkBeast_dbkBlitzV15.safetensors",
1958 18,
1959 );
1960 let paths = ModelPaths {
1961 transformer: transformer.clone(),
1962 transformer_shards: vec![transformer],
1963 vae: mk("flux2/civitai/2669986/flux2-vae.safetensors", 1),
1964 spatial_upscaler: None,
1965 temporal_upscaler: None,
1966 distilled_lora: None,
1967 t5_encoder: None,
1968 clip_encoder: None,
1969 t5_tokenizer: None,
1970 clip_tokenizer: None,
1971 clip_encoder_2: None,
1972 clip_tokenizer_2: None,
1973 text_encoder_files: vec![mk("flux2/civitai/2669986/qwen3.safetensors", 16)],
1974 text_tokenizer: None,
1975 decoder: None,
1976 };
1977 let hint = ActivationHint {
1978 width: 1024,
1979 height: 1024,
1980 batch: 1,
1981 dtype_bytes: 2,
1982 family: ActivationFamily::FluxDit,
1983 };
1984
1985 assert!(
1986 !server_offload_enabled_for_paths(&paths, Some(hint), true),
1987 "Flux.2 LoRA requests must not receive global MOLD_OFFLOAD even \
1988 when the catalog family hint is missing or ambiguous"
1989 );
1990 }
1991
1992 #[test]
1993 fn flux2_lora_request_requires_fresh_engine_when_plain_offload_was_enabled() {
1994 let _guard = offload_env_guard("1");
1995 let dir = tempfile::tempdir().expect("tempdir");
1996 let mk = |name: &str, sz: u64| {
1997 let p = dir.path().join(name);
1998 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1999 let f = std::fs::File::create(&p).unwrap();
2000 f.set_len(sz * GB).unwrap();
2001 p
2002 };
2003 let paths = ModelPaths {
2004 transformer: mk(
2005 "flux2/civitai/2669986/darkBeast_dbkBlitzV15.safetensors",
2006 18,
2007 ),
2008 transformer_shards: Vec::new(),
2009 vae: mk("flux2/civitai/2669986/flux2-vae.safetensors", 1),
2010 spatial_upscaler: None,
2011 temporal_upscaler: None,
2012 distilled_lora: None,
2013 t5_encoder: None,
2014 clip_encoder: None,
2015 t5_tokenizer: None,
2016 clip_tokenizer: None,
2017 clip_encoder_2: None,
2018 clip_tokenizer_2: None,
2019 text_encoder_files: vec![mk("flux2/civitai/2669986/qwen3.safetensors", 16)],
2020 text_tokenizer: None,
2021 decoder: None,
2022 };
2023 let hint = ActivationHint {
2024 width: 1024,
2025 height: 1024,
2026 batch: 1,
2027 dtype_bytes: 2,
2028 family: ActivationFamily::Flux2Dit,
2029 };
2030
2031 assert!(
2032 request_requires_fresh_engine_for_offload_policy(&paths, Some(hint), true),
2033 "a cached Flux.2 engine loaded for plain offload must be recreated \
2034 before serving a LoRA request, otherwise the runtime still sees \
2035 offload+LoRA"
2036 );
2037 }
2038
2039 #[test]
2040 fn offload_env_is_preserved_for_plain_flux2_request() {
2041 let _guard = offload_env_guard("1");
2042 let (_dir, paths) = flux2_klein9b_bf16_paths();
2043 let hint = ActivationHint {
2044 width: 1024,
2045 height: 1024,
2046 batch: 1,
2047 dtype_bytes: 2,
2048 family: ActivationFamily::Flux2Dit,
2049 };
2050
2051 assert!(
2052 server_offload_enabled_for_paths(&paths, Some(hint), false),
2053 "plain Flux.2 requests should still receive explicit offload"
2054 );
2055 }
2056
2057 #[test]
2058 fn offload_env_is_ignored_for_flux2_gguf() {
2059 let _guard = offload_env_guard("1");
2060 let (dir, mut paths) = flux2_klein9b_bf16_paths();
2061 let gguf = dir.path().join("flux2-klein-9b-q8.gguf");
2062 std::fs::File::create(&gguf)
2063 .unwrap()
2064 .set_len(12 * GB)
2065 .unwrap();
2066 paths.transformer = gguf;
2067 paths.transformer_shards.clear();
2068 let hint = ActivationHint {
2069 width: 1024,
2070 height: 1024,
2071 batch: 1,
2072 dtype_bytes: 2,
2073 family: ActivationFamily::Flux2Dit,
2074 };
2075
2076 assert!(
2077 !server_offload_enabled_for_paths(&paths, Some(hint), false),
2078 "global MOLD_OFFLOAD must not force Flux.2 GGUF block offload \
2079 because GGUF variants use quantized transformer paths"
2080 );
2081 }
2082
2083 #[test]
2084 fn offload_env_is_ignored_for_flux2_nvfp4() {
2085 let _guard = offload_env_guard("1");
2086 let dir = tempfile::tempdir().expect("tempdir");
2087 let mk = |name: &str, sz: u64| {
2088 let p = dir.path().join(name);
2089 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2090 let f = std::fs::File::create(&p).unwrap();
2091 f.set_len(sz * GB).unwrap();
2092 p
2093 };
2094 let paths = ModelPaths {
2095 transformer: mk(
2096 "flux2/civitai/2759597/miracleinNSFWGeneration_10Nvfp4.safetensors",
2097 18,
2098 ),
2099 transformer_shards: Vec::new(),
2100 vae: mk("flux2/civitai/2759597/flux2-vae.safetensors", 1),
2101 spatial_upscaler: None,
2102 temporal_upscaler: None,
2103 distilled_lora: None,
2104 t5_encoder: None,
2105 clip_encoder: None,
2106 t5_tokenizer: None,
2107 clip_tokenizer: None,
2108 clip_encoder_2: None,
2109 clip_tokenizer_2: None,
2110 text_encoder_files: vec![mk("flux2/civitai/2759597/qwen3.safetensors", 8)],
2111 text_tokenizer: None,
2112 decoder: None,
2113 };
2114 let hint = ActivationHint {
2115 width: 1024,
2116 height: 1024,
2117 batch: 1,
2118 dtype_bytes: 2,
2119 family: ActivationFamily::Flux2Dit,
2120 };
2121
2122 assert!(
2123 !server_offload_enabled_for_paths(&paths, Some(hint), false),
2124 "global MOLD_OFFLOAD must not force Flux.2 NVFP4 block offload \
2125 because the NVFP4 streaming linear path is the memory-control mechanism"
2126 );
2127 }
2128
2129 fn qwen_image_q8_paths(
2130 transformer_gb: u64,
2131 vae_gb: u64,
2132 text_encoder_gb: u64,
2133 ) -> (tempfile::TempDir, ModelPaths) {
2134 let dir = tempfile::tempdir().expect("tempdir");
2135 let mk = |name: &str, sz: u64| {
2136 let p = dir.path().join(name);
2137 let f = std::fs::File::create(&p).unwrap();
2138 f.set_len(sz * GB).unwrap();
2139 p
2140 };
2141 let transformer = mk("qwen-image-Q8_0.gguf", transformer_gb);
2142 let vae = mk("qwen-image-vae.safetensors", vae_gb);
2143 let text_encoder = mk("qwen2.5-vl.safetensors", text_encoder_gb);
2144 let paths = ModelPaths {
2145 transformer,
2146 transformer_shards: Vec::new(),
2147 vae,
2148 spatial_upscaler: None,
2149 temporal_upscaler: None,
2150 distilled_lora: None,
2151 t5_encoder: None,
2152 clip_encoder: None,
2153 t5_tokenizer: None,
2154 clip_tokenizer: None,
2155 clip_encoder_2: None,
2156 clip_tokenizer_2: None,
2157 text_encoder_files: vec![text_encoder],
2158 text_tokenizer: None,
2159 decoder: None,
2160 };
2161 (dir, paths)
2162 }
2163
2164 #[test]
2165 fn preflight_accepts_quantized_qwen_image_q8_on_24gb() {
2166 let (_dir, paths) = qwen_image_q8_paths(21, 1, 16);
2167 let hint = ActivationHint {
2168 width: 1024,
2169 height: 1024,
2170 batch: 2,
2171 dtype_bytes: 2,
2172 family: ActivationFamily::QwenImageDit,
2173 };
2174
2175 let result =
2176 preflight_memory_guard_with_available("qwen-image:q8", &paths, 0, 24 * GB, Some(hint));
2177
2178 assert!(
2179 result.is_ok(),
2180 "Qwen-Image GGUF Q8 should be admitted on 24 GB because the runtime \
2181 uses split-CFG and staged text/VAE phases instead of the generic \
2182 full-headroom sequential estimate, got {result:?}"
2183 );
2184 }
2185
2186 #[test]
2187 fn server_load_strategy_uses_sequential_for_zimage_requests() {
2188 let (_dir, paths) = flux_shaped_paths_with_sizes(6, 1, 8, 0);
2189 let hint = ActivationHint {
2190 width: 1024,
2191 height: 1024,
2192 batch: 1,
2193 dtype_bytes: 2,
2194 family: ActivationFamily::ZImageDit,
2195 };
2196
2197 let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), Some(hint));
2198
2199 assert_eq!(
2200 strategy,
2201 mold_inference::LoadStrategy::Sequential,
2202 "Z-Image server requests should use staged loading so base/source/LoRA \
2203 share the same memory contract"
2204 );
2205 }
2206
2207 #[test]
2211 fn eager_strategy_would_have_rejected_quantized_flux_on_24gb() {
2212 let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
2213 let eager_peak = mold_inference::device::estimate_peak_memory(
2214 &paths,
2215 mold_inference::LoadStrategy::Eager,
2216 );
2217 let hard_limit = (24 * GB) * 9 / 10;
2219 assert!(
2220 eager_peak > hard_limit,
2221 "Eager peak ({eager_peak}) should exceed hard limit ({hard_limit}) — \
2222 this is the false-rejection the Sequential switch fixes"
2223 );
2224 }
2225
2226 #[test]
2227 fn server_load_strategy_degrades_when_only_sequential_fits() {
2228 let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
2229 let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), None);
2230
2231 assert_eq!(
2232 strategy,
2233 mold_inference::LoadStrategy::Sequential,
2234 "server load should match the sequential preflight assumption instead \
2235 of eager-loading a model whose summed components exceed the budget"
2236 );
2237 }
2238
2239 #[test]
2240 fn server_load_strategy_stays_eager_when_eager_fits() {
2241 let (_dir, paths) = flux_shaped_paths_with_sizes(8, 1, 2, 1);
2242 let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), None);
2243
2244 assert_eq!(strategy, mold_inference::LoadStrategy::Eager);
2245 }
2246
2247 #[test]
2248 fn server_load_strategy_stays_eager_when_no_budget_available() {
2249 let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
2250 let strategy = select_server_load_strategy_for_budget(&paths, None, None);
2251
2252 assert_eq!(strategy, mold_inference::LoadStrategy::Eager);
2253 }
2254
2255 #[test]
2260 fn preflight_memory_guard_accepts_resolution_for_activation_budget() {
2261 let _guard = offload_env_guard("0");
2262 let (_dir, paths) = flux_shaped_paths_with_sizes(19, 1, 9, 1);
2269
2270 let hint_768 = ActivationHint {
2271 width: 768,
2272 height: 768,
2273 batch: 1,
2274 dtype_bytes: 2,
2275 family: ActivationFamily::FluxDit,
2276 };
2277 let hint_2048 = ActivationHint {
2278 width: 2048,
2279 height: 2048,
2280 batch: 1,
2281 dtype_bytes: 2,
2282 family: ActivationFamily::FluxDit,
2283 };
2284
2285 let card_total = 25 * GB;
2286 let result_768 = preflight_memory_guard_with_available(
2287 "flux-dev",
2288 &paths,
2289 0,
2290 card_total,
2291 Some(hint_768),
2292 );
2293 let result_2048 = preflight_memory_guard_with_available(
2294 "flux-dev",
2295 &paths,
2296 0,
2297 card_total,
2298 Some(hint_2048),
2299 );
2300
2301 assert!(
2302 result_768.is_ok(),
2303 "768² FLUX should fit on 30 GB (small activation budget), got {result_768:?}"
2304 );
2305 assert!(
2306 result_2048.is_err(),
2307 "2048² FLUX must be rejected on 30 GB (large activation budget pushes \
2308 peak past 90 % cap), got {result_2048:?}"
2309 );
2310 }
2311
2312 fn flux2_klein9b_bf16_paths() -> (tempfile::TempDir, ModelPaths) {
2313 let dir = tempfile::tempdir().expect("tempdir");
2314 let mk = |name: &str, sz: u64| {
2315 let p = dir.path().join(name);
2316 let f = std::fs::File::create(&p).unwrap();
2317 f.set_len(sz * GB).unwrap();
2318 p
2319 };
2320 let shard_a = mk("diffusion_pytorch_model-00001-of-00002.safetensors", 10);
2321 let shard_b = mk("diffusion_pytorch_model-00002-of-00002.safetensors", 8);
2322 let vae = mk("flux2-vae.safetensors", 1);
2323 let te_a = mk("text_encoder-00001-of-00004.safetensors", 5);
2324 let te_b = mk("text_encoder-00002-of-00004.safetensors", 5);
2325 let te_c = mk("text_encoder-00003-of-00004.safetensors", 5);
2326 let te_d = mk("text_encoder-00004-of-00004.safetensors", 1);
2327 let paths = ModelPaths {
2328 transformer: shard_a.clone(),
2329 transformer_shards: vec![shard_a, shard_b],
2330 vae,
2331 spatial_upscaler: None,
2332 temporal_upscaler: None,
2333 distilled_lora: None,
2334 t5_encoder: None,
2335 clip_encoder: None,
2336 t5_tokenizer: None,
2337 clip_tokenizer: None,
2338 clip_encoder_2: None,
2339 clip_tokenizer_2: None,
2340 text_encoder_files: vec![te_a, te_b, te_c, te_d],
2341 text_tokenizer: None,
2342 decoder: None,
2343 };
2344 (dir, paths)
2345 }
2346
2347 fn flux2_large_bf16_paths_with_quantized_encoder() -> (tempfile::TempDir, ModelPaths) {
2348 let dir = tempfile::tempdir().expect("tempdir");
2349 let mk = |name: &str, sz: u64| {
2350 let p = dir.path().join(name);
2351 let f = std::fs::File::create(&p).unwrap();
2352 f.set_len(sz * GB).unwrap();
2353 p
2354 };
2355 let shard_a = mk("diffusion_pytorch_model-00001-of-00002.safetensors", 10);
2356 let shard_b = mk("diffusion_pytorch_model-00002-of-00002.safetensors", 8);
2357 let vae = mk("flux2-vae.safetensors", 1);
2358 let qwen3_q3 = mk("qwen3-q3.gguf", 3);
2359 let paths = ModelPaths {
2360 transformer: shard_a.clone(),
2361 transformer_shards: vec![shard_a, shard_b],
2362 vae,
2363 spatial_upscaler: None,
2364 temporal_upscaler: None,
2365 distilled_lora: None,
2366 t5_encoder: None,
2367 clip_encoder: None,
2368 t5_tokenizer: None,
2369 clip_tokenizer: None,
2370 clip_encoder_2: None,
2371 clip_tokenizer_2: None,
2372 text_encoder_files: vec![qwen3_q3],
2373 text_tokenizer: None,
2374 decoder: None,
2375 };
2376 (dir, paths)
2377 }
2378
2379 #[test]
2380 fn preflight_allows_flux2_klein9b_bf16_on_24gb_when_sequential_budget_fits() {
2381 let (_dir, paths) = flux2_klein9b_bf16_paths();
2382 let hint = ActivationHint {
2383 width: 1024,
2384 height: 1024,
2385 batch: 1,
2386 dtype_bytes: 2,
2387 family: ActivationFamily::Flux2Dit,
2388 };
2389
2390 let result = preflight_memory_guard_with_available(
2391 "flux2-klein-9b:bf16",
2392 &paths,
2393 0,
2394 24 * GB,
2395 Some(hint),
2396 );
2397
2398 assert!(
2399 result.is_ok(),
2400 "Klein-9B BF16 should be admitted on a 24 GB card when the \
2401 sequential transformer/VAE phase plus activation budget fits; \
2402 Qwen3 can be quantized/dropped before denoise, got {result:?}"
2403 );
2404 }
2405
2406 #[test]
2407 fn preflight_rejects_flux2_klein9b_bf16_on_24gb_when_activation_budget_exceeds_cap() {
2408 let (_dir, paths) = flux2_klein9b_bf16_paths();
2409 let hint = ActivationHint {
2410 width: 2048,
2411 height: 2048,
2412 batch: 1,
2413 dtype_bytes: 2,
2414 family: ActivationFamily::Flux2Dit,
2415 };
2416
2417 let result = preflight_memory_guard_with_available(
2418 "flux2-klein-9b:bf16",
2419 &paths,
2420 0,
2421 24 * GB,
2422 Some(hint),
2423 );
2424
2425 assert!(
2426 result.is_err(),
2427 "Klein-9B BF16 should still reject when resolution-scaled \
2428 activation budget pushes the sequential phase past the 90% cap, got {result:?}"
2429 );
2430 }
2431
2432 #[test]
2433 fn server_load_strategy_degrades_flux2_klein9b_bf16_on_24gb_to_sequential() {
2434 let (_dir, paths) = flux2_klein9b_bf16_paths();
2435 let hint = ActivationHint {
2436 width: 1024,
2437 height: 1024,
2438 batch: 1,
2439 dtype_bytes: 2,
2440 family: ActivationFamily::Flux2Dit,
2441 };
2442
2443 let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), Some(hint));
2444
2445 assert_eq!(
2446 strategy,
2447 mold_inference::LoadStrategy::Sequential,
2448 "server must use load-use-drop for Klein-9B BF16 on 24 GB so the \
2449 text encoder is not co-resident with the transformer"
2450 );
2451 }
2452
2453 #[test]
2454 fn server_load_strategy_degrades_large_flux2_bf16_even_with_quantized_encoder() {
2455 let (_dir, paths) = flux2_large_bf16_paths_with_quantized_encoder();
2456 let hint = ActivationHint {
2457 width: 1024,
2458 height: 1024,
2459 batch: 1,
2460 dtype_bytes: 2,
2461 family: ActivationFamily::Flux2Dit,
2462 };
2463
2464 let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), Some(hint));
2465
2466 assert_eq!(
2467 strategy,
2468 mold_inference::LoadStrategy::Sequential,
2469 "large Flux.2 BF16 transformer shards need load-use-drop on 24 GB \
2470 even when Qwen3 resolves to a small quantized encoder"
2471 );
2472 }
2473
2474 #[test]
2475 fn server_load_strategy_forces_klein9b_bf16_sequential_on_24gb_even_with_overgenerous_budget() {
2476 let (_dir, paths) = flux2_klein9b_bf16_paths();
2477 let hint = ActivationHint {
2478 width: 1024,
2479 height: 1024,
2480 batch: 1,
2481 dtype_bytes: 2,
2482 family: ActivationFamily::Flux2Dit,
2483 };
2484
2485 let strategy = select_server_load_strategy_for_device(
2486 &paths,
2487 Some(128 * GB),
2488 Some(24 * GB),
2489 Some(hint),
2490 );
2491
2492 assert_eq!(
2493 strategy,
2494 mold_inference::LoadStrategy::Sequential,
2495 "Klein-9B BF16 must not use eager loading on 24 GB cards even if \
2496 the live free-memory query is over-generous or falls back to \
2497 system memory"
2498 );
2499 }
2500
2501 #[test]
2502 fn server_load_strategy_caps_overgenerous_budget_for_klein_like_bf16_model() {
2503 let (_dir, paths) = flux2_klein9b_bf16_paths();
2504 let hint = ActivationHint {
2505 width: 1024,
2506 height: 1024,
2507 batch: 1,
2508 dtype_bytes: 2,
2509 family: ActivationFamily::Flux2Dit,
2510 };
2511
2512 let strategy = select_server_load_strategy_for_device(
2513 &paths,
2514 Some(128 * GB),
2515 Some(24 * GB),
2516 Some(hint),
2517 );
2518
2519 assert_eq!(
2520 strategy,
2521 mold_inference::LoadStrategy::Sequential,
2522 "Klein-9B-shaped BF16 loads must use device VRAM as the budget cap \
2523 even when the live available-memory reading falls back to a larger \
2524 system-memory value"
2525 );
2526 }
2527
2528 #[test]
2529 fn server_load_strategy_uses_device_total_when_live_available_missing() {
2530 let (_dir, paths) = flux2_klein9b_bf16_paths();
2531 let hint = ActivationHint {
2532 width: 1024,
2533 height: 1024,
2534 batch: 1,
2535 dtype_bytes: 2,
2536 family: ActivationFamily::Flux2Dit,
2537 };
2538
2539 let strategy =
2540 select_server_load_strategy_for_device(&paths, None, Some(24 * GB), Some(hint));
2541
2542 assert_eq!(
2543 strategy,
2544 mold_inference::LoadStrategy::Sequential,
2545 "when live free-memory probing is unavailable, the worker should still \
2546 use known device total VRAM instead of defaulting to eager"
2547 );
2548 }
2549
2550 fn ltx2_shaped_paths_with_sizes(
2554 transformer_gb: u64,
2555 gemma_te_gb: u64,
2556 ) -> (tempfile::TempDir, ModelPaths) {
2557 let dir = tempfile::tempdir().expect("tempdir");
2558 let mk = |name: &str, sz: u64| {
2559 let p = dir.path().join(name);
2560 let f = std::fs::File::create(&p).unwrap();
2561 f.set_len(sz * GB).unwrap();
2562 p
2563 };
2564 let transformer = mk("ltx2_full.safetensors", transformer_gb);
2565 let gemma = mk("gemma_te.safetensors", gemma_te_gb);
2566 let paths = ModelPaths {
2570 transformer: transformer.clone(),
2571 transformer_shards: Vec::new(),
2572 vae: transformer,
2573 spatial_upscaler: None,
2574 temporal_upscaler: None,
2575 distilled_lora: None,
2576 t5_encoder: None,
2577 clip_encoder: None,
2578 t5_tokenizer: None,
2579 clip_tokenizer: None,
2580 clip_encoder_2: None,
2581 clip_tokenizer_2: None,
2582 text_encoder_files: vec![gemma],
2583 text_tokenizer: None,
2584 decoder: None,
2585 };
2586 (dir, paths)
2587 }
2588
2589 #[test]
2595 fn preflight_accepts_ltx2_22b_on_24gb_card_via_streaming_peak() {
2596 let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 0);
2597 let hint = ActivationHint {
2598 width: 768,
2599 height: 512,
2600 batch: 1,
2601 dtype_bytes: 2,
2602 family: ActivationFamily::Ltx2Video,
2603 };
2604 let result =
2605 preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2606 assert!(
2607 result.is_ok(),
2608 "22B LTX-2 must fit on a 24 GB card under streaming-aware peak \
2609 (only ~2 blocks co-resident; runtime handles its own memory), \
2610 got {result:?}",
2611 );
2612 }
2613
2614 #[test]
2615 fn preflight_accepts_ltx2_22b_by_catalog_path_when_hint_is_missing() {
2616 let dir = tempfile::tempdir().expect("tempdir");
2617 let mk = |name: &str, sz: u64| {
2618 let p = dir.path().join(name);
2619 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2620 let f = std::fs::File::create(&p).unwrap();
2621 f.set_len(sz * GB).unwrap();
2622 p
2623 };
2624 let transformer = mk("ltx2/civitai/2752735/ltx23_full.safetensors", 46);
2625 let paths = ModelPaths {
2626 transformer: transformer.clone(),
2627 transformer_shards: Vec::new(),
2628 vae: transformer,
2629 spatial_upscaler: None,
2630 temporal_upscaler: None,
2631 distilled_lora: None,
2632 t5_encoder: None,
2633 clip_encoder: None,
2634 t5_tokenizer: None,
2635 clip_tokenizer: None,
2636 clip_encoder_2: None,
2637 clip_tokenizer_2: None,
2638 text_encoder_files: Vec::new(),
2639 text_tokenizer: None,
2640 decoder: None,
2641 };
2642 let result = preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, None);
2643
2644 assert!(
2645 result.is_ok(),
2646 "LTX-2 catalog paths should use the streaming-transformer peak even \
2647 when the multi-GPU worker cannot resolve a family hint, got {result:?}"
2648 );
2649 }
2650
2651 #[test]
2655 fn preflight_rejects_ltx2_22b_when_hint_marks_non_streaming() {
2656 let _guard = offload_env_guard("0");
2657 let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 0);
2658 let hint = ActivationHint {
2662 width: 768,
2663 height: 512,
2664 batch: 1,
2665 dtype_bytes: 2,
2666 family: ActivationFamily::FluxDit,
2667 };
2668 let result =
2669 preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2670 assert!(
2671 result.is_err(),
2672 "without the LTX-2 streaming hint the file-size peak must reject \
2673 a 46 GB transformer on a 24 GB card — this anchors the regression \
2674 that landed before the streaming-aware path",
2675 );
2676 }
2677
2678 #[test]
2684 fn preflight_rejects_ltx2_when_encoder_phase_exceeds_card() {
2685 let _guard = ltx2_gemma_env_guard("gpu");
2686 let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 25);
2687 let hint = ActivationHint {
2688 width: 768,
2689 height: 512,
2690 batch: 1,
2691 dtype_bytes: 2,
2692 family: ActivationFamily::Ltx2Video,
2693 };
2694 let result =
2695 preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2696 assert!(
2697 result.is_err(),
2698 "25 GB Gemma TE alone exceeds 90 %% of 24 GB during the encoder \
2699 phase — preflight must surface this even when the transformer \
2700 is streamed, got {result:?}",
2701 );
2702 }
2703
2704 #[test]
2710 fn preflight_admits_ltx2_auto_gemma_even_when_gpu_encoder_would_exceed_cap() {
2711 let _guard = ltx2_gemma_env_guard("auto");
2712 let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 25);
2713 let hint = ActivationHint {
2714 width: 768,
2715 height: 512,
2716 batch: 1,
2717 dtype_bytes: 2,
2718 family: ActivationFamily::Ltx2Video,
2719 };
2720 let result =
2721 preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2722 assert!(
2723 result.is_ok(),
2724 "auto Gemma placement can fall back to CPU at runtime, so preflight \
2725 must not reject solely because a same-GPU prompt encoder phase \
2726 would exceed the hard cap, got {result:?}",
2727 );
2728 }
2729
2730 #[test]
2736 fn preflight_admits_ltx2_22b_with_25gb_gemma_when_resolver_picks_cpu() {
2737 let _guard = ltx2_gemma_env_guard("cpu");
2738 let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 25);
2739 let hint = ActivationHint {
2740 width: 768,
2741 height: 512,
2742 batch: 1,
2743 dtype_bytes: 2,
2744 family: ActivationFamily::Ltx2Video,
2745 };
2746 let result =
2747 preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2748 assert!(
2749 result.is_ok(),
2750 "with MOLD_LTX2_GEMMA_DEVICE=cpu the encoder phase should not \
2751 count against GPU VRAM, so cv:2752735 must admit on 24 GB even \
2752 with a 25 GB Gemma TE, got {result:?}",
2753 );
2754 }
2755
2756 #[test]
2759 fn activation_hint_from_request_classifies_correctly() {
2760 let mut req = GenerateRequest {
2761 prompt: "test".into(),
2762 negative_prompt: None,
2763 model: "flux-dev:bf16".into(),
2764 width: 1024,
2765 height: 1024,
2766 steps: 20,
2767 guidance: 3.5,
2768 seed: None,
2769 batch_size: 1,
2770 output_format: Default::default(),
2771 embed_metadata: None,
2772 scheduler: None,
2773 cfg_plus: None,
2774 source_image: None,
2775 edit_images: None,
2776 strength: 1.0,
2777 mask_image: None,
2778 control_image: None,
2779 control_model: None,
2780 control_scale: 1.0,
2781 expand: None,
2782 original_prompt: None,
2783 lora: None,
2784 frames: None,
2785 fps: None,
2786 upscale_model: None,
2787 gif_preview: false,
2788 enable_audio: None,
2789 audio_file: None,
2790 audio_file_path: None,
2791 source_video: None,
2792 source_video_path: None,
2793 keyframes: None,
2794 pipeline: None,
2795 loras: None,
2796 retake_range: None,
2797 spatial_upscale: None,
2798 temporal_upscale: None,
2799 placement: None,
2800 };
2801
2802 let hint_flux = ActivationHint::from_request(&req, "flux");
2804 assert_eq!(hint_flux.family, ActivationFamily::FluxDit);
2805 assert_eq!(hint_flux.batch, 1);
2806
2807 let hint_sdxl = ActivationHint::from_request(&req, "sdxl");
2809 assert_eq!(hint_sdxl.family, ActivationFamily::SdxlUnet);
2810 assert_eq!(hint_sdxl.batch, 2);
2811
2812 req.guidance = 1.0;
2814 let hint_sdxl_lcm = ActivationHint::from_request(&req, "sdxl");
2815 assert_eq!(hint_sdxl_lcm.batch, 1);
2816
2817 let hint_unknown = ActivationHint::from_request(&req, "totally-bogus");
2819 assert_eq!(hint_unknown.family, ActivationFamily::FluxDit);
2820 }
2821
2822 fn write_safetensors_with_keys(path: &std::path::Path, keys: &[&str]) {
2827 use std::io::Write;
2828 let mut header = serde_json::Map::new();
2829 for key in keys {
2830 header.insert(
2831 (*key).to_string(),
2832 serde_json::json!({
2833 "dtype": "F32",
2834 "shape": [1],
2835 "data_offsets": [0, 4],
2836 }),
2837 );
2838 }
2839 let header_json = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
2840 let mut f = std::fs::File::create(path).expect("create fixture");
2841 f.write_all(&(header_json.len() as u64).to_le_bytes())
2842 .unwrap();
2843 f.write_all(&header_json).unwrap();
2844 f.write_all(&[0u8; 4]).unwrap();
2845 }
2846
2847 fn flux_unet_only_catalog_entry(
2848 version_id: &str,
2849 file_name: &str,
2850 ) -> mold_catalog::entry::CatalogEntry {
2851 use mold_catalog::entry::{
2852 CatalogEntry, CatalogId, DownloadRecipe, FamilyRole, FileFormat, LicenseFlags,
2853 Modality, RecipeFile, Source, TokenKind,
2854 };
2855 use mold_catalog::families::Family;
2856
2857 CatalogEntry {
2858 id: CatalogId::from(format!("cv:{version_id}")),
2859 source: Source::Civitai,
2860 source_id: version_id.to_string(),
2861 name: "FLUX Unet-only fine-tune".into(),
2862 author: Some("someone".into()),
2863 family: Family::Flux,
2864 family_role: FamilyRole::Finetune,
2865 sub_family: None,
2866 modality: Modality::Image,
2867 kind: mold_catalog::entry::Kind::Checkpoint,
2868 file_format: FileFormat::Safetensors,
2869 bundling: mold_catalog::entry::Bundling::SingleFile,
2870 size_bytes: Some(12_000_000_000),
2871 download_count: 0,
2872 rating: None,
2873 likes: 0,
2874 nsfw: false,
2875 thumbnail_url: None,
2876 description: None,
2877 license: None,
2878 license_flags: LicenseFlags::default(),
2879 tags: vec![],
2880 companions: vec!["t5-v1_1-xxl".into(), "clip-l".into(), "flux-vae".into()],
2881 download_recipe: DownloadRecipe {
2882 files: vec![RecipeFile {
2883 url: format!("https://civitai.com/api/download/models/{version_id}"),
2884 dest: format!("{{family}}/civitai/{version_id}/{file_name}"),
2885 sha256: Some("DEAD".repeat(16)),
2886 size_bytes: Some(12_000_000_000),
2887 role: None,
2888 }],
2889 needs_token: Some(TokenKind::Civitai),
2890 },
2891 engine_phase: 1,
2892 created_at: None,
2893 updated_at: None,
2894 added_at: 0,
2895 trained_words: vec![],
2896 page_url: None,
2897 }
2898 }
2899
2900 fn stub_flux_companion_paths_in_dir(
2906 config: &mut mold_core::Config,
2907 models_dir: &std::path::Path,
2908 flux_vae_present: bool,
2909 ) {
2910 let vae_path = models_dir.join("flux-vae/ae.safetensors");
2911 std::fs::create_dir_all(vae_path.parent().unwrap()).unwrap();
2912 if flux_vae_present {
2913 std::fs::File::create(&vae_path).unwrap();
2914 }
2915 config.models.insert(
2916 "flux-vae".into(),
2917 mold_core::ModelConfig {
2918 family: Some("companion".into()),
2919 transformer: Some(vae_path.to_string_lossy().into_owned()),
2920 vae: Some(vae_path.to_string_lossy().into_owned()),
2921 ..Default::default()
2922 },
2923 );
2924 let clip_path = models_dir.join("clip-l/model.safetensors");
2925 std::fs::create_dir_all(clip_path.parent().unwrap()).unwrap();
2926 std::fs::File::create(&clip_path).unwrap();
2927 config.models.insert(
2928 "clip-l".into(),
2929 mold_core::ModelConfig {
2930 family: Some("companion".into()),
2931 transformer: Some(clip_path.to_string_lossy().into_owned()),
2932 vae: Some(clip_path.to_string_lossy().into_owned()),
2933 clip_tokenizer: Some(format!("{}/clip-l/tokenizer.json", models_dir.display())),
2934 ..Default::default()
2935 },
2936 );
2937 let t5_path = models_dir.join("t5-v1_1-xxl/t5xxl_fp16.safetensors");
2938 std::fs::create_dir_all(t5_path.parent().unwrap()).unwrap();
2939 std::fs::File::create(&t5_path).unwrap();
2940 config.models.insert(
2941 "t5-v1_1-xxl".into(),
2942 mold_core::ModelConfig {
2943 family: Some("companion".into()),
2944 transformer: Some(t5_path.to_string_lossy().into_owned()),
2945 vae: Some(t5_path.to_string_lossy().into_owned()),
2946 t5_tokenizer: Some(format!(
2947 "{}/t5-v1_1-xxl/tokenizer.json",
2948 models_dir.display()
2949 )),
2950 ..Default::default()
2951 },
2952 );
2953 }
2954
2955 #[test]
2960 fn synthesis_intent_is_consistent_before_and_after_download() {
2961 let dir = tempfile::tempdir().unwrap();
2962 let models_dir = dir.path();
2963 let entry =
2964 flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
2965
2966 let intent_absent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
2967
2968 let primary_path = models_dir
2969 .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
2970 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
2971 write_safetensors_with_keys(
2972 &primary_path,
2973 &[
2974 "double_blocks.0.img_attn.proj.weight",
2975 "single_blocks.0.linear1.weight",
2976 "img_in.weight",
2977 ],
2978 );
2979
2980 let intent_present =
2981 mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
2982
2983 assert_eq!(
2984 intent_absent, intent_present,
2985 "intent synthesis must be pure — independent of disk state"
2986 );
2987 }
2988
2989 #[test]
2993 fn resolve_intent_picks_flux_vae_companion_when_primary_is_transformer_only() {
2994 let dir = tempfile::tempdir().unwrap();
2995 let models_dir = dir.path();
2996 let _saved = std::env::var("MOLD_HOME").ok();
2997 unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
2998
2999 let primary_path = models_dir
3000 .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
3001 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3002 write_safetensors_with_keys(
3003 &primary_path,
3004 &[
3005 "double_blocks.0.img_attn.proj.weight",
3006 "single_blocks.0.linear1.weight",
3007 "img_in.weight",
3008 ],
3009 );
3010
3011 let mut config = mold_core::Config {
3012 models_dir: models_dir.to_string_lossy().into_owned(),
3013 ..Default::default()
3014 };
3015 stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3016
3017 let entry =
3018 flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
3019 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3020 let cfg = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap();
3021
3022 let vae_path = models_dir.join("flux-vae/ae.safetensors");
3023 assert_eq!(cfg.vae.as_deref(), vae_path.to_str());
3024 assert_eq!(cfg.transformer.as_deref(), primary_path.to_str());
3025
3026 unsafe {
3027 match _saved {
3028 Some(v) => std::env::set_var("MOLD_HOME", v),
3029 None => std::env::remove_var("MOLD_HOME"),
3030 }
3031 }
3032 }
3033
3034 #[test]
3035 fn resolve_intent_preserves_flux_schnell_subfamily() {
3036 let dir = tempfile::tempdir().unwrap();
3037 let models_dir = dir.path();
3038 let _saved = std::env::var("MOLD_HOME").ok();
3039 unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3040
3041 let primary_path = models_dir
3042 .join("cv-1153358/flux/civitai/1153358/agfluxSchnell_realistic23.safetensors");
3043 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3044 write_safetensors_with_keys(
3045 &primary_path,
3046 &[
3047 "model.diffusion_model.double_blocks.0.img_attn.proj.weight",
3048 "model.diffusion_model.img_in.weight",
3049 ],
3050 );
3051
3052 let mut config = mold_core::Config {
3053 models_dir: models_dir.to_string_lossy().into_owned(),
3054 ..Default::default()
3055 };
3056 stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3057
3058 let mut entry =
3059 flux_unet_only_catalog_entry("1153358", "agfluxSchnell_realistic23.safetensors");
3060 entry.sub_family = Some("flux1-s".into());
3061
3062 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3063 let cfg = resolve_intent_to_paths("cv:1153358", &intent, &config).unwrap();
3064
3065 assert_eq!(
3066 cfg.is_schnell,
3067 Some(true),
3068 "flux1-s catalog entries must select FLUX schnell config, not dev guidance config"
3069 );
3070
3071 unsafe {
3072 match _saved {
3073 Some(v) => std::env::set_var("MOLD_HOME", v),
3074 None => std::env::remove_var("MOLD_HOME"),
3075 }
3076 }
3077 }
3078
3079 #[test]
3080 fn resolve_intent_applies_flux_dev_subfamily_defaults() {
3081 let dir = tempfile::tempdir().unwrap();
3082 let models_dir = dir.path();
3083 let _saved = std::env::var("MOLD_HOME").ok();
3084 unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3085
3086 let primary_path =
3087 models_dir.join("cv-2319074/flux/civitai/2319074/jibMixFlux_v12SRPO.safetensors");
3088 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3089 write_safetensors_with_keys(
3090 &primary_path,
3091 &[
3092 "double_blocks.0.img_attn.proj.weight",
3093 "single_blocks.0.linear1.weight",
3094 "img_in.weight",
3095 ],
3096 );
3097
3098 let mut config = mold_core::Config {
3099 models_dir: models_dir.to_string_lossy().into_owned(),
3100 default_steps: 4,
3101 ..Default::default()
3102 };
3103 stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3104
3105 let mut entry = flux_unet_only_catalog_entry("2319074", "jibMixFlux_v12SRPO.safetensors");
3106 entry.sub_family = Some("flux1-d".into());
3107
3108 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3109 let cfg = resolve_intent_to_paths("cv:2319074", &intent, &config).unwrap();
3110
3111 assert_eq!(cfg.is_schnell, Some(false));
3112 assert_eq!(cfg.default_steps, Some(25));
3113 assert_eq!(cfg.default_guidance, Some(3.5));
3114 assert_eq!(cfg.default_width, Some(1024));
3115 assert_eq!(cfg.default_height, Some(1024));
3116
3117 unsafe {
3118 match _saved {
3119 Some(v) => std::env::set_var("MOLD_HOME", v),
3120 None => std::env::remove_var("MOLD_HOME"),
3121 }
3122 }
3123 }
3124
3125 #[test]
3126 fn resolve_intent_populates_qwen_runtime_companion_paths() {
3127 let dir = tempfile::tempdir().unwrap();
3128 let models_dir = dir.path();
3129 let primary_path =
3130 models_dir.join("cv-2110043/qwen-image/civitai/2110043/qwenImage_fp8.safetensors");
3131 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3132 std::fs::File::create(&primary_path).unwrap();
3133
3134 let runtime_dir = models_dir.join("qwen-image-runtime");
3135 let vae_path = runtime_dir.join("vae/diffusion_pytorch_model.safetensors");
3136 let te_path = runtime_dir.join("text_encoder/model-00001-of-00004.safetensors");
3137 let tok_path = runtime_dir.join("tokenizer/tokenizer.json");
3138 for path in [&vae_path, &te_path, &tok_path] {
3139 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3140 std::fs::File::create(path).unwrap();
3141 }
3142
3143 let mut config = mold_core::Config {
3144 models_dir: models_dir.to_string_lossy().into_owned(),
3145 ..Default::default()
3146 };
3147 config.models.insert(
3148 "qwen-image-runtime".into(),
3149 mold_core::ModelConfig {
3150 family: Some("companion".into()),
3151 transformer: Some(vae_path.to_string_lossy().into_owned()),
3152 vae: Some(vae_path.to_string_lossy().into_owned()),
3153 text_encoder_files: Some(vec![te_path.to_string_lossy().into_owned()]),
3154 text_tokenizer: Some(tok_path.to_string_lossy().into_owned()),
3155 ..Default::default()
3156 },
3157 );
3158
3159 let mut entry = flux_unet_only_catalog_entry("2110043", "qwenImage_fp8.safetensors");
3160 entry.family = mold_catalog::families::Family::QwenImage;
3161 entry.companions = vec!["qwen-image-runtime".into()];
3162 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3163 let cfg = resolve_intent_to_paths("cv:2110043", &intent, &config).unwrap();
3164
3165 assert_eq!(cfg.transformer.as_deref(), primary_path.to_str());
3166 assert_eq!(cfg.vae.as_deref(), vae_path.to_str());
3167 assert_eq!(
3168 cfg.text_encoder_files.as_deref(),
3169 Some(vec![te_path.to_string_lossy().into_owned()].as_slice())
3170 );
3171 assert_eq!(cfg.text_tokenizer.as_deref(), tok_path.to_str());
3172 }
3173
3174 #[test]
3175 fn resolve_intent_populates_wuerstchen_runtime_companion_paths() {
3176 let dir = tempfile::tempdir().unwrap();
3177 let models_dir = dir.path();
3178 let primary_path = models_dir.join(
3179 "hf-example/wuerstchen-prior/wuerstchen/example/wuerstchen-prior/prior.safetensors",
3180 );
3181 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3182 std::fs::File::create(&primary_path).unwrap();
3183
3184 let runtime_dir = models_dir.join("wuerstchen-runtime");
3185 let decoder_path = runtime_dir.join("decoder/diffusion_pytorch_model.safetensors");
3186 let vae_path = runtime_dir.join("vqgan/diffusion_pytorch_model.safetensors");
3187 let clip_path = runtime_dir.join("text_encoder/model.safetensors");
3188 let clip_tok_path = runtime_dir.join("tokenizer/tokenizer.json");
3189 let clip_g_path = runtime_dir.join("prior/text_encoder/model.safetensors");
3190 let clip_g_tok_path = runtime_dir.join("prior/tokenizer/tokenizer.json");
3191 for path in [
3192 &decoder_path,
3193 &vae_path,
3194 &clip_path,
3195 &clip_tok_path,
3196 &clip_g_path,
3197 &clip_g_tok_path,
3198 ] {
3199 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3200 std::fs::File::create(path).unwrap();
3201 }
3202
3203 let mut config = mold_core::Config {
3204 models_dir: models_dir.to_string_lossy().into_owned(),
3205 ..Default::default()
3206 };
3207 config.models.insert(
3208 "wuerstchen-runtime".into(),
3209 mold_core::ModelConfig {
3210 family: Some("companion".into()),
3211 transformer: Some(decoder_path.to_string_lossy().into_owned()),
3212 decoder: Some(decoder_path.to_string_lossy().into_owned()),
3213 vae: Some(vae_path.to_string_lossy().into_owned()),
3214 clip_encoder: Some(clip_path.to_string_lossy().into_owned()),
3215 clip_tokenizer: Some(clip_tok_path.to_string_lossy().into_owned()),
3216 clip_encoder_2: Some(clip_g_path.to_string_lossy().into_owned()),
3217 clip_tokenizer_2: Some(clip_g_tok_path.to_string_lossy().into_owned()),
3218 ..Default::default()
3219 },
3220 );
3221
3222 let mut entry = flux_unet_only_catalog_entry("unused", "prior.safetensors");
3223 entry.id = mold_catalog::entry::CatalogId::from("hf:example/wuerstchen-prior");
3224 entry.source = mold_catalog::entry::Source::Hf;
3225 entry.source_id = "example/wuerstchen-prior".into();
3226 entry.family = mold_catalog::families::Family::Wuerstchen;
3227 entry.companions = vec!["wuerstchen-runtime".into()];
3228 entry.download_recipe.files[0].dest = "{family}/{author}/{name}/prior.safetensors".into();
3229
3230 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3231 let cfg = resolve_intent_to_paths("hf:example/wuerstchen-prior", &intent, &config).unwrap();
3232
3233 assert_eq!(cfg.transformer.as_deref(), primary_path.to_str());
3234 assert_eq!(cfg.decoder.as_deref(), decoder_path.to_str());
3235 assert_eq!(cfg.vae.as_deref(), vae_path.to_str());
3236 assert_eq!(cfg.clip_encoder.as_deref(), clip_path.to_str());
3237 assert_eq!(cfg.clip_tokenizer.as_deref(), clip_tok_path.to_str());
3238 assert_eq!(cfg.clip_encoder_2.as_deref(), clip_g_path.to_str());
3239 assert_eq!(cfg.clip_tokenizer_2.as_deref(), clip_g_tok_path.to_str());
3240 }
3241
3242 #[test]
3243 fn resolve_intent_uses_zimage_recipe_text_encoder_and_shared_companion_vae() {
3244 use mold_catalog::entry::{
3245 Bundling, CatalogEntry, CatalogId, DownloadRecipe, FamilyRole, FileFormat,
3246 LicenseFlags, Modality, RecipeFile, RecipeFileRole, Source, TokenKind,
3247 };
3248 use mold_catalog::families::Family;
3249
3250 let dir = tempfile::tempdir().unwrap();
3251 let models_dir = dir.path();
3252 let _saved = std::env::var("MOLD_HOME").ok();
3253 unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3254
3255 let mut config = mold_core::Config {
3256 models_dir: models_dir.to_string_lossy().into_owned(),
3257 ..Default::default()
3258 };
3259 let te_dir = models_dir.join("z-image-te");
3260 config.models.insert(
3261 "z-image-te".into(),
3262 mold_core::ModelConfig {
3263 family: Some("companion".into()),
3264 transformer: Some(
3265 te_dir
3266 .join("text_encoder/model-00001-of-00003.safetensors")
3267 .to_string_lossy()
3268 .into_owned(),
3269 ),
3270 vae: Some(
3271 te_dir
3272 .join("vae/diffusion_pytorch_model.safetensors")
3273 .to_string_lossy()
3274 .into_owned(),
3275 ),
3276 text_encoder_files: Some(vec![
3277 te_dir
3278 .join("text_encoder/model-00001-of-00003.safetensors")
3279 .to_string_lossy()
3280 .into_owned(),
3281 te_dir
3282 .join("text_encoder/model-00002-of-00003.safetensors")
3283 .to_string_lossy()
3284 .into_owned(),
3285 te_dir
3286 .join("text_encoder/model-00003-of-00003.safetensors")
3287 .to_string_lossy()
3288 .into_owned(),
3289 ]),
3290 text_tokenizer: Some(
3291 te_dir
3292 .join("tokenizer/tokenizer.json")
3293 .to_string_lossy()
3294 .into_owned(),
3295 ),
3296 ..Default::default()
3297 },
3298 );
3299 let entry = CatalogEntry {
3300 id: CatalogId::from("cv:2442439"),
3301 source: Source::Civitai,
3302 source_id: "2442439".into(),
3303 name: "Z Image Turbo".into(),
3304 author: Some("z".into()),
3305 family: Family::ZImage,
3306 family_role: FamilyRole::Finetune,
3307 sub_family: None,
3308 modality: Modality::Image,
3309 kind: mold_catalog::entry::Kind::Checkpoint,
3310 file_format: FileFormat::Safetensors,
3311 bundling: Bundling::SingleFile,
3312 size_bytes: Some(12_021_353_906),
3313 download_count: 0,
3314 rating: None,
3315 likes: 0,
3316 nsfw: false,
3317 thumbnail_url: None,
3318 description: None,
3319 license: None,
3320 license_flags: LicenseFlags::default(),
3321 tags: vec![],
3322 companions: vec!["z-image-te".into()],
3323 download_recipe: DownloadRecipe {
3324 files: vec![
3325 RecipeFile {
3326 url: "https://civitai.example/model".into(),
3327 dest: "{family}/civitai/2442439/zImageTurbo_turbo.safetensors".into(),
3328 sha256: None,
3329 size_bytes: Some(12_021_353_906),
3330 role: None,
3331 },
3332 RecipeFile {
3333 url: "https://civitai.example/text".into(),
3334 dest: "{family}/civitai/2442439/zImageTurbo_turbo_txt.safetensors".into(),
3335 sha256: None,
3336 size_bytes: Some(8_044_982_048),
3337 role: Some(RecipeFileRole::TextEncoder),
3338 },
3339 ],
3340 needs_token: Some(TokenKind::Civitai),
3341 },
3342 engine_phase: 1,
3343 created_at: None,
3344 updated_at: None,
3345 added_at: 0,
3346 trained_words: vec![],
3347 page_url: None,
3348 };
3349
3350 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3351 std::fs::create_dir_all(intent.primary_recipe_path.parent().unwrap()).unwrap();
3352 std::fs::write(&intent.primary_recipe_path, b"primary").unwrap();
3353 let cfg = resolve_intent_to_paths("cv:2442439", &intent, &config).unwrap();
3354
3355 let recipe_text_encoder =
3356 models_dir.join("cv-2442439/z-image/civitai/2442439/zImageTurbo_turbo_txt.safetensors");
3357 let shared_vae = te_dir.join("vae/diffusion_pytorch_model.safetensors");
3358 assert_eq!(cfg.vae.as_deref(), shared_vae.to_str());
3359 let expected_text_encoder_files = vec![recipe_text_encoder.to_string_lossy().into_owned()];
3360 assert_eq!(
3361 cfg.text_encoder_files.as_deref(),
3362 Some(expected_text_encoder_files.as_slice())
3363 );
3364
3365 unsafe {
3366 match _saved {
3367 Some(v) => std::env::set_var("MOLD_HOME", v),
3368 None => std::env::remove_var("MOLD_HOME"),
3369 }
3370 }
3371 }
3372
3373 #[test]
3378 fn resolve_intent_returns_error_naming_missing_required_companion() {
3379 let dir = tempfile::tempdir().unwrap();
3380 let models_dir = dir.path();
3381
3382 let primary_path = models_dir
3383 .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
3384 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3385 write_safetensors_with_keys(
3386 &primary_path,
3387 &["double_blocks.0.img_attn.proj.weight", "img_in.weight"],
3388 );
3389
3390 let _saved = std::env::var("MOLD_HOME").ok();
3391 unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3392
3393 let config = mold_core::Config {
3395 models_dir: models_dir.to_string_lossy().into_owned(),
3396 ..Default::default()
3397 };
3398
3399 let entry =
3400 flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
3401 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3402 let err = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap_err();
3403
3404 let msg = err.to_string();
3405 assert!(
3408 msg.contains("t5-v1_1-xxl") || msg.contains("clip-l") || msg.contains("flux-vae"),
3409 "error must name a specific missing companion, got: {msg}"
3410 );
3411 assert!(matches!(err, ResolveError::CompanionConfigMissing { .. }));
3412
3413 unsafe {
3414 match _saved {
3415 Some(v) => std::env::set_var("MOLD_HOME", v),
3416 None => std::env::remove_var("MOLD_HOME"),
3417 }
3418 }
3419 }
3420
3421 #[test]
3427 fn cv_id_resolves_when_files_arrive_after_initial_request() {
3428 let dir = tempfile::tempdir().unwrap();
3429 let models_dir = dir.path();
3430 let _saved = std::env::var("MOLD_HOME").ok();
3431 unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3432
3433 let entry =
3434 flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
3435 let mut config = mold_core::Config {
3436 models_dir: models_dir.to_string_lossy().into_owned(),
3437 ..Default::default()
3438 };
3439 stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3441
3442 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3443
3444 let err = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap_err();
3447 assert!(matches!(err, ResolveError::PrimaryFileMissing { .. }));
3448 let primary_path = models_dir
3449 .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
3450 let vae_path = models_dir.join("flux-vae/ae.safetensors");
3451
3452 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3454 write_safetensors_with_keys(
3455 &primary_path,
3456 &["double_blocks.0.img_attn.proj.weight", "img_in.weight"],
3457 );
3458
3459 let cfg_second = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap();
3463 assert_eq!(cfg_second.transformer.as_deref(), primary_path.to_str());
3464 assert_eq!(cfg_second.vae.as_deref(), vae_path.to_str());
3465
3466 unsafe {
3467 match _saved {
3468 Some(v) => std::env::set_var("MOLD_HOME", v),
3469 None => std::env::remove_var("MOLD_HOME"),
3470 }
3471 }
3472 }
3473
3474 #[test]
3475 fn resolve_intent_rejects_truncated_sidecar_primary() {
3476 let dir = tempfile::tempdir().unwrap();
3477 let models_dir = dir.path();
3478 let _saved = std::env::var("MOLD_HOME").ok();
3479 unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3480
3481 let primary_path = models_dir
3482 .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
3483 std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3484 write_safetensors_with_keys(
3485 &primary_path,
3486 &["double_blocks.0.img_attn.proj.weight", "img_in.weight"],
3487 );
3488 let entry =
3489 flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
3490 let sidecar = mold_catalog::sidecar::sidecar_from_entry(
3491 &entry,
3492 "flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors".into(),
3493 );
3494 let mut sidecar = sidecar;
3495 sidecar.size_bytes = Some(primary_path.metadata().unwrap().len() + 1);
3496 let sidecar_path = mold_catalog::sidecar::civitai_sidecar_path(models_dir, "cv:994561");
3497 mold_catalog::sidecar::write_sidecar(&sidecar_path, &sidecar).unwrap();
3498
3499 let mut config = mold_core::Config {
3500 models_dir: models_dir.to_string_lossy().into_owned(),
3501 ..Default::default()
3502 };
3503 stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3504 let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3505
3506 let err = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap_err();
3507 assert!(matches!(err, ResolveError::PrimaryFileMissing { .. }));
3508
3509 unsafe {
3510 match _saved {
3511 Some(v) => std::env::set_var("MOLD_HOME", v),
3512 None => std::env::remove_var("MOLD_HOME"),
3513 }
3514 }
3515 }
3516
3517 #[test]
3520 fn live_error_to_install_error_maps_404_to_not_found() {
3521 let upstream = mold_catalog::live::LiveSearchError::Upstream {
3522 host: "civitai.com",
3523 status: 404,
3524 body: "{\"error\": \"not found\"}".to_string(),
3525 };
3526 let mapped = live_error_to_install_error("cv:42", &upstream);
3527 assert!(matches!(mapped, mold_core::InstallError::NotFound(_)));
3528 }
3529
3530 #[test]
3531 fn live_error_to_install_error_maps_5xx_to_recipe_malformed() {
3532 let upstream = mold_catalog::live::LiveSearchError::Upstream {
3533 host: "civitai.com",
3534 status: 500,
3535 body: "internal".into(),
3536 };
3537 let mapped = live_error_to_install_error("cv:42", &upstream);
3538 assert!(matches!(
3539 mapped,
3540 mold_core::InstallError::RecipeMalformed(_)
3541 ));
3542 }
3543
3544 #[test]
3545 fn install_error_to_api_error_maps_network_to_502() {
3546 let err = mold_core::InstallError::Network("dns: civitai.com".into());
3547 let api = install_error_to_api_error(&err);
3548 assert!(api.error.contains("network unreachable"));
3553 }
3554
3555 #[test]
3556 fn install_error_to_api_error_maps_not_found_to_404() {
3557 let err = mold_core::InstallError::NotFound("cv:99999999".into());
3558 let api = install_error_to_api_error(&err);
3559 assert_eq!(api.code, "MODEL_NOT_FOUND");
3560 }
3561
3562 const BUDGET_FRACTION_NUMERATOR: u64 = 9;
3568 const BUDGET_FRACTION_DENOMINATOR: u64 = 10;
3569
3570 fn expected_budget_cap(available: u64) -> u64 {
3572 available * BUDGET_FRACTION_NUMERATOR / BUDGET_FRACTION_DENOMINATOR
3573 }
3574
3575 #[test]
3579 fn preflight_error_message_states_correct_budget_cap() {
3580 let peak: u64 = 24_400_000_000;
3583 let available: u64 = 25_300_000_000;
3584 let cap = expected_budget_cap(available);
3585
3586 assert!(
3588 peak > cap,
3589 "test invariant: peak ({peak}) must exceed cap ({cap})"
3590 );
3591
3592 let result = check_model_memory_budget(
3593 "qwen-image:q8",
3594 peak,
3595 available,
3596 "Try a smaller variant (e.g. ':q5' / ':q4'), enable --offload (FLUX), or close other GPU apps.",
3597 );
3598 assert!(result.is_err(), "expected rejection, got Ok");
3599
3600 let err = result.unwrap_err();
3601 let msg = &err.error;
3602
3603 let cap_gb = cap as f64 / 1_000_000_000.0;
3605 let cap_str = format!("{cap_gb:.1}");
3606 assert!(
3607 msg.contains("budget cap"),
3608 "error must mention 'budget cap', got: {msg}"
3609 );
3610 assert!(
3611 msg.contains(&cap_str),
3612 "error must contain the cap value ~{cap_str} GB, got: {msg}"
3613 );
3614
3615 let available_gb = available as f64 / 1_000_000_000.0;
3620 let available_str = format!("{available_gb:.1}");
3621 let _ = available_str; }
3625
3626 #[test]
3630 fn preflight_error_message_does_not_imply_peak_less_than_available() {
3631 let scenarios: &[(f64, f64)] = &[
3632 (24.4, 25.3), (19.0, 20.0), (10.0, 10.5), (30.0, 32.0), (9.1, 10.0), ];
3639 for &(peak_gb, available_gb) in scenarios {
3640 let peak = (peak_gb * 1_000_000_000.0) as u64;
3641 let available = (available_gb * 1_000_000_000.0) as u64;
3642 let cap = expected_budget_cap(available);
3643
3644 if peak <= cap {
3646 continue;
3647 }
3648
3649 let result =
3650 check_model_memory_budget("test-model", peak, available, "Try a smaller variant.");
3651 assert!(
3652 result.is_err(),
3653 "expected rejection for peak={peak_gb} available={available_gb}, got Ok"
3654 );
3655
3656 let msg = result.unwrap_err().error;
3657
3658 let cap_gb = cap as f64 / 1_000_000_000.0;
3661 let cap_str = format!("{cap_gb:.1}");
3662 assert!(
3663 msg.contains("budget cap"),
3664 "scenario peak={peak_gb} available={available_gb}: \
3665 message must say 'budget cap', got: {msg}"
3666 );
3667 assert!(
3668 msg.contains(&cap_str),
3669 "scenario peak={peak_gb} available={available_gb}: \
3670 message must include cap={cap_str}, got: {msg}"
3671 );
3672 }
3673 }
3674
3675 fn ltx_video_13b_paths(
3681 transformer_gb: u64,
3682 vae_gb: u64,
3683 t5_gb: u64,
3684 ) -> (tempfile::TempDir, ModelPaths) {
3685 let dir = tempfile::tempdir().expect("tempdir");
3686 let mk = |name: &str, sz: u64| {
3687 let p = dir.path().join(name);
3688 let f = std::fs::File::create(&p).unwrap();
3689 f.set_len(sz * GB).unwrap();
3690 p
3691 };
3692 let transformer = mk("ltx-video-0.9.8-13b-dev_fp16.safetensors", transformer_gb);
3693 let vae = mk("ltx-video-vae.safetensors", vae_gb);
3694 let t5 = mk("t5xxl_fp16.safetensors", t5_gb);
3695 let paths = ModelPaths {
3696 transformer,
3697 transformer_shards: Vec::new(),
3698 vae,
3699 spatial_upscaler: None,
3700 temporal_upscaler: None,
3701 distilled_lora: None,
3702 t5_encoder: Some(t5),
3703 clip_encoder: None,
3704 t5_tokenizer: None,
3705 clip_tokenizer: None,
3706 clip_encoder_2: None,
3707 clip_tokenizer_2: None,
3708 text_encoder_files: Vec::new(),
3709 text_tokenizer: None,
3710 decoder: None,
3711 };
3712 (dir, paths)
3713 }
3714
3715 #[test]
3722 fn preflight_rejects_ltx_video_13b_at_768x512x25_on_24gb_card() {
3723 let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
3727 let hint = ActivationHint {
3728 width: 768,
3729 height: 512,
3730 batch: 1,
3731 dtype_bytes: 2,
3732 family: ActivationFamily::LtxVideo,
3733 };
3734 let result = preflight_memory_guard_with_available(
3735 "ltx-video-0.9.8-13b-dev:bf16",
3736 &paths,
3737 0,
3738 24 * GB,
3739 Some(hint),
3740 );
3741 assert!(
3742 result.is_err(),
3743 "13B LTX-Video BF16 (26 GB transformer) must be rejected on a 24 GB card — \
3744 the transformer is not streamed and its full weight must be counted, \
3745 got {result:?}",
3746 );
3747 let err = result.unwrap_err();
3748 assert!(
3750 err.error.contains("frames") || err.error.contains("width"),
3751 "rejection message must suggest reducing frames or resolution, got: {}",
3752 err.error,
3753 );
3754 }
3755
3756 #[test]
3762 fn preflight_estimate_for_ltx_video_13b_within_expected_range() {
3763 let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
3764 let expected_gb = 29u64;
3767 let peak = mold_inference::device::estimate_peak_memory(
3768 &paths,
3769 mold_inference::LoadStrategy::Sequential,
3770 );
3771 let peak_gb = peak / GB;
3772 assert!(
3773 peak_gb >= expected_gb.saturating_sub(3),
3774 "peak estimate ({peak_gb} GB) is unexpectedly low — LTX-Video 13B BF16 \
3775 sequential estimate should be ≥ {} GB",
3776 expected_gb.saturating_sub(3),
3777 );
3778 assert!(
3779 peak_gb <= expected_gb + 3,
3780 "peak estimate ({peak_gb} GB) is unexpectedly high for 26+1+10 GB layout \
3781 — should be ≤ {} GB",
3782 expected_gb + 3,
3783 );
3784 }
3785
3786 #[test]
3790 fn activation_family_for_ltx_video_is_non_streaming() {
3791 let family = mold_inference::device::activation_family_for("ltx-video");
3792 assert_eq!(
3793 family,
3794 ActivationFamily::LtxVideo,
3795 "ltx-video slug must map to LtxVideo (non-streaming, full-weight load)"
3796 );
3797 assert!(
3799 !family.streaming_transformer(),
3800 "LtxVideo must NOT be treated as a streaming transformer — \
3801 it loads the entire weight file into VRAM at generate time"
3802 );
3803 assert!(
3805 mold_inference::device::activation_family_for("ltx2").streaming_transformer(),
3806 "ltx2 must still map to the streaming family"
3807 );
3808 }
3809
3810 #[test]
3814 fn preflight_rejection_message_for_ltx_video_suggests_frames_or_resolution() {
3815 let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
3816 let hint = ActivationHint {
3817 width: 768,
3818 height: 512,
3819 batch: 1,
3820 dtype_bytes: 2,
3821 family: ActivationFamily::LtxVideo,
3822 };
3823 let result = preflight_memory_guard_with_available(
3824 "ltx-video-0.9.8-13b-dev:bf16",
3825 &paths,
3826 0,
3827 24 * GB,
3828 Some(hint),
3829 );
3830 let err = result.expect_err("must reject");
3831 assert!(
3832 err.error.contains("frames") || err.error.contains("width"),
3833 "LTX-Video rejection message must suggest reducing frames or \
3834 width/height (not --offload), got: {}",
3835 err.error,
3836 );
3837 assert!(
3839 !err.error.contains("--offload"),
3840 "LTX-Video rejection must not mention --offload (FLUX-only flag), \
3841 got: {}",
3842 err.error,
3843 );
3844 }
3845
3846 #[test]
3847 fn preflight_rejection_message_for_image_suggests_resolution_not_frames() {
3848 let hint = ActivationHint {
3849 width: 1024,
3850 height: 1024,
3851 batch: 2,
3852 dtype_bytes: 2,
3853 family: ActivationFamily::SdxlUnet,
3854 };
3855 let suggestion = rejection_suggestion(Some(hint));
3856
3857 assert!(
3858 suggestion.contains("--width/--height"),
3859 "image preflight suggestion should mention resolution; got: {suggestion}"
3860 );
3861 assert!(
3862 suggestion.contains("--batch"),
3863 "image preflight suggestion should mention batch; got: {suggestion}"
3864 );
3865 assert!(
3866 !suggestion.contains("--frames"),
3867 "image preflight suggestion must not mention video frames; got: {suggestion}"
3868 );
3869 }
3870}