package autogen import ( "os" "path/filepath" "regexp" "strings" "sync" "time" "github.com/quartermaster-labs/quartermaster/internal/config" "github.com/quartermaster-labs/quartermaster/internal/quant" ) // Family-scoped sidecar inheritance. // // A draft gguf (MTP / DFlash) and a vision projector (mmproj) are published for // a MODEL, but they land on disk next to ONE download of it. The moment a user // keeps two quants of the same model in separate folders - the layout every hub // download produces - or runs a finetune of a model whose base shipped a // projector, the sidecar is invisible to every copy but the one it came with. // Dir-local pairing (DiscoverGgufModels) stays the source of truth; this file // only fills the gaps, so a finetune that ships its own drafter keeps it. // // Two gates gate a donation, and both must pass: // // 1. NAME - the recipient is either the same model at another quant // (ModelBaseKey) or a finetune of the same base at the same parameter count // (FamilyKey). These mirror baseKey/familyOf in // ui-svelte/src/lib/modelTable.ts, which already groups the models table on // the same two axes; keep the pair in sync. // 2. HEADER - the donor's own model and the recipient agree on arch, embedding // length, block count and vocab size. This is the gate that matters: a // drafter whose vocab differs does not degrade, it aborts the launch // ("tensor 'output.weight' has wrong shape"), and a projector projects into // a text tower of one fixed width. Names are a heuristic; the header is not. // // Nothing here charges VRAM or emits a flag - it only fills GgufRow, so the // existing kind/spec gates (matchedDraftSizeGB, effectiveSpec, buildCmdLines) // still decide what an inherited sidecar is used for. Opting out is per model: // a spec override without draft-* emits no -md, and the vision twin an // inherited projector creates can be marked unlisted. var ( // A parameter count as publishers write it: 27b, 4b, 0.6b, 350m, gemma's e2b. sizeTokenRe = regexp.MustCompile(`(?i)^[a-z]?\d+(?:\.\d+)?[bm]$`) // A MoE active-parameter tail: the "a3b" of "qwen3.6-35b-a3b". moeTokenRe = regexp.MustCompile(`(?i)^a\d+(?:\.\d+)?b$`) // A bare version number that belongs to the name ("gemma-4-12b"). verTokenRe = regexp.MustCompile(`^\d+(?:\.\d+)?$`) ) // quantTokenIndex finds the FIRST quant-shaped part of a split id. First, not // last: what follows a quant is a build tag ("-MTP", "-preserved", "-MID-HIGH"), // never a second weight type. Never index 0 - an id that IS a quant has no base // left. func quantTokenIndex(parts []string) int { return quant.PartIndex(parts) } // ModelBaseKey cuts an id at the quant, so the same model at Q4_K_M and at Q8_0 // reduce to one key. It cuts rather than splices out the one part, because both // variant suffixes and build tags trail the quant. func ModelBaseKey(id string) string { parts := strings.Split(strings.ToLower(strings.TrimSpace(id)), "-") for len(parts) > 1 && parts[len(parts)-1] == "gguf" { parts = parts[:len(parts)-1] } i := quantTokenIndex(parts) // already folds a UD/i1 marker in front of the token if i > 0 { parts = parts[:i] } // A recipe marker can also arrive already orphaned: discovery strips the // quant off "Qwen3.8-27B-UD-Q4_K_XL" and leaves BaseID "qwen3.8-27b-ud". // Popping it here is what makes unsloth's dynamic quants twins of the same // model's plain quants instead of a family of their own. for len(parts) > 1 && quant.PrefixRe.MatchString(parts[len(parts)-1]) { parts = parts[:len(parts)-1] } return strings.Join(parts, "-") } // FamilyKey is the finetune detector: it reduces an id to , so // "thinkingcap-qwen3.6-27b" and "qwen3.6-27b-uncensored-heretic-v2" both resolve // to "qwen3.6-27b". Deliberately keyed on the parameter count - every finetune // keeps it, and it is the one token a tuner never rewrites. An id carrying no // size token is its own family, which is the conservative answer: nothing but an // exact base-key twin can donate to it. func FamilyKey(id string) string { key := ModelBaseKey(id) parts := strings.Split(key, "-") i := -1 for j, p := range parts { if sizeTokenRe.MatchString(p) && !moeTokenRe.MatchString(p) { i = j break } } if i < 1 { return key } start := i - 1 if i > 1 && verTokenRe.MatchString(parts[i-1]) { start = i - 2 } end := i + 1 if end < len(parts) && moeTokenRe.MatchString(parts[end]) { end = i + 2 } return strings.Join(parts[start:end], "-") } // inheritTier ranks a donor row for a recipient: 0 = the same model at another // quant, 1 = the family's un-tuned base model, 2 = another finetune of it, // -1 = unrelated. Lower wins, so a sidecar always comes from the closest thing // to the recipient itself: its own other quant first, then the base everyone in // the family was tuned from, and only then a peer finetune's copy. func inheritTier(want, donor *GgufRow) int { if want.BaseID == "" || donor.BaseID == "" { return -1 } donorBase := ModelBaseKey(donor.BaseID) if ModelBaseKey(want.BaseID) == donorBase { return 0 } f := FamilyKey(want.BaseID) if f == "" || f != FamilyKey(donor.BaseID) { return -1 } if donorBase == f { return 1 // the donor's name reduces to the family key: it IS the base } return 2 } // sidecarCompatible reports whether a sidecar published alongside donor also // loads against want. All four fields are quant-invariant, so two quants of one // model always agree; a pruned, vocab-extended or re-architected finetune does // not, and is refused. A field the parser could not fill (0) is refused too - an // unknown is not a match. func sidecarCompatible(want, donor Metadata) bool { return want.Architecture != "" && strings.EqualFold(want.Architecture, donor.Architecture) && want.EmbeddingLength > 0 && want.EmbeddingLength == donor.EmbeddingLength && want.BlockCount > 0 && want.BlockCount == donor.BlockCount && want.VocabSize > 0 && want.VocabSize == donor.VocabSize } // inheritSidecars fills in the draft gguf / vision projector of every row that // has none of its own, from a compatible row that does. Runs after the dir-local // pairing pass in DiscoverGgufModels and never overwrites what that pass found. func inheritSidecars(rows []GgufRow) { inheritSidecarsWith(rows, ReadGgufMetadataCached) } // inheritSidecarsWith is inheritSidecars over an injectable header reader, so // the donation rules can be tested without synthesising real gguf headers. func inheritSidecarsWith(rows []GgufRow, readMeta func(string) (Metadata, error)) { var draftDonors, mmprojDonors []int for i := range rows { if rows[i].skipsGgufPipeline() { continue } if rows[i].DraftPath != "" { draftDonors = append(draftDonors, i) } if rows[i].MmprojPath != "" { mmprojDonors = append(mmprojDonors, i) } } if len(draftDonors) == 0 && len(mmprojDonors) == 0 { return } // Headers were already parsed by the walk (clip detection), so these are // cache hits; the local map only avoids re-deriving the cache key per pair. metaOf := map[int]*Metadata{} meta := func(i int) *Metadata { if m, ok := metaOf[i]; ok { return m } var m *Metadata if !rows[i].skipsGgufPipeline() { if v, err := readMeta(rows[i].FullPath); err == nil { m = &v } } metaOf[i] = m return m } // choose picks the donor for row want: nearest tier, then the donor sharing // its quant (likeliest to have been downloaded with it), then the largest // sibling, then the lowest path so the result is deterministic. choose := func(want int, donors []int) int { wm := meta(want) if wm == nil { return -1 } best, bestTier, bestQuant := -1, 0, false for _, d := range donors { if d == want { continue } tier := inheritTier(&rows[want], &rows[d]) if tier < 0 { continue } dm := meta(d) if dm == nil || !sidecarCompatible(*wm, *dm) { continue } quantMatch := rows[d].Quant != "" && strings.EqualFold(rows[d].Quant, rows[want].Quant) if best >= 0 { switch { case tier != bestTier: if tier > bestTier { continue } case quantMatch != bestQuant: if !quantMatch { continue } case rows[d].SizeGB != rows[best].SizeGB: if rows[d].SizeGB < rows[best].SizeGB { continue } default: if rows[d].FullPath >= rows[best].FullPath { continue } } } best, bestTier, bestQuant = d, tier, quantMatch } return best } for i := range rows { if rows[i].skipsGgufPipeline() { continue } if rows[i].DraftPath == "" { if d := choose(i, draftDonors); d >= 0 { rows[i].DraftPath = rows[d].DraftPath rows[i].DraftKind = rows[d].DraftKind rows[i].DraftSizeGB = rows[d].DraftSizeGB } } if rows[i].MmprojPath == "" { if d := choose(i, mmprojDonors); d >= 0 { rows[i].MmprojPath = rows[d].MmprojPath rows[i].MmprojSizeGB = rows[d].MmprojSizeGB } } } } // sidecarIndexTTL bounds how stale an inherited answer may be. The config editor // asks once per model opened, and the walk behind the answer is only worth doing // once per burst; a fresh download shows up within the window. const sidecarIndexTTL = 30 * time.Second // inheritedSidecars is what one discovered model ended up paired with, after // both the dir-local pass and inheritSidecars have run. type inheritedSidecars struct { draft draftSidecar mmproj mmprojSidecar } type sidecarIndexEntry struct { at time.Time byKey map[string]inheritedSidecars } var ( sidecarIndexMu sync.Mutex sidecarIndexes = map[string]sidecarIndexEntry{} ) // sidecarIndexFor returns the paired-sidecar map for a root set, memoized for // sidecarIndexTTL. nil when discovery fails - callers then answer "none", which // is the same answer they gave before inheritance existed. func sidecarIndexFor(roots []string) map[string]inheritedSidecars { if len(roots) == 0 { return nil } key := strings.Join(roots, "\x00") sidecarIndexMu.Lock() entry, ok := sidecarIndexes[key] if ok && time.Since(entry.at) >= sidecarIndexTTL { ok = false } sidecarIndexMu.Unlock() if ok { return entry.byKey } rows, err := DiscoverGgufModelsMulti(roots) if err != nil { return nil } byKey := map[string]inheritedSidecars{} for _, row := range rows { if row.DraftPath == "" && row.MmprojPath == "" { continue } byKey[config.PathKey(row.FullPath)] = inheritedSidecars{ draft: draftSidecar{path: row.DraftPath, kind: row.DraftKind, sizeGB: row.DraftSizeGB}, mmproj: mmprojSidecar{path: row.MmprojPath, sizeGB: row.MmprojSizeGB}, } } sidecarIndexMu.Lock() sidecarIndexes[key] = sidecarIndexEntry{at: time.Now(), byKey: byKey} sidecarIndexMu.Unlock() return byKey } // DraftSidecarFor answers "which drafter would this model launch with": the one // in its own dir when there is one, else the one it inherits from a family // sibling under roots. inherited says which of the two it is, so the UI can say // so rather than pointing at a folder that visibly holds no drafter. // DraftSidecarForDir stays the dir-only answer for callers with no roots to scan. // // The inherited half costs a models-root walk, so it is memoized (sidecarIndexFor) // - and reached only when the model's own dir came up empty. func DraftSidecarFor(roots []string, ggufPath string) (path, kind string, sizeGB float64, inherited bool) { if p, k, gb := DraftSidecarForDir(filepath.Dir(ggufPath)); p != "" { return p, k, gb, false } if strings.TrimSpace(ggufPath) == "" { return "", "", 0, false } if d, hit := sidecarIndexFor(roots)[config.PathKey(ggufPath)]; hit && d.draft.path != "" { return d.draft.path, d.draft.kind, d.draft.sizeGB, true } return "", "", 0, false } // MmprojSidecarForDir scans one directory (non-recursive) for a vision // projector, by the same rule the discovery walk uses: a gguf whose header // reports the "clip" architecture. Name-based detection is deliberately avoided // - publishers ship projectors as mmproj-*, -mmproj-*, clip-* and bare. func MmprojSidecarForDir(dir string) (path string, sizeGB float64) { entries, err := os.ReadDir(dir) if err != nil { return "", 0 } for _, ent := range entries { if ent.IsDir() || !strings.EqualFold(filepath.Ext(ent.Name()), ".gguf") { continue } full := filepath.Join(dir, ent.Name()) meta, err := ReadGgufMetadataCached(full) if err != nil || meta.Architecture != "clip" { continue } fi, err := ent.Info() if err != nil { continue } return full, round(float64(fi.Size())/gib, 2) } return "", 0 } // mmprojFor resolves which projector a model's "-vision" twin loads and how big // it is: the explicit mmprojFile override when it names one, else whatever // discovery paired (dir-local, or inherited from a family sibling). // // The size cannot come from the row: GgufRow.MmprojSizeGB describes discovery's // file, and pricing an override's projector at another file's size is how a twin // ends up sized against 0.5 GB of VRAM it will not have. So an explicit path is // stat'd. A path that does not exist yields 0 and is left to save-time // validation (configapi) rather than dropped here, which would silently emit no // twin for a path the user can see in the editor. func mmprojFor(row GgufRow, mmprojFile string) (path string, sizeGB float64) { p := strings.TrimSpace(mmprojFile) if p == "" { return row.MmprojPath, row.MmprojSizeGB } if fi, err := os.Stat(p); err == nil && !fi.IsDir() { return p, round(float64(fi.Size())/gib, 2) } return p, 0 } // MmprojSidecarFor is DraftSidecarFor for the vision projector: the one in the // model's own dir, else the one inherited from a family sibling. It answers what // the "-vision" twin actually loads, which is not something the model's folder // shows once inheritance is in play. func MmprojSidecarFor(roots []string, ggufPath string) (path string, sizeGB float64, inherited bool) { if p, gb := MmprojSidecarForDir(filepath.Dir(ggufPath)); p != "" { return p, gb, false } if strings.TrimSpace(ggufPath) == "" { return "", 0, false } if d, hit := sidecarIndexFor(roots)[config.PathKey(ggufPath)]; hit && d.mmproj.path != "" { return d.mmproj.path, d.mmproj.sizeGB, true } return "", 0, false }