// Package autogen discovers local GGUF models, reads their headers, computes a // llama-server load plan (-ngl / --n-cpu-moe / context) from a VRAM budget, and // emits a quartermaster config. It is a Go port of the domina-llm-eval PowerShell // planner (Read-GgufMetadata / Get-LlamaLoadPlan / Pick-LocalGguf) plus the // Generate-Config orchestration. Fork-specific; kept separable for upstreaming. package autogen import ( "bufio" "encoding/binary" "encoding/json" "fmt" "io" "math" "os" "regexp" "strconv" "strings" ) // GGUF metadata value type tags (spec v2/v3). const ( ggufU8 = iota ggufI8 ggufU16 ggufI16 ggufU32 ggufI32 ggufF32 ggufBool ggufString ggufArray ggufU64 ggufI64 ggufF64 ) // isIntType reports whether a value type tag is an integer scalar (the set the // PowerShell reader accepted for count/dim keys). func isIntType(t uint32) bool { switch t { case ggufU8, ggufI8, ggufU16, ggufI16, ggufU32, ggufI32, ggufU64, ggufI64: return true } return false } // Metadata is the parsed subset of a GGUF header needed for load planning. A // zero value for an optional numeric field means "absent in the header"; every // downstream consumer guards on > 0, matching the PowerShell $null checks. type Metadata struct { Path string FileSizeGB float64 Architecture string BlockCount int64 // QuantLabel is how the file is quantized according to its TENSORS - the // heaviest weight type, or " mix" when no type dominates. Empty when // the tensor section can't be judged. This is the only trustworthy source: // general.file_type is self-declared and routinely wrong, and a filename is // whoever-uploaded-it's opinion. See quantLabelFrom. QuantLabel string // ParamCount is the total element count across every tensor, whatever its // stored type. It prices a load that converts the weights on the way in // (audio.cpp's weight_type: f16 is ParamCount * 2 bytes), which the file // size alone cannot tell you for a quantized file. ParamCount int64 // BaseName / SizeLabel / FineTune / GeneralName are the identity KVs, raw // and unjudged (general.basename, .size_label, .finetune, .name). They are // what a model IS according to whoever converted it, which is the only // identity a renamed download still carries. Frequently absent (most // diffusion ggufs and hand-converted models write none) and occasionally // junk (a git SHA in .finetune, a hex blob in .name), so nothing consumes // them directly - identityFrom sanity-filters them first. BaseName string SizeLabel string FineTune string GeneralName string // AudioFamily is audio.cpp's own "audiocpp.model_spec.family" KV: the family // id (moss_tts_nano, qwen3_asr, ...) whose model_spec drives the load. Only // audio.cpp's converter writes it, and it is the ONE field its server config // cannot derive for itself, which is what makes it worth parsing here rather // than guessing from a filename. Blank on a third-party "-orig" conversion // that dropped the spec -- see audiocpp.go, which warns instead of guessing. AudioFamily string // GeneralType is the gguf "general.type" KV ("diffusion" for an image model, // absent/"model" for a normal LLM). Some diffusion GGUFs (e.g. HiDream-O1, // whose transformer is Qwen-based) report a non-image general.architecture // ("qwen") but mark themselves here — the authoritative diffusion signal when // the arch tag doesn't self-identify. See effectiveImageArch. GeneralType string // CondHidden is the hidden width of the text encoder a diffusion transformer // expects (see tensorScan.condHidden). 0 for anything that is not a DiT with // a recognised caption projection. CondHidden int64 // VideoKind is the video-diffusion family the tensor layout marks // (VideoFamilyMinimaxH3 / VideoFamilyWan), or "" for everything that is not // a video DiT. See tensorScan.videoKind: structural, because H3 has no // metadata at all and arch=wan means ERNIE-Image-Turbo, an image model. VideoKind string // HasAudioOut is true when a video DiT also denoises an audio latent, i.e. // it needs an --audio-vae and its output has a soundtrack. HasAudioOut bool // DiffusionKind is a tensor-name-sniffed diffusion arch ("sdxl"/"sd1"/"flux"/"qwen_image") for a // UNet gguf that carries no general.architecture — stable-diffusion.cpp's // `convert` strips the metadata KVs, so a converted SDXL UNet reports // Architecture="" but still has input_blocks/label_emb tensors. Empty when the // tensor section shows no UNet markers (i.e. a normal LLM/embedder). DiffusionKind string // HasBakedEncoders is true when a diffusion gguf carries its text encoder(s) // in-file (tensor names under conditioner.embedders/cond_stage_model) — i.e. // it's a full checkpoint, not a bare UNet. SD1/SD2/SDXL can only be // version-detected (and served) as full checkpoints via -m; a UNet-only // export of those has no working sd-server load path. HasBakedEncoders bool ExpertCount int64 ExpertUsed int64 ContextLength int64 EmbeddingLength int64 HeadCount int64 HeadCountKv int64 // representative per-attn-layer KV heads HeadCountKvSum int64 // KV heads summed over all layers (hybrid-aware) HeadCountKvArr []int64 // per-layer KV heads (nil = uniform); 0 = no-KV layer AttnLayerCount int64 // layers with KV (0 = uniform/all layers) KeyLength int64 ValueLength int64 RopeScalingType string // "none"|"linear"|"yarn"|"" (absent) RopeScalingFactor float64 RopeOrigCtxLen int64 RopeFreqBase float64 SlidingWindow int64 SlidingWinPattern int64 KeyLengthSwa int64 ValueLengthSwa int64 FullAttnInterval int64 // hybrid SSM: full-attn layer every Nth; 0 = not hybrid SsmInnerSize int64 SsmConvKernel int64 SsmStateSize int64 // VocabSize is the token vocabulary size, derived from the token_embd.weight // (or output.weight) tensor shape / embedding_length. 0 when unsizable. Used // to size the logits/output compute buffer in the VRAM estimate. VocabSize int64 // PoolingType is the gguf ".pooling_type" (0/absent=none, 1=mean, // 2=cls, 3=last). A value > 0 marks an embedding model (it pools token // states into one sentence vector); generative models leave it unset. PoolingType int64 // DecisionType is the gguf ".decision.type" (e.g. "kev", "laya", // "lfm2-d1-omni"): set only on llama.cpp decision models, which llama-server // serves on /v1/systemone. They carry an ordinary LLM or encoder arch and may // also bake a pooling_type, so this key is the only reliable signal. DecisionType string // NonCausal is ".attention.causal" = false: every token attends to the // whole sequence (a BERT-style encoder), so the model cannot generate text. // ClassifierHead is set when ".classifier.output_labels" exists: the // file ends in a label head, not an LM head. Either one without a // DecisionType or pooling_type is a classifier llama-server cannot serve. NonCausal bool ClassifierHead bool IsMoE bool // IsMTP is true when the model carries multi-token-prediction / nextn // layers (gguf key ".num_nextn_predict_layers" or ".nextn_predict_layers" > 0). // Converters disagree on the spelling; accept both. Only such models // can use --spec-type draft-mtp. IsMTP bool // NextnLayers is how many of those layers there are. It is not a detail: // llama-server builds the baked-MTP drafter as a SECOND context over the same // model, whose KV cache covers exactly these layers at the target's full // n_ctx, so the draft's VRAM scales with the context window. See // mtpDraftSlopeGB. NextnLayers int64 // ExpertWeightShare is the fraction of on-disk weight bytes in expert // tensors (*_exps.weight), derived from the tensor section. 0 when not MoE // or the tensor section could not be sized. Replaces the per-arch heuristic. ExpertWeightShare float64 // Vision (CLIP mmproj) hparams — nonzero only in an mmproj projector gguf // (general.architecture = "clip"); all 0 in a normal LLM gguf. Drive // clipComputeBufferGB so a "-vision" twin's compute-buffer reserve scales // per projector instead of a flat pad. VisionImageSize int64 VisionPatchSize int64 VisionEmbd int64 VisionFFN int64 VisionBlocks int64 VisionHeads int64 VisionMerge int64 // ChatTemplatePreservesThinking is true when the baked-in // "tokenizer.chat_template" keeps prior-turn blocks in rendered // history *by default* (Qwen 3.8+ wording: "preserve_thinking is undefined // or preserve_thinking is true"). False for the 3.5/3.6 wording // ("preserve_thinking is defined and ..."), which strips them and so // re-renders history differently every turn, and false for any template // with no preserve_thinking logic at all. ChatTemplatePreservesThinking bool // ChatTemplateEffortLevels are the reasoning-effort values the baked-in chat // template accepts, read out of its own validation guard (Qwen 3.8: // "xhigh", "medium", "low", injected as a system-prompt instruction). Nil // when the template has no reasoning_effort support, or when it has some but // declares no value set we can read — in both cases nothing may be // normalized against it, because the template raises on an unexpected value // and that surfaces as a 500. Only meaningful when the model actually runs // its own template rather than a --chat-template-file override. ChatTemplateEffortLevels []string } // effortLevelsRe pulls the value tuple out of a template's own guard, e.g. // // {%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %} // {{- raise_exception('Unexpected reasoning effort ...') }} // // Matched against whitespace-collapsed source, so the guard may be wrapped. var effortLevelsRe = regexp.MustCompile(`reasoning_effort\s+not\s+in\s*\(([^)]*)\)`) // effortValueRe extracts one quoted value from that tuple. var effortValueRe = regexp.MustCompile(`['"]([A-Za-z0-9_-]+)['"]`) // effortAssignRe is the fallback for TOLERANT templates — ones that read // reasoning_effort but never validate it, so they declare no value tuple to // read. They instead normalize the request onto their own canonical rungs: // // {%- elif _effort_raw == 'high' or _effort_raw == 'xhigh' %} // {%- set _initial_effort = 'xhigh' %} // // so the ladder is the set of literals ASSIGNED to an effort-named variable, // not the set compared against it (which is padded with OpenAI-ladder aliases // the template folds away). Only consulted when the guard regex finds nothing // and the template validates nothing — with no raise in play a level we get // wrong degrades to the template's own default instead of a 500. var effortAssignRe = regexp.MustCompile(`set\s+[A-Za-z0-9_]*[Ee]ffort[A-Za-z0-9_]*\s*=\s*['"]([A-Za-z0-9_-]+)['"]`) // effortRaiseRe asks the narrow question "does this template raise ABOUT // effort", not "does it raise at all": every one of these templates raises on // malformed content ("Unexpected item type in content"), so a blanket // raise_exception check would call every real template strict and suppress the // fallback. A raise that belongs to an effort guard is the body of the test // that names effort: // // {%- if resolved_reasoning_effort not in (...) %} {{- raise_exception(...) }} // // so it is reachable from the word "effort" without crossing a `{%` — the // start of the next statement tag, which is where an unrelated raise lives. var effortRaiseRe = regexp.MustCompile(`(?is)effort(?:[^{]|\{[^%])*raise_exception`) // scanChatTemplate derives the chat-template feature flags stored on Metadata // from the raw jinja source. Matching is done on whitespace-collapsed text so // re-indentation by a converter doesn't defeat it; both markers are literal // jinja conditions copied from the upstream Qwen templates, which quant // repackagers reproduce verbatim. func scanChatTemplate(tmpl string) (preservesThinking bool, effortLevels []string) { if tmpl == "" { return false, nil } flat := strings.Join(strings.Fields(tmpl), " ") preservesThinking = strings.Contains(flat, "preserve_thinking is undefined or preserve_thinking is true") if m := effortLevelsRe.FindStringSubmatch(flat); m != nil { for _, v := range effortValueRe.FindAllStringSubmatch(m[1], -1) { effortLevels = append(effortLevels, strings.ToLower(v[1])) } return preservesThinking, effortLevels } if strings.Contains(flat, "reasoning_effort") && !effortRaiseRe.MatchString(flat) { seen := map[string]bool{} for _, v := range effortAssignRe.FindAllStringSubmatch(flat, -1) { lvl := strings.ToLower(v[1]) // "none"/"off" is the enable_thinking switch, not a rung; the UI // and the proxy both carry it separately. if lvl == "none" || lvl == "off" || seen[lvl] { continue } seen[lvl] = true effortLevels = append(effortLevels, lvl) } } return preservesThinking, effortLevels } // ggmlTypeSize maps a ggml tensor type to (block size in elements, bytes per // block). bytes(tensor) = n_elements / blockElems * blockBytes. // // The numbers are ggml's own: the enum in ggml/include/ggml.h fixes the type // ids, and each block struct in ggml/src/ggml-common.h fixes its pair (QK_* is // the block size, sizeof(block_*) the byte count). An id we don't list only // costs the BYTE accounting - the tensor walk keeps going and returns // unknownType, which suppresses the MoE expert share rather than failing the // file (see readTensorScan). Removed types (4/5, 31-33, 36-38) are absent on // purpose: no gguf still carries them. var ggmlTypeSize = map[uint32][2]int64{ 0: {1, 4}, // F32 1: {1, 2}, // F16 2: {32, 18}, // Q4_0 3: {32, 20}, // Q4_1 6: {32, 22}, // Q5_0 7: {32, 24}, // Q5_1 8: {32, 34}, // Q8_0 9: {32, 36}, // Q8_1 10: {256, 84}, // Q2_K 11: {256, 110}, // Q3_K 12: {256, 144}, // Q4_K 13: {256, 176}, // Q5_K 14: {256, 210}, // Q6_K 15: {256, 292}, // Q8_K 16: {256, 66}, // IQ2_XXS 17: {256, 74}, // IQ2_XS 18: {256, 98}, // IQ3_XXS 19: {256, 50}, // IQ1_S 20: {32, 18}, // IQ4_NL 21: {256, 110}, // IQ3_S 22: {256, 82}, // IQ2_S 23: {256, 136}, // IQ4_XS 24: {1, 1}, // I8 25: {1, 2}, // I16 26: {1, 4}, // I32 27: {1, 8}, // I64 28: {1, 8}, // F64 29: {256, 56}, // IQ1_M 30: {1, 2}, // BF16 34: {256, 54}, // TQ1_0 35: {256, 66}, // TQ2_0 39: {32, 17}, // MXFP4 (1-byte E8M0 scale + 32 4-bit elements) 40: {64, 36}, // NVFP4 (4x 1-byte E4M3 sub-scales + 64 4-bit elements) 41: {128, 18}, // Q1_0 (1.125 bpw: fp16 delta + 128 1-bit elements) } // ggufReader reads little-endian GGUF primitives from a seekable source. It is // an io.ReadSeeker rather than an *os.File so the same parser can run over a // header pulled from a hub with a Range request (see ReadGgufMetadataFrom) — // the metadata + tensor-info sections are the first few MB of the file, which // is the whole point of being able to size a model before downloading it. type ggufReader struct { f io.ReadSeeker br *bufio.Reader pos int64 } func (r *ggufReader) read(n int64) ([]byte, error) { buf := make([]byte, n) if _, err := io.ReadFull(r.br, buf); err != nil { return nil, err } r.pos += n return buf, nil } // discard advances past n bytes without allocating, keeping the read buffer warm. // Used to skip string/array payloads (e.g. the tokenizer vocab) we don't decode. func (r *ggufReader) discard(n int64) error { d, err := io.CopyN(io.Discard, r.br, n) r.pos += d return err } func (r *ggufReader) u32() (uint32, error) { b, err := r.read(4) if err != nil { return 0, err } return binary.LittleEndian.Uint32(b), nil } func (r *ggufReader) u64() (uint64, error) { b, err := r.read(8) if err != nil { return 0, err } return binary.LittleEndian.Uint64(b), nil } func (r *ggufReader) seek(delta int64) error { // br has buffered ahead, so the file offset is past r.pos. Seek to the absolute // logical target and reset the buffer rather than seeking the file relatively. np, err := r.f.Seek(r.pos+delta, io.SeekStart) if err != nil { return err } r.pos = np r.br.Reset(r.f) return nil } // str reads a GGUF string (uint64 length + UTF-8 bytes). func (r *ggufReader) str() (string, error) { n, err := r.u64() if err != nil { return "", err } if n > 1<<20 { return "", fmt.Errorf("gguf string length absurd (%d) at offset %d", n, r.pos-8) } b, err := r.read(int64(n)) if err != nil { return "", err } return string(b), nil } // skipStr advances past a GGUF string (uint64 length + bytes) without decoding it. func (r *ggufReader) skipStr() error { n, err := r.u64() if err != nil { return err } return r.discard(int64(n)) } // scalarWidth returns the byte width of a fixed-width scalar type, or 0 for // variable-width (string) / unknown types. func scalarWidth(t uint32) int64 { switch t { case ggufU8, ggufI8, ggufBool: return 1 case ggufU16, ggufI16: return 2 case ggufU32, ggufI32, ggufF32: return 4 case ggufU64, ggufI64, ggufF64: return 8 } return 0 } // readScalar reads a value of type t as int64 (integers/bool) or float64 // (f32/f64) or string. Returns an error for array/unknown types. func (r *ggufReader) readScalar(t uint32) (i int64, f float64, s string, err error) { switch t { case ggufU8: b, e := r.read(1) return int64(b[0]), 0, "", e case ggufI8: b, e := r.read(1) return int64(int8(b[0])), 0, "", e case ggufU16: b, e := r.read(2) if e != nil { return 0, 0, "", e } return int64(binary.LittleEndian.Uint16(b)), 0, "", nil case ggufI16: b, e := r.read(2) if e != nil { return 0, 0, "", e } return int64(int16(binary.LittleEndian.Uint16(b))), 0, "", nil case ggufU32: v, e := r.u32() return int64(v), 0, "", e case ggufI32: v, e := r.u32() return int64(int32(v)), 0, "", e case ggufF32: b, e := r.read(4) if e != nil { return 0, 0, "", e } return 0, float64(math.Float32frombits(binary.LittleEndian.Uint32(b))), "", nil case ggufBool: b, e := r.read(1) if e != nil { return 0, 0, "", e } if b[0] != 0 { return 1, 0, "", nil } return 0, 0, "", nil case ggufString: s, e := r.str() return 0, 0, s, e case ggufU64: v, e := r.u64() return int64(v), 0, "", e case ggufI64: v, e := r.u64() return int64(v), 0, "", e case ggufF64: b, e := r.read(8) if e != nil { return 0, 0, "", e } return 0, math.Float64frombits(binary.LittleEndian.Uint64(b)), "", nil } return 0, 0, "", fmt.Errorf("scalar requested but type=%d (array or unknown)", t) } // skipValue advances past a value of type t without decoding it. Arrays of // fixed-width elements seek past the whole block in one move. func (r *ggufReader) skipValue(t uint32) error { if w := scalarWidth(t); w > 0 { return r.discard(w) } switch t { case ggufString: return r.skipStr() case ggufArray: elemType, err := r.u32() if err != nil { return err } count, err := r.u64() if err != nil { return err } if w := scalarWidth(elemType); w > 0 { return r.seek(int64(count) * w) } if elemType == ggufString { // Skip each string's bytes via the buffer (no alloc, no per-string seek); // large vocab arrays would otherwise cost ~2 syscalls per entry. for i := uint64(0); i < count; i++ { if err := r.skipStr(); err != nil { return err } } return nil } for i := uint64(0); i < count; i++ { if err := r.skipValue(elemType); err != nil { return err } } return nil } return fmt.Errorf("unknown gguf value type=%d at offset %d", t, r.pos-4) } // readIntArray reads a type-9 array of numeric elements into an int64 slice. func (r *ggufReader) readIntArray() ([]int64, error) { elemType, err := r.u32() if err != nil { return nil, err } count, err := r.u64() if err != nil { return nil, err } out := make([]int64, 0, count) for i := uint64(0); i < count; i++ { v, _, _, err := r.readScalar(elemType) if err != nil { return nil, err } out = append(out, v) } return out, nil } // ggufSetSizeBytes reports the bytes a gguf really charges: its own size, or the // summed size of every shard when path is one file of a split set // ("-00001-of-00004.gguf"). llama-server is handed shard 1 and opens the // siblings itself, so discovery represents the whole set by that one file - but // the weights figure the VRAM sizer charges is the whole set, not the shard. // Stat'ing shard 1 alone reported an 80B MoE as a quarter of its real size, // which offloaded it whole (-ngl 99, no --n-cpu-moe) and spent the phantom slack // on context; on Windows the driver spills the overflow into system RAM rather // than failing, so the only symptom was a very slow run. // // A sibling that cannot be stat'd is skipped rather than fatal: a short total // still beats a quarter one, and a truncated set is llama.cpp's error to report. func ggufSetSizeBytes(path string, own int64) int64 { loc := shardRe.FindStringSubmatchIndex(path) if loc == nil { return own } idx, errIdx := strconv.Atoi(path[loc[2]:loc[3]]) count, errCount := strconv.Atoi(path[loc[4]:loc[5]]) if errIdx != nil || errCount != nil || count <= 1 || idx < 1 || idx > count { return own } prefix := path[:loc[0]] total := own for i := 1; i <= count; i++ { if i == idx { continue } if fi, err := os.Stat(fmt.Sprintf("%s-%05d-of-%05d.gguf", prefix, i, count)); err == nil { total += fi.Size() } } return total } // ReadGgufMetadata parses the metadata KV section of a GGUF file. Tensor // descriptors are not read, so the work is bounded to the header. func ReadGgufMetadata(path string) (Metadata, error) { fi, err := os.Stat(path) if err != nil { return Metadata{}, fmt.Errorf("gguf not found: %s", path) } f, err := os.Open(path) if err != nil { return Metadata{}, err } defer f.Close() return ReadGgufMetadataFrom(f, path, ggufSetSizeBytes(path, fi.Size())) } // ReadGgufMetadataFrom is ReadGgufMetadata over an already-open source. Callers // that do not have the file on disk (the model browser, which Range-fetches the // first few MB from the hub) pass a bytes.Reader over the header and the file's // FULL size — sizeBytes is the weights figure every sizing path charges, so // handing it the length of the prefix that was fetched would report a model // that fits in any budget. A truncated source surfaces as io.ErrUnexpectedEOF, // which is the caller's signal to re-fetch a longer prefix. func ReadGgufMetadataFrom(rs io.ReadSeeker, path string, sizeBytes int64) (Metadata, error) { r := &ggufReader{f: rs, br: bufio.NewReaderSize(rs, 1<<20)} magic, err := r.read(4) if err != nil { return Metadata{}, err } if string(magic) != "GGUF" { return Metadata{}, fmt.Errorf("not a gguf file (magic=%q): %s", string(magic), path) } // version (v2/v3 share this layout); newer minor revisions stay compatible. if _, err := r.u32(); err != nil { return Metadata{}, err } tensorCount, err := r.u64() if err != nil { return Metadata{}, err } kvCount, err := r.u64() if err != nil { return Metadata{}, err } // Optional fields tracked via pointers so absence is distinguishable from a // real zero, exactly like the PowerShell $null checks. var arch string var genType string var chatTmpl string var diffConfig string var blockCount, expertCount, expertUsed *int64 var contextLength, embeddingLength, headCount, headCountKv *int64 var headCountKvArr []int64 var keyLength, valueLength *int64 var ropeScalingType *string var ropeScalingFactor *float64 var ropeOrigCtxLen *int64 var ropeFreqBase *float64 var slidingWindow, slidingWinPattern, keyLengthSwa, valueLengthSwa *int64 var fullAttnInterval, ssmInnerSize, ssmConvKernel, ssmStateSize *int64 var nextnLayers, poolingType *int64 var decisionType string var nonCausal, classifierHead bool var visImageSize, visPatchSize, visEmbd, visFFN, visBlocks, visHeads, visMerge *int64 // The publisher's own words for what this file IS. Written by convert_hf_ // to_gguf from the source repo's name, so they are the only identity in the // file that does not depend on whoever renamed the download. Any of them can // be absent, blank or junk - see identityFrom, which is what judges them. var baseName, sizeLabel, fineTune, generalName string var audioFamily string idKVs := map[string]*string{ "general.basename": &baseName, "general.size_label": &sizeLabel, "general.finetune": &fineTune, "general.name": &generalName, } pi := func(v int64) *int64 { return &v } pf := func(v float64) *float64 { return &v } ps := func(v string) *string { return &v } for i := uint64(0); i < kvCount; i++ { key, err := r.str() if err != nil { return Metadata{}, err } t, err := r.u32() if err != nil { return Metadata{}, err } matched := false if key == "general.architecture" && t == ggufString { _, _, s, err := r.readScalar(t) if err != nil { return Metadata{}, err } arch = s matched = true } else if key == "audiocpp.model_spec.family" && t == ggufString { // Sits beside audiocpp.model_spec.json, the whole spec inlined. The // json is deliberately skipped: the server reads it out of the same // file for itself, and it is the larger of the two by orders of // magnitude. _, _, s, err := r.readScalar(t) if err != nil { return Metadata{}, err } audioFamily = s matched = true } else if key == "general.type" && t == ggufString { _, _, s, err := r.readScalar(t) if err != nil { return Metadata{}, err } genType = s matched = true } else if key == "tokenizer.chat_template" && t == ggufString { // Decoded (not skipped) so the baked template's own feature flags can // gate the arch-derived --chat-template-file override. Templates run // ~10-100 KB — well under ggufReader.str's 1 MB sanity cap — and only // the derived booleans are kept, not the source. _, _, s, err := r.readScalar(t) if err != nil { return Metadata{}, err } chatTmpl = s matched = true } else if key == "config" && t == ggufString { // LTX ships its whole diffusers config as ONE json blob instead of // typed KVs, and that blob is the only place the file states the // text-encoder width it was trained against (caption_channels 3840 // = the projected Gemma-4-12B). The tensor table cannot answer it // the way every other DiT's can: LTX's caption projection lives in // the ENCODER gguf ("with-proj"), not in the DiT, so there is no // txt_in/cap_embedder here for condHiddenFrom to measure. _, _, str, err := r.readScalar(t) if err != nil { return Metadata{}, err } diffConfig = str matched = true } else if dst, ok := idKVs[key]; ok && t == ggufString { _, _, s, err := r.readScalar(t) if err != nil { return Metadata{}, err } *dst = s matched = true } else if arch != "" { pfx := arch + "." readInt := func() (int64, error) { v, _, _, err := r.readScalar(t); return v, err } readFloat := func() (float64, error) { _, v, _, err := r.readScalar(t); return v, err } readStr := func() (string, error) { _, _, v, err := r.readScalar(t); return v, err } switch { case key == pfx+"block_count" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } blockCount = pi(v) matched = true case key == pfx+"expert_count" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } expertCount = pi(v) matched = true case key == pfx+"expert_used_count" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } expertUsed = pi(v) matched = true case key == pfx+"context_length" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } contextLength = pi(v) matched = true case key == pfx+"embedding_length" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } embeddingLength = pi(v) matched = true case key == pfx+"attention.head_count" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } headCount = pi(v) matched = true case key == pfx+"attention.head_count_kv" && t == ggufArray: arr, err := r.readIntArray() if err != nil { return Metadata{}, err } headCountKvArr = arr matched = true case key == pfx+"attention.head_count_kv" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } headCountKv = pi(v) matched = true case key == pfx+"attention.key_length" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } keyLength = pi(v) matched = true case key == pfx+"attention.value_length" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } valueLength = pi(v) matched = true case key == pfx+"rope.scaling.type" && t == ggufString: v, err := readStr() if err != nil { return Metadata{}, err } ropeScalingType = ps(v) matched = true case key == pfx+"rope.scaling.factor" && (t == ggufF32 || t == ggufF64): v, err := readFloat() if err != nil { return Metadata{}, err } ropeScalingFactor = pf(v) matched = true case key == pfx+"rope.scaling.original_context_length" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } ropeOrigCtxLen = pi(v) matched = true case key == pfx+"rope.freq_base" && (t == ggufF32 || t == ggufF64): v, err := readFloat() if err != nil { return Metadata{}, err } ropeFreqBase = pf(v) matched = true case key == pfx+"attention.sliding_window" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } slidingWindow = pi(v) matched = true case key == pfx+"attention.sliding_window_pattern" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } slidingWinPattern = pi(v) matched = true case key == pfx+"attention.key_length_swa" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } keyLengthSwa = pi(v) matched = true case key == pfx+"attention.value_length_swa" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } valueLengthSwa = pi(v) matched = true case key == pfx+"full_attention_interval" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } fullAttnInterval = pi(v) matched = true case key == pfx+"ssm.inner_size" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } ssmInnerSize = pi(v) matched = true case key == pfx+"ssm.conv_kernel" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } ssmConvKernel = pi(v) matched = true case key == pfx+"ssm.state_size" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } ssmStateSize = pi(v) matched = true case (key == pfx+"num_nextn_predict_layers" || key == pfx+"nextn_predict_layers") && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } nextnLayers = pi(v) matched = true case key == pfx+"pooling_type" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } poolingType = pi(v) matched = true case key == pfx+"decision.type" && t == ggufString: v, err := readStr() if err != nil { return Metadata{}, err } decisionType = v matched = true case key == pfx+"attention.causal" && t == ggufBool: v, err := readInt() if err != nil { return Metadata{}, err } nonCausal = v == 0 matched = true case key == pfx+"classifier.output_labels": // Presence is the signal; the labels themselves are skipped below. classifierHead = true // Vision (CLIP mmproj) hparams — present only when arch == "clip" // (the projector gguf). Drive the per-projector CLIP compute-buffer // model in clipComputeBufferGB. case key == pfx+"vision.image_size" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } visImageSize = pi(v) matched = true case key == pfx+"vision.patch_size" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } visPatchSize = pi(v) matched = true case key == pfx+"vision.embedding_length" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } visEmbd = pi(v) matched = true case key == pfx+"vision.feed_forward_length" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } visFFN = pi(v) matched = true case key == pfx+"vision.block_count" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } visBlocks = pi(v) matched = true case key == pfx+"vision.attention.head_count" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } visHeads = pi(v) matched = true case key == pfx+"vision.spatial_merge_size" && isIntType(t): v, err := readInt() if err != nil { return Metadata{}, err } visMerge = pi(v) matched = true } } if !matched { if err := r.skipValue(t); err != nil { return Metadata{}, err } } } // Tensor section: sum on-disk weight bytes of expert tensors vs all tensors // to derive the expert-weight share exactly (replaces the per-arch heuristic). // The reader is now positioned at the first tensor info. Failures leave the // share at 0 so the caller falls back to the arch table. scan, _ := readTensorScan(r, tensorCount) expertShare, vocabElems, diffKind, bakedEnc := scan.expertShare, scan.vocabElems, scan.diffKind, scan.bakedEnc quantLabel := quantLabelFrom(scan.typeBytes) var vocab int64 if vocabElems > 0 && embeddingLength != nil && *embeddingLength > 0 { vocab = vocabElems / *embeddingLength } // Derive per-head dim when key/value_length absent: head_dim = // embedding_length / head_count. K and V share it unless stated. if (keyLength == nil || valueLength == nil) && embeddingLength != nil && headCount != nil && *headCount > 0 { derived := int64(math.Floor(float64(*embeddingLength) / float64(*headCount))) if keyLength == nil { keyLength = pi(derived) } if valueLength == nil { valueLength = pi(derived) } } // Hybrid conv+attn archs give head_count_kv as a per-layer array: conv // layers report 0. Sum across layers = total KV heads; non-zero = attn layers. var kvHeadSum *int64 var attnLayers *int64 if len(headCountKvArr) > 0 { var sum int64 var nz int64 for _, h := range headCountKvArr { sum += h if h > 0 { nz++ } } kvHeadSum = pi(sum) attnLayers = pi(nz) if headCountKv == nil { for _, h := range headCountKvArr { if h > 0 { headCountKv = pi(h) break } } } } // GQA: KV heads default to attention heads when head_count_kv is absent (MHA). if headCountKv == nil { headCountKv = headCount } // Uniform model: layer-summed KV heads = blocks * per-layer KV heads. if kvHeadSum == nil && blockCount != nil && headCountKv != nil { kvHeadSum = pi(*blockCount * *headCountKv) } deref := func(p *int64) int64 { if p == nil { return 0 } return *p } derefF := func(p *float64) float64 { if p == nil { return 0 } return *p } derefS := func(p *string) string { if p == nil { return "" } return *p } // The caption projection is the primary signal (it is IN the weights); the // config blob is the fallback for a family that keeps that projection in its // encoder file. Never the other way round: a tensor shape is what the model // will actually run with. condHidden := scan.condHidden if condHidden == 0 { condHidden = captionChannelsFrom(diffConfig) } // A gguf converted outside llama.cpp can carry a full tensor table and NOT a // single hyperparameter KV: LTX-2.5's Gemma-4 text encoder is exactly that // (arch "gemma4", 686 tensors, no gemma4.embedding_length). Width 0 reads as // "not a text encoder" in the encoder pool, so fall back to the width the // token-embedding tensor states. KVs win whenever they exist. embWidth := deref(embeddingLength) if embWidth == 0 { embWidth = scan.embedWidth } m := Metadata{ Path: path, FileSizeGB: round(float64(sizeBytes)/gib, 3), Architecture: arch, GeneralType: genType, BlockCount: deref(blockCount), ExpertCount: deref(expertCount), ExpertUsed: deref(expertUsed), ContextLength: deref(contextLength), EmbeddingLength: embWidth, HeadCount: deref(headCount), HeadCountKv: deref(headCountKv), HeadCountKvSum: deref(kvHeadSum), HeadCountKvArr: headCountKvArr, AttnLayerCount: deref(attnLayers), KeyLength: deref(keyLength), ValueLength: deref(valueLength), RopeScalingType: derefS(ropeScalingType), RopeScalingFactor: derefF(ropeScalingFactor), RopeOrigCtxLen: deref(ropeOrigCtxLen), RopeFreqBase: derefF(ropeFreqBase), SlidingWindow: deref(slidingWindow), SlidingWinPattern: deref(slidingWinPattern), KeyLengthSwa: deref(keyLengthSwa), ValueLengthSwa: deref(valueLengthSwa), FullAttnInterval: deref(fullAttnInterval), SsmInnerSize: deref(ssmInnerSize), SsmConvKernel: deref(ssmConvKernel), SsmStateSize: deref(ssmStateSize), VocabSize: vocab, QuantLabel: quantLabel, ParamCount: scan.paramCount, BaseName: baseName, SizeLabel: sizeLabel, FineTune: fineTune, GeneralName: generalName, AudioFamily: audioFamily, DiffusionKind: diffKind, CondHidden: condHidden, VideoKind: scan.videoKind, HasAudioOut: scan.hasAudioOut, HasBakedEncoders: bakedEnc, PoolingType: deref(poolingType), DecisionType: decisionType, NonCausal: nonCausal, ClassifierHead: classifierHead, IsMoE: expertCount != nil && *expertCount > 0, IsMTP: nextnLayers != nil && *nextnLayers > 0, NextnLayers: deref(nextnLayers), ExpertWeightShare: expertShare, VisionImageSize: deref(visImageSize), VisionPatchSize: deref(visPatchSize), VisionEmbd: deref(visEmbd), VisionFFN: deref(visFFN), VisionBlocks: deref(visBlocks), VisionHeads: deref(visHeads), VisionMerge: deref(visMerge), } m.ChatTemplatePreservesThinking, m.ChatTemplateEffortLevels = scanChatTemplate(chatTmpl) return m, nil } // readTensorScan parses the GGUF tensor info section (the reader must be // positioned at the first tensor info) and returns the fraction of on-disk // weight bytes held by expert tensors (name contains "_exps"), plus the element // count of the token_embd.weight (or output.weight) tensor (= n_embd*n_vocab, // for vocab sizing). Share is 0 when no expert tensors are found or any tensor // uses an unknown ggml type; vocabElems is 0 when neither tensor is present. // diffKind is "sdxl"/"sd1" when the tensor names mark a diffusion UNet, // "flux" when they mark a BFL-format MMDiT (double_blocks), or "qwen_image" // for the Qwen-Image MMDiT layout, for a // metadata-stripped converted model; else "". bakedEnc is true when the file // also carries its text encoder(s) (conditioner.embedders/cond_stage_model), // i.e. it's a full checkpoint rather than a bare UNet. func readTensorScan(r *ggufReader, tensorCount uint64) (scan tensorScan, err error) { var share float64 var vocabElems int64 var diffKind string var bakedEnc bool var expertBytes, totalBytes int64 var videoKind string var sawExpert, sawInputBlocks, sawLabelEmb, sawDoubleBlocks, unknownType bool var sawImgIn, sawTxtIn, sawTransformerBlocks bool var sawVideoPatch, sawAudioPatch, sawTemporalPatch, sawLtxPatch bool var embedWidth int64 condDims := map[string]int64{} typeBytes := map[uint32]int64{} var paramCount int64 out := func() tensorScan { return tensorScan{expertShare: share, vocabElems: vocabElems, diffKind: diffKind, bakedEnc: bakedEnc, condHidden: condHiddenFrom(condDims), videoKind: videoKind, hasAudioOut: sawAudioPatch, embedWidth: embedWidth, typeBytes: typeBytes, paramCount: paramCount} } for i := uint64(0); i < tensorCount; i++ { name, err := r.str() if err != nil { return out(), err } nDims, err := r.u32() if err != nil { return out(), err } elems := int64(1) var firstDim int64 for d := uint32(0); d < nDims; d++ { dim, err := r.u64() if err != nil { return out(), err } if d == 0 { firstDim = int64(dim) } elems *= int64(dim) } paramCount += elems typ, err := r.u32() if err != nil { return out(), err } if _, err := r.u64(); err != nil { // offset (unused) return out(), err } // An unknown ggml type only costs us the BYTE accounting - the walk // itself is fixed-width, and name/elems are already in hand. Keep going // rather than failing the file: bailing here used to zero VocabSize for // every gguf in a quant we had not tabulated yet (MXFP4), which silently // refused it a family sidecar and mis-sized its logits buffer. ts, ok := ggmlTypeSize[typ] if ok { bytes := elems / ts[0] * ts[1] totalBytes += bytes typeBytes[typ] += bytes if strings.Contains(name, "_exps") { expertBytes += bytes } } else { unknownType = true typeBytes[typ] += 0 // presence is what QuantLabel needs; bytes are unknowable } if strings.Contains(name, "_exps") { sawExpert = true } // token_embd.weight is the canonical vocab×embd tensor; output.weight is // the tied/untied fallback (some models omit token_embd in the count). if name == "token_embd.weight" || (vocabElems == 0 && name == "output.weight") { vocabElems = elems } // ne[0] of the token-embedding tensor IS the hidden width. Read it for // both namings: llama.cpp's own converter writes token_embd.weight, while // a ComfyUI-layout conversion keeps HF's model.embed_tokens.weight. This // is a FALLBACK only (see Metadata below) - when the KVs are present they // remain authoritative. if name == "token_embd.weight" || name == "model.embed_tokens.weight" { embedWidth = firstDim } // Every diffusion marker below is matched on the BARE name. ComfyUI-layout // conversions (unsloth's and AtomicChat's Qwen-Image 2.1 quants, the two // most downloaded) keep the checkpoint's model.diffusion_model. prefix, // and an exact-name match against the prefixed form misses the caption // projection: condHidden reads 0 and the model is wired to the wrong text // encoder and the 16-channel VAE. sd.cpp strips the prefix itself. name = strings.TrimPrefix(name, "model.diffusion_model.") // SD/SDXL UNet markers, for a converted diffusion gguf that lost its // general.architecture. input_blocks = a UNet; label_emb (the size/crop // conditioning) is SDXL-only, absent in SD1. if strings.Contains(name, "input_blocks.") { sawInputBlocks = true } if strings.Contains(name, "label_emb.") { sawLabelEmb = true } // BFL-format MMDiT marker (flux.1, chroma, LongCat-Image). Some // publishers convert with the KVs stripped, so this is the only signal // that the file is a diffusion transformer at all. It says "flux tensor // layout", not "is flux.1": resolveComponents still name-detects the // families that share the layout but not the encoders. if strings.Contains(name, "double_blocks.") { sawDoubleBlocks = true } // Qwen-Image MMDiT marker (20B, Edit, 2.1). AtomicChat's 2.1 quants ship // ZERO KVs, so without it the file falls through to the LLM path and // loads as a chat model. img_in + txt_in + transformer_blocks together is // the Qwen-Image layout: diffusers flux names its input x_embedder, and // the BFL families above use double_blocks. switch { case name == "img_in.weight": sawImgIn = true case name == "txt_in.weight" || name == "txt_in.in_layer.weight": sawTxtIn = true case strings.HasPrefix(name, "transformer_blocks."): sawTransformerBlocks = true } // Video-DiT markers. MiniMax-H3 patchifies a video latent through its // own projection and, on the audio-capable weights, a second audio // latent alongside it. This has to be read off the tensor table: H3's // gguf carries ZERO metadata KVs, and the one arch string that does say // "wan" belongs to ERNIE-Image-Turbo, which is an IMAGE model. Rerouting // by arch name would have broken a working model. if name == "video_patch_proj.weight" || strings.HasPrefix(name, "final_layer.video_out.") { sawVideoPatch = true } if name == "audio_patch_proj.weight" || strings.HasPrefix(name, "final_layer.audio_out.") { sawAudioPatch = true } // LTX-AV (arch "ltxv"): one flat DiT over a joint video+audio token // stream, so it patchifies with a LINEAR layer rather than H3's conv // stem or Wan's conv3d - neither marker above sees it. The audio half is // a second projection in the same tower, and its presence is what makes // --audio-vae required rather than optional. if name == "patchify_proj.weight" { sawLtxPatch = true } if name == "audio_patchify_proj.weight" { sawAudioPatch = true } // Wan2.x and the DiTs that copy its layout patchify with a conv3d, so // the patch embedding is 5-dimensional (out, in, t, h, w). A 4-dim // (image) patch embedding is not a match, and ERNIE ships no patch // embedding tensor at all. if strings.Contains(name, "patch_embedding") && nDims == 5 { sawTemporalPatch = true } // Caption-projection width: the hidden size of the text encoder this DiT // was trained against. Recorded by name here, ranked in condHiddenFrom. if _, want := condTensors[name]; want && nDims > 0 { condDims[name] = firstDim } // Baked text encoder = full checkpoint. SDXL bakes both encoders under // conditioner.embedders.{0,1}; older SD1 checkpoints use cond_stage_model. if strings.Contains(name, "conditioner.embedders.") || strings.Contains(name, "cond_stage_model.") { bakedEnc = true } } switch { case sawLtxPatch: videoKind = VideoFamilyLtxAV case sawVideoPatch: videoKind = VideoFamilyMinimaxH3 case sawTemporalPatch: videoKind = VideoFamilyWan } switch { case sawInputBlocks: if sawLabelEmb { diffKind = "sdxl" } else { diffKind = "sd1" } case sawDoubleBlocks: diffKind = "flux" case sawImgIn && sawTxtIn && sawTransformerBlocks: diffKind = "qwen_image" } // An untabulated type makes the ratio meaningless (the missing tensors are // exactly the ones whose share we would be reporting), so the share alone // degrades to "unknown". Everything else here is type-independent. if !(!sawExpert || totalBytes <= 0 || unknownType) { share = float64(expertBytes) / float64(totalBytes) } return out(), nil } const gib = 1024 * 1024 * 1024 // round mirrors PowerShell [math]::Round (banker's rounding is not required for // these display/size values; standard half-away rounding matches in practice). func round(v float64, places int) float64 { p := math.Pow(10, float64(places)) return math.Round(v*p) / p } // captionChannelsFrom reads the text-encoder hidden width out of a diffusers // "config" json blob, i.e. transformer.caption_channels. Returns 0 for anything // that is not such a blob, so a gguf carrying an unrelated "config" string costs // one failed unmarshal and nothing else. func captionChannelsFrom(s string) int64 { if !strings.Contains(s, "caption_channels") { return 0 } var c struct { Transformer struct { CaptionChannels int64 `json:"caption_channels"` } `json:"transformer"` } if err := json.Unmarshal([]byte(s), &c); err != nil { return 0 } if c.Transformer.CaptionChannels < 0 { return 0 } return c.Transformer.CaptionChannels }