package autogen import ( "strings" "testing" ) // Issue #57: an offloaded extra model places its VAE through the one --backend // spec, not the deprecated --vae-on-cpu beside it. func TestExtraImageCmdLines_OffloadVaeInBackendSpec(t *testing.T) { s := Settings{SdServerExe: "sd-server"} m := ExtraImageModel{ModelPath: "C:/models/dit.safetensors", OffloadToCpu: "on"} cmd := strings.Join(extraImageCmdLines(s, m).Lines, " ") if !strings.Contains(cmd, "--backend te=cpu,vae=cpu") || !strings.Contains(cmd, "--offload-to-cpu") { t.Errorf("offloaded extra model should carry te=cpu,vae=cpu:\n%s", cmd) } if strings.Contains(cmd, "--vae-on-cpu") { t.Errorf("deprecated --vae-on-cpu still emitted:\n%s", cmd) } // Both knobs on must not duplicate the assignment. m.VaeOnCpu = "on" if cmd = strings.Join(extraImageCmdLines(s, m).Lines, " "); strings.Count(cmd, "vae=cpu") != 1 { t.Errorf("vae=cpu should appear once:\n%s", cmd) } } // A hand-declared extra image model carries its own backend pin (the model // editor offers the same sd rows), so the class default must not move it. func TestExtraImageCmdLines_BackendPin(t *testing.T) { rows := []BackendEntry{ {ID: "managed-rocm", Kind: "sd", Path: "C:/rocm/sd-server.exe", Default: true}, {ID: "build-vulkan", Kind: "sd", Path: "C:/vulkan/sd-server.exe"}, } s := Settings{SdServerExe: "C:/rocm/sd-server.exe", Backends: rows} m := ExtraImageModel{ModelPath: "C:/models/dit.safetensors", Backend: "build-vulkan"} if got := extraImageCmdLines(s, m).Lines[0]; got != "C:/vulkan/sd-server.exe" { t.Errorf("pinned exe = %q, want the vulkan build", got) } m.Backend = "" if got := extraImageCmdLines(s, m).Lines[0]; got != "C:/rocm/sd-server.exe" { t.Errorf("auto exe = %q, want the class default", got) } // The editor reaches the same cmd by applying the override onto the model. m = ApplyOverrideToExtraImage(m, &Override{Backend: "build-vulkan"}) if got := RenderExtraImageCmd(s, m); !strings.HasPrefix(got, "C:/vulkan/sd-server.exe ") { t.Errorf("preview = %q, want the pinned build first", got) } } func TestIsImageArch(t *testing.T) { for _, a := range []string{"flux", "FLUX", "flux.1", "sd3", "sd3.5", "qwen_image", "z_image", " stable-diffusion "} { if !isImageArch(a) { t.Errorf("isImageArch(%q) = false, want true", a) } } for _, a := range []string{"", "qwen3moe", "llama", "gemma4"} { if isImageArch(a) { t.Errorf("isImageArch(%q) = true, want false", a) } } } func TestEmitImageModel(t *testing.T) { var b strings.Builder var emitted []string onDisk(t, "ae.safetensors", "clip_l.safetensors", "t5xxl.gguf", "zae.safetensors", "qwen3.gguf") pool := EncoderSet{FluxVae: "ae.safetensors", ClipL: "clip_l.safetensors", T5: "t5xxl.gguf", ZimageVae: "zae.safetensors", QwenLlm: "qwen3.gguf"} s := Settings{SdServerExe: "sd-server", TtlSec: 600, TargetVramGB: 7, VramOverheadGB: 0.5, Threads: 7, Encoders: pool} // Big model (6.5GB + 1.5 compute > 6.5 budget) → offload path. No per-model // override: VAE + CLIP-L + T5 must be auto-attached from the arch (flux) pool. big := GgufRow{FullPath: `C:\models\flux.gguf`, SizeGB: 6.5} emitImageModel(&b, s, big, &Override{}, "flux-q4", "flux", false, 0, &emitted) out := b.String() for _, want := range []string{"sd-server", "--diffusion-model C:/models/flux.gguf", "--listen-port ${PORT}", "--max-vram 6.5", "--vae ae.safetensors", "--clip_l clip_l.safetensors", "--t5xxl t5xxl.gguf", "--diffusion-fa", "--vae-tiling", "--offload-to-cpu", "--backend te=cpu,vae=cpu", "offload=true", "checkEndpoint: /", "out: [image]", "in: [text]", "ttl: 600"} { if !strings.Contains(out, want) { t.Errorf("emit missing %q:\n%s", want, out) } } // One --backend spec carries the VAE placement; the deprecated flag is gone. if strings.Contains(out, "--vae-on-cpu") { t.Errorf("offload should place the VAE via --backend, not --vae-on-cpu:\n%s", out) } if strings.Contains(out, "WARNING") || strings.Contains(out, "missingComponents") { t.Errorf("complete pool should not warn:\n%s", out) } if len(emitted) != 1 || emitted[0] != "flux-q4" { t.Errorf("emitted = %v, want [flux-q4]", emitted) } // Override knobs: component paths + gen defaults emit, toggled-off savers omit, // offload pinned off wins over the auto "fits" decision. var b3 strings.Builder var em3 []string ov := &Override{ VaePath: `C:\models\ae.safetensors`, ClipLPath: "clip_l.gguf", T5Path: "t5xxl.gguf", TextEncoderPath: "qwen3.gguf", VaeTiling: "off", DiffusionFa: "off", TeOnCpu: "off", OffloadToCpu: "off", DefaultSteps: 8, DefaultCfg: 1.0, DefaultSampler: "euler", DefaultWidth: 768, DefaultHeight: 512, ExtraArgs: "--clip-on-cpu", } emitImageModel(&b3, s, big, ov, "flux-tuned", "flux", false, 0, &em3) out3 := b3.String() for _, want := range []string{"--vae C:/models/ae.safetensors", "--clip_l clip_l.gguf", "--t5xxl t5xxl.gguf", "--llm qwen3.gguf", "--steps 8", "--cfg-scale 1", "--sampling-method euler", "--width 768", "--height 512", "--clip-on-cpu", "offload=false"} { if !strings.Contains(out3, want) { t.Errorf("tuned emit missing %q:\n%s", want, out3) } } for _, unwant := range []string{"--vae-tiling", "--diffusion-fa", "--backend te=cpu", "--offload-to-cpu"} { if strings.Contains(out3, unwant) { t.Errorf("tuned emit should omit %q:\n%s", unwant, out3) } } // Small model (2GB + 1.5 < 6.5 budget) → fits resident, no offload flags. var b2 strings.Builder var em2 []string small := GgufRow{FullPath: `C:\models\sd15.gguf`, SizeGB: 2.0} emitImageModel(&b2, s, small, &Override{}, "sd15-q4", "sd1", true, 0, &em2) out2 := b2.String() // SD1 is a full-checkpoint arch → -m, no external components. if !strings.Contains(out2, "-m C:/models/sd15.gguf") || strings.Contains(out2, "--diffusion-model") { t.Errorf("sd1 should load via -m, not --diffusion-model:\n%s", out2) } if strings.Contains(out2, "--offload-to-cpu") || strings.Contains(out2, "offload=true") { t.Errorf("small model should not offload:\n%s", out2) } // vae=cpu rides with offload only; a resident model keeps VAE on GPU. if strings.Contains(out2, "vae=cpu") || strings.Contains(out2, "--vae-on-cpu") { t.Errorf("resident model should not force vae-on-cpu:\n%s", out2) } // vae-tiling is always on (caps the VAE decode VRAM spike), even when resident. if !strings.Contains(out2, "--vae-tiling") { t.Errorf("small model should still vae-tile:\n%s", out2) } // te=cpu is unconditional (external encoder always parked on CPU), even when the // diffusion weights fit resident. if !strings.Contains(out2, "--backend te=cpu") { t.Errorf("small model should still set te=cpu:\n%s", out2) } } func TestFluxGuidance(t *testing.T) { cases := map[string]struct { val float64 ok bool }{ "flux1-kontext-dev-Q4_K_M": {2.5, true}, "flux1-fill-dev-Q4_K_S": {30, true}, "flux1-schnell-Q4_K_S": {0, false}, "flux1-dev": {0, false}, } for name, want := range cases { if v, ok := fluxGuidance(name); v != want.val || ok != want.ok { t.Errorf("fluxGuidance(%q) = (%g,%t), want (%g,%t)", name, v, ok, want.val, want.ok) } } } func TestResolveComponents(t *testing.T) { pool := EncoderSet{FluxVae: "ae", ClipL: "cl", ClipG: "cg", T5: "t5", ZimageVae: "zae", QwenLlm: "qwen"} // flux: vae + clip_l + t5, nothing missing. if c, m := resolveComponents(pool, nil, "flux", "flux1-schnell", nil, 0, videoInfo{}); c.vae != "ae" || c.clipL != "cl" || c.t5 != "t5" || c.clipG != "" || c.llm != "" || len(m) != 0 { t.Errorf("flux: got %+v missing=%v", c, m) } // chroma (arch flux, name-detected): vae + t5, NO clip_l. if c, m := resolveComponents(pool, nil, "flux", "Chroma1-HD-Q5_K_M", nil, 0, videoInfo{}); c.vae != "ae" || c.t5 != "t5" || c.clipL != "" || len(m) != 0 { t.Errorf("chroma should be vae+t5 only: got %+v missing=%v", c, m) } // z-image (arch lumina2): vae + llm. if c, m := resolveComponents(pool, nil, "lumina2", "Z-Image-Turbo", nil, 0, videoInfo{}); c.vae != "zae" || c.llm != "qwen" || c.clipL != "" || len(m) != 0 { t.Errorf("z-image should be vae+llm: got %+v missing=%v", c, m) } // sdxl: full-checkpoint arch → never resolves external components (loads via -m). // resolveComponents isn't called for it in emit, and falls through to attach nothing. if c, m := resolveComponents(pool, nil, "sdxl", "animagineXLV31", nil, 0, videoInfo{}); c.vae != "" || c.clipL != "" || c.clipG != "" || len(m) != 0 { t.Errorf("sdxl should attach nothing (served via -m): got %+v missing=%v", c, m) } // empty pool: flux reports every required role missing. if _, m := resolveComponents(EncoderSet{}, nil, "flux", "flux1-fill-dev", nil, 0, videoInfo{}); len(m) != 3 { t.Errorf("empty-pool flux should miss 3 roles, got %v", m) } // override supplies what the pool lacks → wins and clears the missing role. ov := &Override{VaePath: "ov-ae", ClipLPath: "ov-cl", T5Path: "ov-t5"} if c, m := resolveComponents(EncoderSet{}, ov, "flux", "flux1-fill-dev", nil, 0, videoInfo{}); c.vae != "ov-ae" || c.clipL != "ov-cl" || c.t5 != "ov-t5" || len(m) != 0 { t.Errorf("override should win and clear missing: got %+v missing=%v", c, m) } } // LongCat ships its DiT in BFL (flux) tensor layout, so it reports // general.architecture "flux" and would otherwise be wired like flux.1: // clip_l + t5xxl and no --llm. It actually wants flux.1's AE plus a Qwen2.5-VL // text encoder, and the edit variant additionally wants that encoder's vision // projector. Nothing here is declared: the components come from the scanned // pool, matched on the width the DiT itself asks for. func TestResolveComponents_LongCatFromPool(t *testing.T) { pool := &EncoderPool{Files: []ComponentFile{ {Path: "/m/ae.safetensors", Role: RoleVae, Family: VaeFamilyFlux}, {Path: "/m/clip_l.safetensors", Role: RoleClip, Width: 768}, {Path: "/m/t5xxl.gguf", Role: RoleT5, Width: 4096}, {Path: "/m/qwen25vl/model-Q8_0.gguf", Role: RoleLlm, Width: 3584, SizeGB: 8, Mmproj: "/m/qwen25vl/mmproj-F16.gguf", Vision: true}, {Path: "/m/qwen3-4b.gguf", Role: RoleLlm, Width: 2560, SizeGB: 3}, }} // Edit variant: vae + llm + the paired projector, and NO clip_l/t5. c, m := resolveComponents(EncoderSet{}, nil, "flux", "LongCat-Image-Edit-Turbo-Q8_0", pool, 3584, videoInfo{}) if len(m) != 0 { t.Fatalf("missing = %v, want none", m) } if c.vae != "/m/ae.safetensors" { t.Errorf("vae = %q", c.vae) } if c.llm != "/m/qwen25vl/model-Q8_0.gguf" { t.Errorf("llm = %q", c.llm) } if c.llmVision != "/m/qwen25vl/mmproj-F16.gguf" { t.Errorf("llm_vision = %q", c.llmVision) } if c.clipL != "" || c.t5 != "" || c.clipG != "" { t.Errorf("longcat must not take clip/t5: %+v", c) } // Base (non-edit) LongCat: same encoder, no projector. c2, _ := resolveComponents(EncoderSet{}, nil, "flux", "LongCat-Image-Q8_0", pool, 3584, videoInfo{}) if c2.llm != "/m/qwen25vl/model-Q8_0.gguf" || c2.llmVision != "" { t.Errorf("base longcat: %+v", c2) } // A different caption width picks a different encoder with no name table. c3, _ := resolveComponents(EncoderSet{}, nil, "lumina2", "Z-Image-Turbo", pool, 2560, videoInfo{}) if c3.llm != "/m/qwen3-4b.gguf" { t.Errorf("z-image llm = %q", c3.llm) } // A declared encoder still wins, and drags its own neighbour projector. ov := &Override{TextEncoderPath: "/m/qwen25vl/model-Q8_0.gguf"} c4, _ := resolveComponents(EncoderSet{}, ov, "flux", "LongCat-Image-Edit", pool, 3584, videoInfo{}) if c4.llm != ov.TextEncoderPath || c4.llmVision != "/m/qwen25vl/mmproj-F16.gguf" { t.Errorf("declared encoder: %+v", c4) } // llmVision "off" drops the projector without touching the encoder pick. c5, _ := resolveComponents(EncoderSet{}, &Override{LlmVision: "off"}, "flux", "LongCat-Image-Edit", pool, 3584, videoInfo{}) if c5.llm == "" || c5.llmVision != "" { t.Errorf("llmVision off: %+v", c5) } } // Width narrows the field, the declaration names the file. A pool holding both a // Qwen3-4B and a Qwen3-VL-4B of the same caption width (both are 2560) used to // resolve to the VL file on the size/path tiebreak no matter what // settings.encoders.qwenLlm pinned, which wired Z-Image to the encoder Krea-2 // wants. Regression: 6aea9f5 (discover image encoders instead of declaring them). func TestResolveComponents_DeclaredLlmWins(t *testing.T) { const pin = "/m/lmstudio/Qwen3-4B-Instruct-2507-Q8_0.gguf" pool := &EncoderPool{Files: []ComponentFile{ {Path: "/m/QwenVL/Qwen3VL-4B-Instruct-Q8_0.gguf", Role: RoleLlm, Width: 2560, SizeGB: 3.99, Vision: true, Mmproj: "/m/QwenVL/mmproj-Qwen3VL-4B-Instruct-F16.gguf"}, {Path: pin, Role: RoleLlm, Width: 2560, SizeGB: 3.99}, }} onDisk(t, "ae.safetensors") enc := EncoderSet{ZimageVae: "ae.safetensors", QwenLlm: pin} c, m := resolveComponents(enc, nil, "lumina2", "Z-Image-Turbo", pool, 2560, videoInfo{}) if len(m) != 0 { t.Fatalf("missing = %v, want none", m) } if c.llm != pin { t.Errorf("llm = %q, want the declared %q", c.llm, pin) } if c.vae != enc.ZimageVae { t.Errorf("vae = %q", c.vae) } // Krea-2 is the model the VL file is right for, and a per-model override // still beats both the declaration and the scan. ov := &Override{TextEncoderPath: "/m/QwenVL/Qwen3VL-4B-Instruct-Q8_0.gguf"} c2, _ := resolveComponents(enc, ov, "qwen_image", "krea2_turbo-Q8_0", pool, 2560, videoInfo{}) if c2.llm != ov.TextEncoderPath { t.Errorf("per-model override lost to the global pin: %q", c2.llm) } } func TestMergeImageVariant(t *testing.T) { base := Override{ VaePath: "ae.safetensors", TextEncoderPath: "qwen3.gguf", DefaultSteps: 30, DefaultCfg: 7, VramTargetGB: 6, } // A "fast" preset overrides only steps/cfg. v := VariantSpec{Name: "fast", DefaultSteps: 8, DefaultCfg: 1} got := mergeImageVariant(base, v) if got.VaePath != "ae.safetensors" || got.TextEncoderPath != "qwen3.gguf" { t.Errorf("preset should inherit component paths, got %+v", got) } if got.DefaultSteps != 8 || got.DefaultCfg != 1 { t.Errorf("preset should override steps/cfg, got steps=%d cfg=%g", got.DefaultSteps, got.DefaultCfg) } if got.VramTargetGB != 6 { t.Errorf("preset should inherit vram budget, got %g", got.VramTargetGB) } if got.Variants != nil { t.Errorf("merged override must clear Variants to avoid re-emit") } } // Flux.2 is the only family whose caption width is not its encoder width: it // concatenates 3 LLM hidden layers, so klein 9B states txt_in 12288 for a // 4096-wide Qwen3-8B. The variants differ only in that arithmetic, which is why // none of them is named here -- the width match resolves all three. func TestResolveComponents_Flux2Klein(t *testing.T) { onDisk(t, "f2ae", "elsewhere-qwen3-8b.gguf") enc := EncoderSet{Flux2Vae: "f2ae", QwenLlm: "/m/qwen3-4b.gguf"} pool := &EncoderPool{Files: []ComponentFile{ {Path: "/m/qwen3-8b.gguf", Role: RoleLlm, Family: "qwen3", Width: 4096, SizeGB: 8}, {Path: "/m/qwen3-4b.gguf", Role: RoleLlm, Family: "qwen3", Width: 2560, SizeGB: 3}, }} // klein 9B: the flux.2 VAE and the 8B encoder, no clip_l/t5. if c, m := resolveComponents(enc, nil, "flux", "Flux2-Klein-9B-True-v2-Q8_0", pool, 12288, videoInfo{}); c.vae != "f2ae" || c.llm != "/m/qwen3-8b.gguf" || c.clipL != "" || c.t5 != "" || len(m) != 0 { t.Errorf("klein 9B: got %+v missing=%v", c, m) } // klein 4B: same branch, 7680 = 3 x 2560, resolves to the smaller encoder // with no extra case in resolveComponents. if c, m := resolveComponents(enc, nil, "flux", "flux2-klein-4b-Q8_0", pool, 7680, videoInfo{}); c.llm != "/m/qwen3-4b.gguf" || len(m) != 0 { t.Errorf("klein 4B: got %+v missing=%v", c, m) } // The encoder is not on disk. The global qwenLlm pin is 2560 wide and the // scan classified it, so substituting it would wire a 12288-caption DiT to a // 2560-wide encoder and emit NOTHING -- req only warns on an empty path. // Report the role missing instead. only4b := &EncoderPool{Files: []ComponentFile{ {Path: "/m/qwen3-4b.gguf", Role: RoleLlm, Family: "qwen3", Width: 2560, SizeGB: 3}, }} if c, m := resolveComponents(enc, nil, "flux", "Flux2-Klein-9B-True-v2-Q8_0", only4b, 12288, videoInfo{}); c.llm != "" || len(m) != 1 || m[0] != "llm" { t.Errorf("klein 9B without its encoder should report llm missing: got %+v missing=%v", c, m) } // A pin the scan never saw (kept outside the models root) is still honoured: // that is the case the fallback exists for. unseen := EncoderSet{Flux2Vae: "f2ae", QwenLlm: "elsewhere-qwen3-8b.gguf"} if c, m := resolveComponents(unseen, nil, "flux", "Flux2-Klein-9B-True-v2-Q8_0", only4b, 12288, videoInfo{}); c.llm != "elsewhere-qwen3-8b.gguf" || len(m) != 0 { t.Errorf("an unscanned pin should still be honoured: got %+v missing=%v", c, m) } } func TestImageDefaultsFor(t *testing.T) { cases := []struct { name, arch, model string cond int64 steps int cfg float64 }{ {"2.1 turbo", "qwen_image", "qwen_image_2.1_turbo-q8_0", 4096, 8, 1.0}, {"krea2 turbo", "qwen_image", "krea2_turbo-q8_0", 2560, 8, 1.0}, {"2.1 base", "qwen_image", "qwen_image_2.1-q8_0", 4096, 40, 1.0}, {"20B line untouched", "qwen_image", "qwen-image-edit-q4_k_m", 3584, 0, 0}, {"other arch untouched", "flux", "flux1-schnell-turbo", 4096, 0, 0}, } for _, c := range cases { d := imageDefaultsFor(c.arch, c.model, c.cond) if d.steps != c.steps || d.cfg != c.cfg { t.Errorf("%s: got steps %d cfg %g, want %d / %g", c.name, d.steps, d.cfg, c.steps, c.cfg) } } // The override still wins over the arch profile. d := resolveGenDefaults(videoInfo{}, "qwen_image", "qwen_image_2.1_turbo-q8_0", 4096, &Override{DefaultSteps: 12}) if d.steps != 12 || d.cfg != 1.0 { t.Errorf("override: got steps %d cfg %g, want 12 / 1", d.steps, d.cfg) } }