package autogen import ( "encoding/binary" "encoding/json" "errors" "io" "io/fs" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" "github.com/quartermaster-labs/quartermaster/internal/config" ) // Diffusion component auto-discovery. // // A bare --diffusion-model GGUF carries no VAE and no text encoder, so sd-server // needs those wired as separate files. They used to come exclusively from // settings.encoders: a hand-written path per role, per machine, which is the // opposite of this project's "no hand tuning" goal and silently broke whenever // the models tree moved. // // Every one of those files announces what it is, structurally, in its header, so // the pool is discovered instead of declared: // // - a VAE has an encoder/decoder conv stack, and the thing that makes two VAEs // incompatible (latent channel count) is literally decoder.conv_in's input // width: 4 = SD/SDXL, 16 = flux.1 "ae", 32 = flux.2. Filenames cannot do // this job: three unrelated files on the dev box are all "ae.safetensors", // and two of those turned out to be byte-identical copies. // - a CLIP has text_model.embeddings.token_embedding, whose width separates // CLIP-L (768) from CLIP-G (1280). // - a text-encoder LLM reports its own hidden width (gguf embedding_length, or // safetensors model.embed_tokens), and the DiT that needs it states the width // it expects in its caption-projection tensor (Metadata.CondHidden). Matching // those two numbers picks the right encoder with no name table: verified // against LongCat and Qwen-Image-Edit (3584 = Qwen2.5-VL-7B), Z-Image and // Krea2 (2560 = Qwen3-4B / Qwen3-VL-4B), ERNIE (3072 = Ministral-3B) and // flux (4096 = T5-XXL). // // settings.encoders still wins wherever it is set, so an existing config keeps // working and an odd pick stays overridable. // ComponentRole is what a discovered component file is FOR. One file has exactly // one role; the family/width fields below say which models can use it. type ComponentRole string const ( RoleNone ComponentRole = "" RoleVae ComponentRole = "vae" // RoleAudioVae decodes an AUDIO latent, not a picture. Its own role rather // than a VAE family because it is never a substitute for one: a video model // that emits a soundtrack loads BOTH (--vae and --audio-vae). RoleAudioVae ComponentRole = "audio-vae" RoleClip ComponentRole = "clip" RoleT5 ComponentRole = "t5" RoleLlm ComponentRole = "llm" // RoleRecurrentLlm is an LLM with recurrent (GatedDeltaNet/SSM) layers. It // is its own role, not RoleLlm with a flag, so that no width-matched pick // (Llm, LlmHinted, fillEncoderSet) can ever reach one: most conditioners // are a plain transformer stack and die loading it (see isRecurrentLlm). // Only a family whose sd.cpp runner ships a recurrent conditioner asks for // it by name, through RecurrentLlm (MOSS-Image on Qwen3.5-9B). RoleRecurrentLlm ComponentRole = "recurrent-llm" // RoleTokenizer is a standalone HF tokenizer.json, which sd.cpp takes as // --tokenizer for a family whose conditioner will not use the vocab baked // into the encoder gguf (MOSS-Image: "requires the official Qwen3.5 // tokenizer.json"). Recorded by FILENAME, never parsed: a 20 MB JSON per // HF folder per scan is not worth the read, and the picker is hint-scoped. RoleTokenizer ComponentRole = "tokenizer" // roleProj is a vision projector: paired to an llm, never picked alone. roleProj ComponentRole = "mmproj" ) // VAE families, keyed by latent channel count (decoder.conv_in input width). // The count IS the compatibility class: a 16-channel model cannot decode a // 4-channel latent, so a family mismatch is a broken image, not a quality knob. const ( VaeFamilySD = "sd" // 4 ch: SD1/SD2/SDXL VaeFamilyFlux = "flux" // 16 ch: flux.1 "ae", and every model that reuses it VaeFamilyFlux2 = "flux2" // 32 ch: flux.2 / ERNIE (AutoencoderKLFlux2) VaeFamilyWan3D = "wan3d" // Wan 2.1-derived 3D causal VAE (conv1 is 5-dim) // VaeFamilyVideo3D is a transformer 3D video autoencoder (MiniMax-H3): the // encoder patchifies RGB with a conv3d but the DECODER is a transformer, so // it has neither decoder.conv_in nor Wan's bare conv1. VaeFamilyVideo3D = "video3d" // VaeFamilyLtx is the LTX-2.x pair of autoencoders. Both halves carry this // family: they ship as two files (video + audio) that are only ever loaded // TOGETHER, and giving them one family is what keeps an LTX model from being // handed MiniMax-H3's soundtrack decoder, which is a different role-mate // entirely. The video half is a 128-channel 3D conv VAE whose convolutions // are wrapped one level deeper than anyone else's (decoder.conv_in.CONV. // weight), which is also why neither arm above sees it. VaeFamilyLtx = "ltx" ) // ComponentFile is one classified file on disk. type ComponentFile struct { Path string Role ComponentRole Family string // VAE family, or the gguf arch for an llm/t5 Width int64 // hidden width: clip 768/1280, llm/t5 embedding length SizeGB float64 Mmproj string // llm only: the vision projector paired to it (see pairProjectors) Vision bool // llm only: carries (or is paired to) a vision tower } // EncoderPool is the discovered set of component files. type EncoderPool struct { Files []ComponentFile } // safetensorsHeaderCap bounds the JSON header read. Real headers are tens of KB // (a few hundred for a full checkpoint); anything past this is a corrupt or // hostile length field, not a model. const safetensorsHeaderCap = 64 << 20 type stTensor struct { DType string `json:"dtype"` Shape []int64 `json:"shape"` } // readSafetensorsHeader parses the tensor table at the head of a .safetensors // file: an 8-byte little-endian JSON length, then that many bytes of JSON. Only // the header is read, so the cost is independent of the file size. func readSafetensorsHeader(path string) (map[string]stTensor, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var n uint64 if err := binary.Read(f, binary.LittleEndian, &n); err != nil { return nil, err } if n == 0 || n > safetensorsHeaderCap { return nil, io.ErrUnexpectedEOF } buf := make([]byte, n) if _, err := io.ReadFull(f, buf); err != nil { return nil, err } var raw map[string]json.RawMessage if err := json.Unmarshal(buf, &raw); err != nil { return nil, err } out := make(map[string]stTensor, len(raw)) for k, v := range raw { if k == "__metadata__" { continue } var t stTensor if json.Unmarshal(v, &t) == nil { out[k] = t } } return out, nil } // classifySafetensors identifies a .safetensors component from its tensor table. // Returns RoleNone for anything that is not a component (a DiT, a LoRA, a full // checkpoint) so the caller can ignore it. func classifySafetensors(h map[string]stTensor, sizeGB float64, path string) ComponentFile { c := ComponentFile{Path: path, SizeGB: sizeGB} dim := func(name string, i int) int64 { t, ok := h[name] if !ok || i >= len(t.Shape) { return 0 } return t.Shape[i] } switch { // 2D AE (SD / flux lineage). decoder.conv_in.weight is [out, latent, 3, 3], // so shape[1] is the latent channel count = the compatibility family. case dim("decoder.conv_in.weight", 1) > 0: c.Role = RoleVae switch dim("decoder.conv_in.weight", 1) { case 4: c.Family = VaeFamilySD case 16: c.Family = VaeFamilyFlux case 32: // The FLUX.2 "small decoder" (MOSS-Image ships it as its vae/) has // the same 32-ch latent but a 96-wide decoder (conv_in out 384, // not 512). sd.cpp builds the FLUX.2 autoencoder at a fixed // width, so it fails to load; left in the family, the unhinted // path sort would hand it to every FLUX.2 / ERNIE / MOSS model. if dim("decoder.conv_in.weight", 0) != 512 { return ComponentFile{} } c.Family = VaeFamilyFlux2 default: return ComponentFile{} } // Wan 2.1-derived 3D causal VAE: conv1.weight is 5-dim. Wan 2.1 and the // Qwen-Image 20B line have the identical shape table, so those two need a // path hint to pick between them (see EncoderPool.Vae). // // Width is the LATENT channel count, read off the decoder's input projection // ([out, latent, t, h, w]). It is 16 for Wan 2.1 and Qwen-Image 20B, and 64 // for Qwen-Image 2.1's RGBA autoencoder, which is the first member of this // family whose latent is a different size. A 64-channel VAE handed to a // 16-channel DiT decodes to noise rather than failing, so the width is what // keeps the filename sort in pickByHint from deciding it. case len(h["conv1.weight"].Shape) == 5: c.Role = RoleVae c.Family = VaeFamilyWan3D c.Width = dim("decoder.conv1.weight", 1) // Transformer 3D video VAE (MiniMax-H3). Neither arm above sees it: the // decoder is a transformer stack (decoder.x_embedder), so there is no // decoder.conv_in and no bare conv1. What remains is the encoder's conv3d // stem, encoder.conv_in.weight = [out, 3, t, h, w]. Deliberately placed // AFTER the wan3d arm so a Wan VAE that also carries this tensor keeps its // own family. case len(h["encoder.conv_in.weight"].Shape) == 5: c.Role = RoleVae c.Family = VaeFamilyVideo3D // LTX-2.x video VAE. Its conv modules are wrapped (LTXVideoCausalConv3d), // so the tensor is decoder.conv_in.CONV.weight = [out, latent, t, h, w] and // neither the 2D arm nor the two 3D arms above match it. shape[1] is the // latent channel count (128 for LTX-2.5), kept as the width so a future // second latent size is a family mismatch rather than a silent bad decode. case len(h["decoder.conv_in.conv.weight"].Shape) == 5: c.Role = RoleVae c.Family = VaeFamilyLtx c.Width = dim("decoder.conv_in.conv.weight", 1) // LTX-2.x audio VAE. One file, two stacks: the VAE proper under an // audio_vae. prefix plus the vocoder that turns its mel output into samples. // The decoder input projection is a 2D conv over the mel spectrogram // ([width, latent, 3, 3]), NOT the 1D stack H3 uses, so it needs its own arm // and carries the LTX family so the two audio decoders never cross-wire. case len(h["audio_vae.decoder.conv_in.conv.weight"].Shape) == 4: c.Role = RoleAudioVae c.Family = VaeFamilyLtx c.Width = dim("audio_vae.decoder.conv_in.conv.weight", 1) // Audio VAE (MiniMax-H3): a 1D conv stack, so the decoder input projection // is [width, latent, 1]. Width here is the LATENT channel count, which is // what has to match the DiT's audio_patch_proj. case len(h["dec_in_proj.weight"].Shape) == 3: c.Role = RoleAudioVae c.Family = VaeFamilyVideo3D c.Width = dim("dec_in_proj.weight", 1) // CLIP text tower. token_embedding is [vocab, width]; 768 = CLIP-L, 1280 = G. case dim("text_model.embeddings.token_embedding.weight", 1) > 0: c.Role = RoleClip c.Width = dim("text_model.embeddings.token_embedding.weight", 1) // A plain decoder LLM used as a text encoder (ERNIE takes Ministral-3 this // way). Hidden width comes from the embedding table; a vision_tower prefix // means it can also condition on a reference image. case dim("model.embed_tokens.weight", 1) > 0: c.Role = RoleLlm c.Width = dim("model.embed_tokens.weight", 1) for k := range h { if strings.HasPrefix(k, "vision_tower.") || strings.HasPrefix(k, "visual.") { c.Vision = true break } } // T5 encoder stack. case dim("encoder.block.0.layer.0.SelfAttention.q.weight", 0) > 0: c.Role = RoleT5 c.Width = dim("encoder.block.0.layer.0.SelfAttention.q.weight", 0) default: return ComponentFile{} } return c } // ScanEncoderPool walks the model roots and classifies every diffusion component // it finds. GGUF headers come from the shared metadata cache, so a component // GGUF is not parsed twice when discovery has already read it. func ScanEncoderPool(roots []string) *EncoderPool { p := &EncoderPool{} projByDir := map[string]ComponentFile{} seen := map[string]bool{} for _, root := range roots { if strings.TrimSpace(root) == "" { continue } abs, err := filepath.Abs(root) if err != nil { continue } filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } key := config.PathKey(path) if seen[key] { return nil } fi, e := d.Info() if e != nil { return nil } sizeGB := round(float64(fi.Size())/gib, 2) if strings.EqualFold(d.Name(), "tokenizer.json") { seen[key] = true p.Files = append(p.Files, ComponentFile{Path: path, Role: RoleTokenizer, SizeGB: sizeGB}) return nil } switch strings.ToLower(filepath.Ext(path)) { case ".safetensors": h, e := readSafetensorsHeader(path) if e != nil { return nil } // Same exclusion as the gguf ladder below: a PE published as // safetensors is still not an encoder. if c := classifySafetensors(h, sizeGB, path); c.Role != RoleNone && !(c.Role == RoleLlm && IsPromptEnhancerFile(path)) { seen[key] = true p.Files = append(p.Files, c) } case ".gguf": meta, e := ReadGgufMetadataCached(path) if e != nil { return nil } arch := strings.ToLower(strings.TrimSpace(meta.Architecture)) switch { case arch == "clip": // A vision projector is never a standalone encoder: it is // paired to the LLM gguf sitting beside it. projByDir[config.PathKey(filepath.Dir(path))] = ComponentFile{ Path: path, Role: roleProj, SizeGB: sizeGB, } case encoderArch[arch]: seen[key] = true p.Files = append(p.Files, ComponentFile{ Path: path, Role: RoleT5, Family: arch, Width: meta.EmbeddingLength, SizeGB: sizeGB, }) case isImageArch(effectiveImageArch(meta)) || meta.EmbeddingLength == 0: // A diffusion model, or something with no hidden width: // not usable as a text encoder. case IsPromptEnhancerFile(path): // A prompt rewriter is a chat finetune NAMED AFTER the image // model it serves, so it lands in that model's own width class // while being the one file there that was never trained as its // conditioner. Worse, it is usually the largest: better() breaks // a same-arch tie on file size, so a BF16 PE outranks the real // Q8_0 encoder and the model renders confident nonsense. case isDraftSidecar(filepath.Base(path)): // A drafter is a reduced head, not an encoder. Its width // can coincide with a real encoder's (the Gemma-4 MTP // sidecars are the case), so leaving it in the pool risks // conditioning a diffusion model on a speculation head. case IsDecisionModel(meta): // A decision model is a classifier head over its LM; its // width can match a real encoder's (kev is a Qwen3), but it // was never trained to condition anything. case IsEmbeddingModel(meta): // A pooled embedder emits one vector for the whole prompt, // not the per-token sequence a DiT cross-attends to. case isRecurrentLlm(meta): // Most of sd.cpp's conditioners are a plain transformer // stack, so a hybrid cannot load there at all. Left in the // LLM pool it still wins its width class: // MiMo-V2.6-Distill-Qwen-9B (a Qwen3.5 hybrid) is 4096 // wide, arch qwen*, has an mmproj and outweighs // Qwen3-VL-8B, so it took Qwen-Image 2.1 and sd-server // died on "model metadata validation failed". So it is // recorded under its own role, which only a family with a // recurrent conditioner (MOSS-Image) asks for. seen[key] = true p.Files = append(p.Files, ComponentFile{ Path: path, Role: RoleRecurrentLlm, Family: arch, Width: meta.EmbeddingLength, SizeGB: sizeGB, }) default: seen[key] = true p.Files = append(p.Files, ComponentFile{ Path: path, Role: RoleLlm, Family: arch, Width: meta.EmbeddingLength, SizeGB: sizeGB, }) } } return nil }) } p.pairProjectors(projByDir) sort.Slice(p.Files, func(i, j int) bool { return p.Files[i].Path < p.Files[j].Path }) return p } // isRecurrentLlm reports whether an LLM gguf carries recurrent (linear/SSM) // layers: a GatedDeltaNet hybrid like Qwen3.5/3.6 states full_attention_interval, // and a Mamba-style model or hybrid states its ssm.* sizes. No diffusion model // conditions on one, and the same keys already drive the recurrent KV and // checkpoint sizing, so this is no new identity table. func isRecurrentLlm(meta Metadata) bool { return meta.FullAttnInterval > 0 || meta.SsmStateSize > 0 || meta.SsmInnerSize > 0 } // isDraftSidecar reports whether a gguf filename is one of the speculation // sidecars discovery already refuses to serve on its own. The pool reuses the // same patterns so a drafter never becomes a text encoder. func isDraftSidecar(base string) bool { return mtpFileRe.MatchString(base) || fastMtpFileRe.MatchString(base) || dflashFileRe.MatchString(base) } // pairProjectors attaches each LLM gguf to the vision projector in its own // directory, reusing the convention discovery already relies on for vision LLMs // (publishers ship "mmproj-*.gguf" beside the model). This is what makes // --llm_vision free: the projector for a chosen --llm is simply its neighbour. func (p *EncoderPool) pairProjectors(projByDir map[string]ComponentFile) { for i := range p.Files { if p.Files[i].Role != RoleLlm { continue } if proj, ok := projByDir[config.PathKey(filepath.Dir(p.Files[i].Path))]; ok { p.Files[i].Mmproj = proj.Path p.Files[i].Vision = true } } } // Vae returns the discovered VAE of a family, or "" when none is on disk. hints // are path substrings preferred when a family holds more than one file: the // Wan-2.1 and Qwen-Image VAEs have the same shape table (same 194 tensors, every // dimension equal), so nothing but the path distinguishes them. With several // candidates and no hint match the pick is the first path in sorted order, which // is at least stable across regens. func (p *EncoderPool) Vae(family string, hints ...string) string { return p.VaeOfWidth(family, 0, hints...) } // VaeOfWidth is Vae narrowed to a latent channel count. want 0 means "no // opinion" and behaves exactly like Vae, which is what every family with a // single latent size passes. // // A want that matches nothing falls back to the full candidate list rather than // returning "": a family whose files predate the width being recorded (Width 0) // would otherwise turn a working config into a missing-vae warning, and an // unfiltered pick is no worse than what this function did before. func (p *EncoderPool) VaeOfWidth(family string, want int64, hints ...string) string { if p == nil { return "" } var cands, sized []ComponentFile for _, f := range p.Files { if f.Role != RoleVae || f.Family != family { continue } cands = append(cands, f) if want > 0 && f.Width == want { sized = append(sized, f) } } if len(sized) > 0 { return pickByHint(sized, hints) } return pickByHint(cands, hints) } // pickByHint returns the first candidate whose path contains one of the hints // (hints in priority order), else the first candidate, else "". func pickByHint(cands []ComponentFile, hints []string) string { if len(cands) == 0 { return "" } for _, h := range hints { h = strings.ToLower(h) if h == "" { continue } for _, c := range cands { if strings.Contains(strings.ToLower(filepath.ToSlash(c.Path)), h) { return c.Path } } } return cands[0].Path } // AudioVae returns the discovered audio VAE of a family, or "" when none is on // disk. An empty family matches any, which is what a box with a single audio // model wants; naming one is what keeps a machine holding BOTH an LTX and a // MiniMax-H3 checkpoint from handing either the other's soundtrack decoder (the // two are unrelated networks over unrelated latents, so a cross-wire is a load // failure at best). hints work exactly as in Vae. func (p *EncoderPool) AudioVae(family string, hints ...string) string { if p == nil { return "" } var cands []ComponentFile for _, f := range p.Files { if f.Role == RoleAudioVae && (family == "" || f.Family == family) { cands = append(cands, f) } } return pickByHint(cands, hints) } // LlmHinted returns the text-encoder LLM whose PATH matches one of the hints, // or "" when none does. Unlike Llm it never falls back to an arbitrary // candidate: it exists for a family whose encoder is a bespoke, republished file // (LTX ships a Gemma-4-12B with the caption projection grafted on), where the // width alone would also match every stock Gemma-3-12B on the disk. A miss here // is meant to fall through to the ordinary width-matched pick, not to guess. func (p *EncoderPool) LlmHinted(hints ...string) string { if p == nil { return "" } for _, h := range hints { h = strings.ToLower(strings.TrimSpace(h)) if h == "" { continue } for _, f := range p.Files { if f.Role != RoleLlm { continue } if strings.Contains(strings.ToLower(filepath.ToSlash(f.Path)), h) { return f.Path } } } return "" } // RecurrentLlm returns the recurrent (hybrid) text encoder of exactly this // width, for the one kind of caller that can load one: a family whose sd.cpp // runner ships its own recurrent conditioner (MOSS-Image's Qwen35DeltaNet). // Exact width only, no caption concatenation: no recurrent-conditioned DiT // stacks layers, and width is the only structural gate this role has. // // hints are tried first, in order, against the path: a hybrid distill of the // same base (MiMo-V2.6-Distill-Qwen-9B against Qwen3.5-9B) has the same width // and arch, and size would hand the DiT the wrong one. Without a hint hit the // usual better() order decides. func (p *EncoderPool) RecurrentLlm(hidden int64, hints ...string) string { if p == nil || hidden <= 0 { return "" } var cands []ComponentFile for _, f := range p.Files { if f.Role == RoleRecurrentLlm && f.Width == hidden { cands = append(cands, f) } } for _, h := range hints { h = strings.ToLower(strings.TrimSpace(h)) if h == "" { continue } for _, f := range cands { if strings.Contains(strings.ToLower(filepath.ToSlash(f.Path)), h) { return f.Path } } } var best ComponentFile for _, f := range cands { if best.Path == "" || better(f, best) { best = f } } return best.Path } // Tokenizer returns the standalone tokenizer.json whose path carries one of // the hints, tried in order. Hint-scoped with NO fallback, unlike the VAE and // LLM pickers: every HF model folder on the box ships a tokenizer.json, and a // wrong vocab is not a crash, it is an encoder reading a prompt as different // tokens. An empty answer surfaces as the "needs component [tokenizer]" // warning, which the model browser's companion download can clear. func (p *EncoderPool) Tokenizer(hints ...string) string { if p == nil { return "" } for _, h := range hints { h = strings.ToLower(strings.TrimSpace(h)) if h == "" { continue } for _, f := range p.Files { if f.Role == RoleTokenizer && strings.Contains(strings.ToLower(filepath.ToSlash(f.Path)), h) { return f.Path } } } return "" } // Clip returns the discovered CLIP tower of a given width (768 = L, 1280 = G). func (p *EncoderPool) Clip(width int64) string { if p == nil { return "" } for _, f := range p.Files { if f.Role == RoleClip && f.Width == width { return f.Path } } return "" } // T5 returns the discovered T5/UMT5 encoder, preferring the widest: T5-XXL is // 4096, and a stray smaller T5 would condition a flux model into mush. func (p *EncoderPool) T5() string { if p == nil { return "" } best := ComponentFile{} for _, f := range p.Files { if f.Role == RoleT5 && f.Width >= best.Width { best = f } } return best.Path } // encoderArchRank ranks a candidate text encoder by how likely its arch is to be // the one a diffusion model was trained against. Hidden width alone is NOT an // identity: on the dev box FIVE unrelated 2560-wide models are installed (Qwen3-4B, // Qwen3.5-4B, Qwen3-VL-4B, Granite-4.2-3B, Gemma-4-E4B), and picking the wrong one // produces confident garbage rather than an error, so a size tiebreak alone was // wiring Z-Image to Gemma. // // The ranking is shipped knowledge, not per-machine tuning: essentially every // LLM-conditioned DiT in circulation (Qwen-Image, Z-Image, LongCat, Krea, // Flux.2-Klein) conditions on a Qwen, and the Mistral rung covers ERNIE-Image and // Flux.2-dev. Lumina-Image-2.0 proper is the known exception (it conditions on // Gemma-2), which is why Gemma is ranked rather than excluded, and why // settings.encoders / textEncoderPath still win outright. func encoderArchRank(arch string) int { a := strings.ToLower(arch) switch { case strings.HasPrefix(a, "qwen"): return 3 case strings.HasPrefix(a, "mistral") || strings.HasPrefix(a, "ministral"): return 2 case strings.HasPrefix(a, "gemma") || strings.HasPrefix(a, "llama"): return 1 } return 0 } // better orders two same-width candidates: known encoder arch first, then the // larger file (the encoder runs once per generation, usually on the CPU, so a // higher quant is nearly free), then the path so a regen is reproducible. func better(f, best ComponentFile) bool { if r, br := encoderArchRank(f.Family), encoderArchRank(best.Family); r != br { return r > br } if f.SizeGB != best.SizeGB { return f.SizeGB > best.SizeGB } return f.Path < best.Path } // Llm returns the text-encoder LLM whose hidden width matches what the DiT's // caption projection expects, plus its vision projector when one is paired. // wantVision restricts the pick to encoders that have one: an edit model without // its vision tower does not fail, it silently conditions on nothing and emits an // unrelated image, which is worse than not starting at all. // // "Matches" is captionFactor, not equality: a DiT that concatenates several // encoder hidden layers states a caption width that is a MULTIPLE of the encoder // it wants (flux.2 klein 9B: txt_in 12288 against a 4096-wide Qwen3-8B). An // exact match always outranks a concatenated one. // // prefer is the declared settings.encoders.qwenLlm (empty when none declared). // A declared file that clears the same width and vision gates wins outright: // width only narrows the field, it does not name the file a publisher trained // against. Qwen3-4B-Instruct-2507 and Qwen3-VL-4B-Instruct are both 2560 wide // and round to the same size, so the tiebreak below will hand one of them to // either kind of DiT; a user who pinned the right one has answered the question. // A prefer that fails the gates (wrong width, no vision tower for a vision // model, a file the scan never saw) is ignored and the scan decides. // // Ties break by encoderArchRank, then file size, then path (see better). func (p *EncoderPool) Llm(hidden int64, wantVision bool, prefer string) (path, mmproj string) { return p.LlmArch(hidden, wantVision, prefer, "") } // LlmArch is Llm restricted to one encoder ARCHITECTURE (the gguf's // general.architecture, recorded as Family), for a DiT whose trained-against // encoder shares its width with a different model. "" means no restriction. // // Width cannot separate Qwen3-4B (`qwen3`) from Qwen3-VL-4B (`qwen3vl`): both // are 2560 wide, and FLUX.3 (8 x 2560) wants the VL one while Z-Image and // FLUX.2 klein want the plain one. Before this, the answer was whichever won // the tiebreak (the larger file) or the single global qwenLlm pin, so ONE of // the two families was always wired wrong on a box that had both, with no // warning: a mismatched encoder renders, just not the asked-for picture. // // The restriction applies to the pin too: a pin of the wrong architecture is // ignored and the scan decides, exactly as for a pin of the wrong width. A // file of UNKNOWN family (a safetensors encoder, which records no arch) is not // excluded: only a positively different architecture is. func (p *EncoderPool) LlmArch(hidden int64, wantVision bool, prefer, arch string) (path, mmproj string) { if p == nil { return "", "" } if hidden <= 0 { return "", "" } pin := config.PathKey(strings.TrimSpace(prefer)) var best, pinned ComponentFile var bestFactor, pinnedFactor int64 for _, f := range p.Files { if f.Role != RoleLlm { continue } factor := captionFactor(hidden, f.Width) if factor == 0 { continue } if wantVision && !f.Vision { continue } if arch != "" && f.Family != "" && !strings.EqualFold(f.Family, arch) { continue } if pin != "" && config.PathKey(f.Path) == pin { if pinnedFactor == 0 || factor < pinnedFactor { pinned, pinnedFactor = f, factor } continue } if bestFactor == 0 || factor < bestFactor || (factor == bestFactor && better(f, best)) { best, bestFactor = f, factor } } // The pin wins inside its tier, not across tiers: an exact-width scan hit // beats a pin that only fits by concatenation, because k>1 is an inference // about the model's layout and k==1 is not. if pinnedFactor != 0 && (bestFactor == 0 || pinnedFactor <= bestFactor) { return pinned.Path, pinned.Mmproj } return best.Path, best.Mmproj } // captionLayouts are the encoder-layer concatenations a DiT is known to use, // i.e. the k in txt_in = k x encoder width. 1 is every single-hidden-state DiT; // 3 is FLUX.2 (klein 9B: 12288 = 3 x 4096, Qwen3-8B); 8 is FLUX.3 klein // (20480 = 8 x 2560, Qwen3-VL-4B). // // A whitelist, not a cap, because the caller prefers the LOWEST factor and the // two disagree the moment a DiT concatenates more than a few layers. Under the // old "any k <= 4" rule FLUX.3's 20480 matched nothing (8 > 4); raising the cap // to 8 would have matched 4 x 5120 (a Qwen3-32B) and 5 x 4096 (any Qwen3-8B on // the box) ahead of the 8 x 2560 it was trained against, wiring the wrong // encoder with no warning. Only layouts that exist get to compete. var captionLayouts = []int64{1, 3, 8} // captionFactor reports how many encoder hidden states of the given width this // DiT's caption projection consumes, or 0 when that encoder cannot be the one. // // Nearly every DiT projects a SINGLE encoder hidden state, so txt_in is exactly // the encoder width and the answer is 1. The FLUX.2/3 line is the exception: it // conditions on several hidden LAYERS of its LLM concatenated. Matched on // equality that finds nothing, and the caller's pin fallback then substitutes // an encoder of the wrong width with no warning at all. // // Exactness is still preferred by the caller. func captionFactor(hidden, width int64) int64 { if hidden <= 0 || width <= 0 || hidden%width != 0 { return 0 } k := hidden / width for _, l := range captionLayouts { if k == l { return k } } return 0 } // knowsLlm reports whether the scan classified this exact path as a text-encoder // LLM, at ANY width. That is a different question from whether Llm would PICK it // for a given DiT: a pin the scan measured and rejected on width is a mismatch // worth reporting, while one the scan never saw (kept outside the models root, // say) is still the user's explicit choice and should be honoured. func (p *EncoderPool) knowsLlm(path string) bool { key := config.PathKey(strings.TrimSpace(path)) if p == nil || key == "" { return false } for _, f := range p.Files { if f.Role == RoleLlm && config.PathKey(f.Path) == key { return true } } return false } // llmCandidate reports whether a declared pin would actually be HONOURED by Llm // at this width, which is a different question from whether one was declared. // settings.encoders.qwenLlm is a single global field shared by every diffusion // model on the box, and Llm applies it only to candidates that clear the same // width and vision gates, so a pin aimed at an image model is silently dropped // for a video one. A family that carries its own path hint has to test for the // pin APPLYING, or a populated encoders block (every real install has one) // suppresses the hint without ever using the pin. // // The width test is captionFactor, not equality, for the same reason Llm's is: // a pin this mirror rejected on a width Llm would have accepted by concatenation // would fire the family's path hint over a pin that was about to be honoured. func (p *EncoderPool) llmCandidate(path string, hidden int64, wantVision bool) bool { key := config.PathKey(strings.TrimSpace(path)) if p == nil || key == "" || hidden <= 0 { return false } for _, f := range p.Files { if f.Role != RoleLlm || captionFactor(hidden, f.Width) == 0 || config.PathKey(f.Path) != key { continue } return !wantVision || f.Vision } return false } // Pool scans are cached per root set: a regen happens on every settings save and // on every models-watcher tick, and re-walking the tree (plus re-reading every // safetensors header) each time would dominate. The TTL keeps a freshly // downloaded encoder from needing a restart to be seen. var ( poolCacheMu sync.Mutex poolCache = map[string]*cachedPool{} ) const poolCacheTTL = 30 * time.Second type cachedPool struct { pool *EncoderPool at time.Time } func encoderPoolFor(roots []string) *EncoderPool { key := strings.Join(roots, "\x00") poolCacheMu.Lock() e, ok := poolCache[key] poolCacheMu.Unlock() if ok && time.Since(e.at) < poolCacheTTL { return e.pool } p := ScanEncoderPool(roots) poolCacheMu.Lock() poolCache[key] = &cachedPool{pool: p, at: time.Now()} poolCacheMu.Unlock() return p } // InvalidateEncoderPool drops every cached scan, for a caller that has just put // a component on disk and is about to regen. The TTL alone is not enough there: // the model browser queues a DiT and its companions together, a small VAE lands // seconds after the previous regen scanned, and a cached pool would wire the // model without it until the next unrelated regen. func InvalidateEncoderPool() { poolCacheMu.Lock() clear(poolCache) poolCacheMu.Unlock() } // fillEncoderSet returns declared with every blank field filled from what the // scan actually found on disk. Declared paths always win: an explicit setting is // a deliberate statement about a machine, and silently second-guessing it would // make a working config drift. // // The one exception is a declared file that no longer EXISTS. It is cleared // first, so the pool fills the slot or the role is reported missing. Kept, it // read as satisfied: the regen emitted a flag at a path sd-server then failed to // open, with no warning, and the model browser never offered the file again // because nothing was "missing". Moving one encoder out of the models folder // was enough to hit it. // // The hints exist because a family can hold more than one file (see Vae). func fillEncoderSet(declared EncoderSet, p *EncoderPool) EncoderSet { if p == nil { return declared } for _, dst := range []*string{ &declared.FluxVae, &declared.ClipL, &declared.ClipG, &declared.T5, &declared.SdxlVae, &declared.ZimageVae, &declared.QwenLlm, &declared.Flux2Vae, &declared.VideoVae, &declared.AudioVae, } { if declaredGone(*dst, p) { *dst = "" } } fill := func(dst *string, v string) { if strings.TrimSpace(*dst) == "" { *dst = v } } fill(&declared.FluxVae, p.Vae(VaeFamilyFlux, "flux", "/ae.")) fill(&declared.Flux2Vae, p.Vae(VaeFamilyFlux2)) fill(&declared.SdxlVae, p.Vae(VaeFamilySD, "sdxl")) // Z-Image ships "its own" ae.safetensors that is byte-for-byte the flux.1 // AE (verified: same size, same hash), so the flux pick is the correct // fallback and one copy on disk serves both. fill(&declared.ZimageVae, p.Vae(VaeFamilyFlux, "z-image", "zimage")) fill(&declared.ZimageVae, declared.FluxVae) // Video components. Both fields describe the MiniMax-H3 pair: the 3D // transformer VAE has no image-model consumer, so an unhinted pick within // the family is safe. LTX's two VAEs are deliberately NOT filled here - // there is one slot per role and the two families' files are not // interchangeable, so LTX resolves its own pair structurally in // videoComponents and leaves these pins meaning what they have always meant. fill(&declared.VideoVae, p.Vae(VaeFamilyVideo3D)) fill(&declared.AudioVae, p.AudioVae(VaeFamilyVideo3D)) fill(&declared.ClipL, p.Clip(768)) fill(&declared.ClipG, p.Clip(1280)) fill(&declared.T5, p.T5()) return declared } // declaredGone reports a declared component path that is provably absent: the // scan did not find it AND the filesystem says it does not exist. A file the pool // lists is present by construction, and any stat error other than not-exist // (permissions, a slow share) keeps the declaration, since dropping a working // path on a transient error would rewire a model behind the user's back. func declaredGone(path string, p *EncoderPool) bool { path = strings.TrimSpace(path) if path == "" { return false } key := config.PathKey(path) for _, f := range p.Files { if config.PathKey(f.Path) == key { return false } } _, err := os.Stat(path) return errors.Is(err, fs.ErrNotExist) } // projectorBeside returns the vision projector paired to a given text-encoder // gguf, i.e. the mmproj the pool found in that file's own directory. Empty for a // safetensors encoder (its vision tower, if any, is inside the same file) or // when the encoder ships without one. func projectorBeside(llmPath string, p *EncoderPool) string { if p == nil || strings.TrimSpace(llmPath) == "" { return "" } key := config.PathKey(llmPath) for _, f := range p.Files { if f.Role == RoleLlm && config.PathKey(f.Path) == key { return f.Mmproj } } return "" } // editModelRe marks a diffusion model whose pipeline conditions on a REFERENCE // IMAGE and therefore needs the text encoder's vision tower (--llm_vision). // // This one genuinely cannot be read off the weights. The obvious structural // tells do not hold: LongCat-Image-Edit and plain LongCat-Image have identical // img_in/txt_in shapes and differ by two unrelated norm tensors, because the // reference image enters as extra SEQUENCE tokens rather than extra input // channels. So the model name is the signal, with an explicit llmVision // override as the escape hatch when a publisher names something unhelpfully. var editModelRe = regexp.MustCompile(`(?i)(^|[-_. ])(edit|rapid|kontext|instruct[-_]?pix2pix|inpaint|redux)([-_. ]|$)`) // unifiedEditRe matches models where ONE checkpoint does both text-to-image and // reference editing, so the name carries no edit token to detect and the two // regexes above both miss. Qwen-Image 2.1 is the first of these: upstream // documents `-r ref.png` against the same weights used for plain generation, // and every 2.1 checkpoint is edit-capable, so this is a property of the model // version rather than of the individual file. // // Still matched by name, for the reason given above: the reference enters as // extra sequence tokens, so an edit-capable and a text-only checkpoint have the // same tensor shapes. The version number is the reliable part of the name here, // since both upstream releases (leejet's gguf, Comfy-Org's safetensors) spell // it in the filename, whereas neither says "edit" anywhere. // // Getting this wrong is a silent downgrade, not an error: the model would // declare `in: [text]`, the playground would fall through to img2img, and that // route scales the step count by the denoise strength and redraws the whole // frame instead of editing against a reference. var unifiedEditRe = regexp.MustCompile(`(?i)(^|[-_. ])qwen[-_. ]?image[-_. ]?2\.1([-_. ]|$)`) // fluxRefEditRe matches the FLUX.2 / FLUX.3 line (dev and klein), which is // unified the same way: BFL ships one checkpoint for text-to-image, a single // reference edit and multi-reference composition (FLUX.3 klein's card: "up to // 10 reference images ... through the same model"). Every converter tags these // general.architecture "flux", the same as flux.1, so the name is again the // only signal; it is the same set resolveComponents' klein arm keys on. // // Kept OUT of unifiedEditRe on purpose, because that one also drives // wantsVisionEncoder. FLUX takes its references through the VAE, as latent // sequence tokens, not through the text encoder's vision tower: klein 4B/9B // condition on text-only Qwen3, so there is no projector to pair and a // --llm_vision would be a wasted load at best. Only the capability changes. var fluxRefEditRe = regexp.MustCompile(`(?i)(^|[-_. ])(flux[-_. ]?[23]|klein)([-_. ]|$)`) // wantsVisionEncoder reports whether this model should get --llm_vision. Only // llm-conditioned families are candidates: flux.1 edit models condition through // T5, which has no vision tower at all. // refEditRe matches models that take their source image as a REFERENCE (extra // sequence tokens) rather than as an img2img denoise base. Deliberately // narrower than editModelRe: inpaint/redux models are edits too, but they want // the masked img2img route, and routing them through reference images would // redraw the whole frame instead of honouring the mask. var refEditRe = regexp.MustCompile(`(?i)(^|[-_. ])(edit|rapid|kontext)([-_. ]|$)`) // IsReferenceEditModel reports whether a model conditions on a reference image. // It is what the emitted `in: [text, image]` capability is keyed on, so the // client can route an edit to the reference path instead of guessing from the // model id. Like the projector pairing this is name detection, because the // distinction is not structural: the reference enters as extra sequence tokens, // not as extra input channels, so a base and an edit checkpoint have identical // tensor shapes. An explicit llmVision pin doubles as the escape hatch. func IsReferenceEditModel(name string, ov *Override) bool { if ov != nil { // refEdit is the field that means exactly this, so it wins outright. switch ov.RefEdit { case "on": return true case "off": return false } // An llmVision pin is the older, narrower spelling: a user who pinned a // vision projector on has said this model consumes a reference image. switch ov.LlmVision { case "on": return true case "off": return false } } return refEditRe.MatchString(name) || unifiedEditRe.MatchString(name) || fluxRefEditRe.MatchString(name) } func wantsVisionEncoder(arch, name string, ov *Override) bool { if ov != nil { switch ov.LlmVision { case "on": return true case "off": return false } // refEdit on is the same statement from the other side: the user has // said this model consumes a reference image, and for an llm-conditioned // family the reference is READ by the encoder's vision tower. Pinning // one without the other is the trap this closes: sd.cpp refuses the job // outright ("Qwen Image 2.1 editing requires Qwen3-VL vision weights; // provide --llm_vision or a combined encoder"), so a user who turns on // reference editing and nothing else gets a model that cannot generate. // // Only the "on" direction implies anything. refEdit off does NOT mean no // vision: an inpaint model is an edit that wants the masked img2img // route, and it still needs the projector. if ov.RefEdit == "on" { return true } } return editModelRe.MatchString(name) || unifiedEditRe.MatchString(name) }