mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 02:18:50 -04:00
* fix(gallery): repoint qwen3-4b/qwen3.5-9b dflash drafters at post-rename GGUFs The drafters both entries referenced were converted from the pre-merge DFlash PR branch and carry dflash.target_layer_ids. llama.cpp reads dflash.target_layers and refuses the load. The stored values are offset by +1 relative to the HF-side field, so the files cannot be repaired by renaming the key and must be replaced. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ci): add apexentries HuggingFace client Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(apexentries): build the HF client via pkg/httpclient The apexentries HuggingFace client was constructed as a raw &http.Client{Timeout: 60s}. The repo convention (documented in .golangci.yml, which cannot express this as a forbidigo pattern) is that all outbound HTTP goes through pkg/httpclient, which refuses redirects by default and sets a TLS 1.2 floor. The std client follows redirects and forwards custom credential headers to the redirect target on a cross-host hop (GHSA-3mj3-57v2-4636). Only a User-Agent is sent today, but this calls an external API and an HF_TOKEN header added later would leak. Switch to httpclient.NewWithTimeout, preserving the 60 second timeout. No behaviour change for the current header set. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ci): discover APEX tiers by filename suffix Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ci): resolve unsloth counterparts and sharded quants Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ci): render APEX child entries with the dflash/mtp tag rule Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(apexentries): set backend, known_usecases and cross-repo drafters RenderChild left three gaps against the hand-written gallery entries. The generated entries reference gallery/virtual.yaml, which supplies no backend, so every generated entry named no engine at all. All comparable hand-written entries set backend: llama-cpp in overrides; do the same. Set known_usecases to [chat] alongside it: LocalAI falls back to the backend defaults when it is absent, so this is convention rather than breakage, but generated entries should not read differently from their neighbours. The drafter was also assumed to live in the repo publishing the weights. Speculative pairings routinely cross repos, and a drafter URI built from the weights repo 404s at install time. Add ChildInput.DraftRepo, used for both the drafter URI and its local path, falling back to Repo when empty so pairings that do ship the drafter alongside the weights are unchanged. The dflash/mtp tagging rule is untouched: the tag still follows SpecType and nothing else. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ci): dedupe generated entries against the existing gallery Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * apexentries: canonicalize HF URIs and dedup the generated batch Merge exists to stop a second gallery entry being added for weights the gallery already ships, but two gaps let duplicates through on a bulk run. The URI key was compared as an exact string while render.go only ever emits https://huggingface.co/{repo}/resolve/main/{file} and the gallery records 1038 of its URIs in huggingface://{repo}/{file} shorthand. A generated unsloth rung whose weights are already shipped in shorthand was therefore not recognised. canonicalURI reduces both spellings to one key and is applied on both sides, taking care that the repo is exactly the first two path segments so sharded quants in a subdirectory still match. A URI in neither form is returned untouched so other hosts dedup on their literal string. Merge also never accounted for entries it had just accepted, so two generated entries sharing a name or a primary URI both landed in add. Several APEX repos share one base model and resolve to the same unsloth counterpart, so the identical rungs are generated twice under the same name. Batch state is tracked locally rather than written back into the caller's ExistingIndex, which a caller may reasonably reuse. Name is still checked before URI: a name collision must block the add regardless of the weights. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ci): verify variant and tagging invariants in the gallery index Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ci): scope the apex-entries verifier to what it can actually judge The verifier reported 60 problems against the real gallery, 57 of which were llama.cpp assumptions meeting entries from other backends. A gate that is wrong 57 times out of 60 cannot gate anything. - The weight-count check catches a quant label collision in llama-cpp quant discovery, so it now runs only for overrides.backend: llama-cpp. Entries with no declared backend are skipped because their weights are declared in the referenced url: template, which the verifier never reads. - The dflash/mtp tag check now implements the per-backend table in .agents/adding-gallery-models.md instead of assuming llama.cpp's spec_type: vocabulary. ds4 declares mtp_path:/mtp_draft:; sglang declares speculative_algorithm: in a file this verifier cannot follow, so sglang entries are not judged in either direction. The check stays bidirectional within the backends it does judge. - sha256 is now required on .gguf files only, since every non-GGUF asset in the index belongs to a hand-curated entry outside this generator's scope. Against the current gallery this leaves exactly the three genuine problems: two entries setting spec_type:draft-mtp without the mtp tag, and one entry whose overrides.mmproj names a file it does not download. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(apexentries): anchor quant matching and invert the sha256 rule UnaccountedQuants matched files to wanted quants with strings.Contains, which reproduces the substring collision it was written to warn about: Q8_0 is a substring of UD-Q8_0, so a repo publishing only UD-Q8_0 was reported as publishing an unbuilt Q8_0. Subdirectory-sharded UD quants are the normal unsloth layout for large repos, so this fired on realistic input. Match on the quant label as an anchored token instead, the way DiscoverUnslothQuants does, so the diagnostic and the discovery it audits cannot disagree about what a file is. Root-level shards, the layout the diagnostic mainly exists to catch, stay detected. The sha256 requirement was scoped to .gguf, which exempted seven real model weights: wan_2.1_vae.safetensors and clip_vision_h.safetensors across the wan-2.1-*-ggml entries, both load-bearing weights named by gallery/wan-ggml.yaml. Invert the rule so a checksum is required on everything except metadata extensions, which keeps a future weight format covered by default rather than silently exempt. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ci): wire the apexentries command Adds the generation path to the apexentries command: list the mudler APEX repos, discover each one's quality ladder and its unsloth counterpart's quant rungs from the filenames actually published, render a child entry per build plus a family parent carrying the variants list, dedup against the gallery, and write the additions to -out or append them with -apply. Discovery shortfalls are reported at discovery time rather than left to the verifier. A quant or a tier that discovery drops leaves no trace in a finished gallery file, and because an empty imatrix ladder falls back to the plain one, a repo whose imatrix filenames all fail to match downgrades the whole family silently instead of erroring. Merge's single reused map is split into two reported categories. A URI match means the gallery already ships exactly these weights and referencing the existing entry is correct; a name collision means an unrelated entry owns the name and referencing it would substitute a different build. Multimodal children now declare known_usecases [chat, vision]. An explicit known_usecases suppresses the backend-default fallback, so a chat-only entry carrying an mmproj never matches the vision or multimodal gallery filters. .github/ci is invisible to go list ./..., so a workflow names both generator packages explicitly and their specs finally run on pull requests. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ci): gather APEX builds under the base model entry The hub for a family is the BASE model entry, never a generated *-apex parent. Somebody looking for qwen3.6-35b-a3b has to find every build of those weights under that one name, so a competing qwen3.6-35b-a3b-apex hub would split the family and leave half of it invisible. When the gallery already ships the base entry, a variants block is spliced into it textually, leaving its description, icon, tags, overrides and files untouched. Only a family whose base model the gallery does not ship gets a new hub, still named for the base model and carrying one of the discovered builds as its own payload so it declares a backend the verifier can judge. The line editing is factored into .github/ci/galleryedit, shared with the variantproposals job, so the two cannot drift apart on where a variants block belongs. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(apexentries): treat an unreadable optional counterpart repo as absent HuggingFace answers 401 Unauthorized, not 404, for a repository that does not exist when the request carries no credentials. FetchRepoFiles treated only 404 as absence, so probing for the OPTIONAL unsloth counterpart hard failed for every family that legitimately has none: 27 of the 45 APEX families are community merges that will never have an unsloth build, and a full run failed all of them. Split the fetch so the two call sites can apply different policies to the same response. The APEX repo itself stays strict: a 401 or 403 on a repo the run requires is a real failure and still errors. Only the optional probe tolerates it, because without a token 401 cannot be told apart from absence. That collapse is lossy in one direction, since a private or gated repo also answers 401, so the skipped candidates are named in the run summary alongside the other silent-shortfall counters instead of being dropped in silence. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(apexentries): report full-precision sources as a known exclusion The 45 APEX repos publish their unquantized F16 sources next to the imatrix ladder, flat or sharded. Discovery correctly emits nothing for them, but they were landing in the unclassified total, leaving a permanent baseline of 24 benign lines on every run. That baseline is what the unclassified check exists to prevent: a standing count of known-benign files is exactly what hides the one file that ever genuinely matters. Count full-precision sources separately and give them their own summary line, so unclassified returns to 0 and stays loud when something really is an unknown shape. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(apexentries): namespace local paths by owner and enable MTP builds localPath namespaced downloads by the repo basename alone, so two repos publishing the same filename under different owners collapsed to one local path. LiquidAI/LFM2.5-8B-A1B-GGUF and unsloth/LFM2.5-8B-A1B-GGUF collided that way, and both were offered from the same hub, so installing the second either overwrote the first model's weights or was skipped as already present while recording a sha256 that did not match the bytes on disk. The owner is now its own path segment: owner/repo is globally unique on HuggingFace and neither half can contain a separator, so uniqueness holds by construction. Verify gains a check for the whole class, that no local filename may map to two different upstream URIs. It surfaces seven pre-existing collisions in the gallery, which are left alone here. Entries built from the *-APEX-MTP-GGUF repos now configure MTP rather than shipping the heads inert, matching the pattern the hand-written MTP entries already use: spec_type:draft-mtp with spec_n_max and spec_p_min, tagged mtp, and no draft_model because the heads live in the weights. RenderChild no longer requires a separate drafter file before it will configure a spec type, while the cross-repo drafter path is unchanged. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add the APEX GGUF families as variant ladders Adds the imatrix quality ladder from each mudler/*-APEX-GGUF repo, a fixed subset of unsloth quant rungs where a counterpart repo exists, and the MTP builds, then attaches them to the base model entry so one entry offers every build of the same weights and LocalAI picks the one that fits the hardware. Ten existing base model entries gain a variants list; twenty-seven families that the gallery had no base entry for get one. Builds are discovered from the filenames each repo actually publishes rather than derived from its name, since six repos ship a stem that differs from their repo name. Every file carries a sha256 taken from the HuggingFace API. Assisted-by: Claude Opus 4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
796 lines
26 KiB
Go
796 lines
26 KiB
Go
// Command apexentries generates gallery entries for the mudler APEX GGUF
|
|
// repositories: one entry per imatrix tier and per unsloth quant rung, all
|
|
// gathered under the BASE model's entry. Builds off a *-APEX-MTP-GGUF repo turn
|
|
// speculative decoding on, because those weights retain the model's MTP heads
|
|
// and are only worth their extra size with the heads in use.
|
|
//
|
|
// The base model entry is the hub. Somebody looking for qwen3.6-35b-a3b must
|
|
// find every build of those weights under that one name, so when the gallery
|
|
// already ships the base entry this command splices a variants block into it
|
|
// rather than emitting a competing *-apex parent beside it. Only a family whose
|
|
// base model the gallery does not ship at all gets a new hub entry, and that one
|
|
// is still named for the base model.
|
|
//
|
|
// Builds are discovered by inspecting the filenames a repo actually publishes.
|
|
// Repo names do not reliably predict them: mudler/gemma-4-26B-A4B-it-APEX-GGUF
|
|
// ships gemma-4-26B-A4B-APEX-*.gguf, and six of the 45 repos drop a suffix or a
|
|
// vendor prefix in the same way.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/mudler/LocalAI/.github/ci/galleryedit"
|
|
)
|
|
|
|
const (
|
|
// entryTemplate carries no backend and no parameters of its own, which is
|
|
// why RenderChild states everything inline.
|
|
entryTemplate = "virtual.yaml"
|
|
unslothOwner = "unsloth"
|
|
authorListURL = "https://huggingface.co/api/models?author=mudler&limit=300"
|
|
)
|
|
|
|
// rungRank orders the quality ladder from best to smallest. The HuggingFace API
|
|
// returns siblings alphabetically and DiscoverAPEXTiers preserves that order, so
|
|
// an unsorted variants list reads I-Balanced, I-Compact, I-Mini, I-Nano,
|
|
// I-Quality. Selection ignores authored order, so this is purely so the file a
|
|
// human reviews scans in a meaningful sequence.
|
|
var rungRank = map[string]int{
|
|
"I-Quality": 0, "I-Balanced": 1, "I-Compact": 2, "I-Mini": 3, "I-Nano": 4,
|
|
"Quality": 5, "Balanced": 6, "Compact": 7, "Mini": 8, "Nano": 9,
|
|
}
|
|
|
|
// baseTags are the tags every generated entry carries. dflash and mtp are never
|
|
// among them: RenderChild adds those if and only if the entry configures the
|
|
// matching spec_type.
|
|
var baseTags = []string{"llm", "gguf", "cpu", "gpu"}
|
|
|
|
// childBuild pairs a rendered entry with its position on the quality ladder, so
|
|
// the parent's variants list can be sorted without re-parsing entry names.
|
|
type childBuild struct {
|
|
entry GalleryEntry
|
|
rank int
|
|
}
|
|
|
|
// family is one APEX repo's full generated output.
|
|
type family struct {
|
|
repo string
|
|
repoBase string
|
|
stem string
|
|
hasMMProj bool
|
|
children []childBuild
|
|
|
|
// skippedRepos are counterpart candidates HuggingFace would not describe.
|
|
// Carried on the family rather than printed and forgotten so the run can
|
|
// summarize them next to everything else a reviewer has to eyeball.
|
|
skippedRepos []string
|
|
census fileCensus
|
|
unaccounted int
|
|
}
|
|
|
|
// fileCensus splits the files discovery emitted nothing for into the ones a
|
|
// reviewer must chase and the ones that are deliberately out of scope.
|
|
//
|
|
// Full-precision sources are the second kind: they are the unquantized weights
|
|
// the ladder is derived FROM, not a rung of it. Folding them into the
|
|
// unclassified total would leave a permanent benign baseline, and a permanent
|
|
// baseline is exactly what hides the one file that ever genuinely matters.
|
|
type fileCensus struct {
|
|
unclassified int
|
|
fullPrecision int
|
|
}
|
|
|
|
// add accumulates one repo's census into a running total.
|
|
func (c *fileCensus) add(o fileCensus) {
|
|
c.unclassified += o.unclassified
|
|
c.fullPrecision += o.fullPrecision
|
|
}
|
|
|
|
// sortedChildren returns the family's builds in ladder order, best first.
|
|
func (f *family) sortedChildren() []childBuild {
|
|
sorted := append([]childBuild{}, f.children...)
|
|
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].rank < sorted[j].rank })
|
|
return sorted
|
|
}
|
|
|
|
func main() {
|
|
verify := flag.String("verify", "", "verify a gallery index and exit")
|
|
index := flag.String("index", "gallery/index.yaml", "gallery index to dedup against")
|
|
only := flag.String("only", "", "comma-separated repo names to restrict generation to")
|
|
out := flag.String("out", "", "write the entries to add to this file")
|
|
apply := flag.Bool("apply", false, "append the entries to add to -index")
|
|
flag.Parse()
|
|
|
|
if *verify != "" {
|
|
problems := Verify(*verify)
|
|
for _, p := range problems {
|
|
fmt.Fprintln(os.Stderr, p)
|
|
}
|
|
if len(problems) > 0 {
|
|
fmt.Fprintf(os.Stderr, "%d problem(s)\n", len(problems))
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("index is sound")
|
|
return
|
|
}
|
|
|
|
if err := generate(*index, *only, *out, *apply); err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func generate(indexPath, only, outPath string, apply bool) error {
|
|
if outPath == "" && !apply {
|
|
return fmt.Errorf("nothing to do: pass -out <file> or -apply")
|
|
}
|
|
|
|
client := newHTTPClient()
|
|
|
|
repos, err := listAPEXRepos(client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if only != "" {
|
|
repos = restrict(repos, only)
|
|
}
|
|
if len(repos) == 0 {
|
|
return fmt.Errorf("no APEX repos selected")
|
|
}
|
|
fmt.Printf("repos selected: %d\n", len(repos))
|
|
|
|
var families []family
|
|
var failed []string
|
|
|
|
for _, repo := range repos {
|
|
f, err := buildFamily(client, repo)
|
|
if err != nil {
|
|
// A missing sha256 is fatal for the family rather than skippable: an
|
|
// entry without one ships an unverifiable download. Report which repo
|
|
// and keep going, so one bad repo does not hide the state of the rest.
|
|
fmt.Fprintf(os.Stderr, "FAILED %s: %v\n", repo, err)
|
|
failed = append(failed, repo)
|
|
continue
|
|
}
|
|
families = append(families, *f)
|
|
}
|
|
|
|
existing, err := LoadExisting(indexPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ixText, err := LoadIndexText(indexPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("existing index: %d names, %d weight URIs, %d lines\n",
|
|
len(existing.ByName), len(existing.ByURI), len(ixText.Lines))
|
|
|
|
// Only the builds go through Merge. A hub is deliberately kept out of it: a
|
|
// new hub carries the family's top rung as its own payload, so Merge's URI
|
|
// dedup would fold the hub into that rung and the family would lose the very
|
|
// entry point this command exists to create. Hub names are checked against
|
|
// the index directly, by ResolveHub.
|
|
var generated []GalleryEntry
|
|
for _, f := range families {
|
|
for _, c := range f.children {
|
|
generated = append(generated, c.entry)
|
|
}
|
|
}
|
|
|
|
add, reused := Merge(existing, generated)
|
|
reportReuse(existing, generated, reused)
|
|
|
|
// Variant references are resolved from `reused`, never used to decide what to
|
|
// emit: on a within-batch name collision Merge records reused[name] = name
|
|
// while the first entry of that name is still in `add`, so treating presence
|
|
// in `reused` as "dropped" would silently emit nothing for it.
|
|
added := map[string]bool{}
|
|
for _, e := range add {
|
|
added[e.Name] = true
|
|
}
|
|
|
|
inserts, newHubs, err := planHubs(families, ixText, reused, added)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reportHubs(ixText, inserts, newHubs)
|
|
|
|
skipped, census, fullPrecisionRepos, unaccounted := reportSkipped(families)
|
|
|
|
add = append(add, newHubs...)
|
|
|
|
fmt.Printf("\nentries generated: %d\nentries to add: %d\nentries reused: %d\nhubs spliced: %d\nhubs created: %d\nrepos skipped: %d\nexcluded (full precision): %d files across %d repos\nunclassified: %d\nunaccounted: %d\n",
|
|
len(generated), len(add), len(reused), len(inserts), len(newHubs), len(skipped),
|
|
census.fullPrecision, fullPrecisionRepos, census.unclassified, unaccounted)
|
|
|
|
lines, err := galleryedit.Apply(ixText.Lines, inserts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := writeEntries(add, lines, outPath, apply, indexPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(failed) > 0 {
|
|
return fmt.Errorf("%d repo(s) failed: %s", len(failed), strings.Join(failed, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveVariant maps a generated child name onto whatever entry actually stands
|
|
// for it after the merge. `added` is consulted first because a within-batch name
|
|
// collision puts a name in BOTH add and reused, and the entry that was emitted
|
|
// is the one the parent must reference.
|
|
func resolveVariant(name string, reused map[string]string, added map[string]bool) string {
|
|
if added[name] {
|
|
return name
|
|
}
|
|
if target, ok := reused[name]; ok {
|
|
return target
|
|
}
|
|
return name
|
|
}
|
|
|
|
// SpecTypeForRepo reports the speculative decoding mechanism a repo's builds can
|
|
// turn on with no extra download.
|
|
//
|
|
// The *-APEX-MTP-GGUF repos republish the base weights with the model's own MTP
|
|
// heads retained, so those builds are only worth their extra size if the heads
|
|
// are actually used. Every other APEX repo drops them, and switching MTP on
|
|
// there would name a mechanism the weights cannot serve.
|
|
//
|
|
// The suffix is read off the repo the FILES come from, so nothing downstream has
|
|
// to infer a capability from an entry name.
|
|
func SpecTypeForRepo(repo string) string {
|
|
if strings.HasSuffix(path.Base(repo), "-APEX-MTP-GGUF") {
|
|
return "draft-mtp"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildFamily discovers everything one APEX repo and its unsloth counterpart
|
|
// publish, and renders it.
|
|
func buildFamily(client *http.Client, repo string) (*family, error) {
|
|
files, err := FetchRepoFiles(client, repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, fmt.Errorf("no gguf files")
|
|
}
|
|
|
|
imatrix, plain := DiscoverAPEXTiers(files)
|
|
mmproj, hasMMProj := DiscoverMMProj(files)
|
|
|
|
census := reportUnclassified(repo, files, imatrix, plain)
|
|
|
|
// The imatrix ladder is preferred, but two of the 45 repos publish no
|
|
// imatrix tiers at all and must still contribute their plain ladder.
|
|
ladder := imatrix
|
|
ladderKind := "imatrix"
|
|
if len(ladder) == 0 {
|
|
ladder = plain
|
|
ladderKind = "plain"
|
|
}
|
|
if len(ladder) == 0 {
|
|
return nil, fmt.Errorf("no tiers discovered")
|
|
}
|
|
|
|
sortTiers(ladder)
|
|
|
|
var mm *GGUFFile
|
|
if hasMMProj {
|
|
mm = &mmproj
|
|
}
|
|
|
|
repoBase := strings.TrimSuffix(path.Base(repo), "-GGUF")
|
|
f := &family{repo: repo, repoBase: repoBase, hasMMProj: hasMMProj, census: census}
|
|
|
|
// Only the APEX ladder can carry MTP heads; the unsloth counterpart quantizes
|
|
// the plain weights and gets nothing from this.
|
|
specType := SpecTypeForRepo(repo)
|
|
|
|
for _, t := range ladder {
|
|
f.children = append(f.children, childBuild{
|
|
rank: rungRank[t.Label],
|
|
entry: RenderChild(ChildInput{
|
|
Name: slug(repoBase) + "-" + slug(t.Label),
|
|
Repo: repo,
|
|
Template: entryTemplate,
|
|
SpecType: specType,
|
|
Weights: []GGUFFile{t.File},
|
|
MMProj: mm,
|
|
BaseTags: baseTags,
|
|
}),
|
|
})
|
|
}
|
|
|
|
stem := FileStem(ladder[0])
|
|
f.stem = stem
|
|
fmt.Printf("%s: %d %s tier(s) [%s], stem %s, mmproj %v\n",
|
|
repo, len(ladder), ladderKind, tierLabels(ladder), stem, hasMMProj)
|
|
|
|
counterpart, cpFiles, skipped, err := resolveCounterpart(client, repoBase, stem)
|
|
f.skippedRepos = skipped
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if counterpart != "" {
|
|
builds := DiscoverUnslothQuants(cpFiles)
|
|
|
|
// Called here rather than inside Verify: a quant dropped at discovery
|
|
// leaves no trace at all in the finished gallery file, so the only place
|
|
// the shortfall is still visible is the moment of discovery.
|
|
unaccounted := UnaccountedQuants(cpFiles, builds)
|
|
f.unaccounted = len(unaccounted)
|
|
for _, p := range unaccounted {
|
|
fmt.Fprintf(os.Stderr, "UNACCOUNTED QUANT %s: %s\n", counterpart, p)
|
|
}
|
|
|
|
cpMMProj, hasCPMMProj := DiscoverMMProj(cpFiles)
|
|
var cpMM *GGUFFile
|
|
if hasCPMMProj {
|
|
cpMM = &cpMMProj
|
|
}
|
|
cpBase := strings.TrimSuffix(path.Base(counterpart), "-GGUF")
|
|
|
|
for i, b := range builds {
|
|
f.children = append(f.children, childBuild{
|
|
rank: 100 + i,
|
|
entry: RenderChild(ChildInput{
|
|
Name: slug(cpBase) + "-" + slug(b.Quant),
|
|
Repo: counterpart,
|
|
Template: entryTemplate,
|
|
Weights: b.Files,
|
|
MMProj: cpMM,
|
|
BaseTags: baseTags,
|
|
}),
|
|
})
|
|
}
|
|
fmt.Printf("%s: counterpart %s, %d quant build(s) %s\n", repo, counterpart, len(builds), quantLabels(builds))
|
|
} else {
|
|
fmt.Printf("%s: no unsloth counterpart\n", repo)
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
// planHubs decides, per family, whether the family's builds are spliced into a
|
|
// base model entry the gallery already ships or gathered under a new hub.
|
|
//
|
|
// Splicing is strongly preferred and is the measured majority-adjacent case. The
|
|
// existing entry keeps its description, icon, tags, overrides and files
|
|
// untouched; only variant lines are added to it.
|
|
func planHubs(families []family, ix *IndexText, reused map[string]string, added map[string]bool) ([]galleryedit.Insert, []GalleryEntry, error) {
|
|
// Several APEX repos can resolve to one base model, so both paths accumulate
|
|
// by hub name rather than assuming one family per hub.
|
|
wantByHub := map[string][]string{}
|
|
var spliceOrder []string
|
|
|
|
var newHubs []GalleryEntry
|
|
hubAt := map[string]int{}
|
|
|
|
for i := range families {
|
|
f := &families[i]
|
|
|
|
hubName, exists := ResolveHub(ix, f.repoBase, f.stem)
|
|
want := hubVariants(f, ix, reused, added)
|
|
|
|
if exists {
|
|
if _, seen := wantByHub[hubName]; !seen {
|
|
spliceOrder = append(spliceOrder, hubName)
|
|
}
|
|
wantByHub[hubName] = append(wantByHub[hubName], want...)
|
|
continue
|
|
}
|
|
|
|
if at, dup := hubAt[hubName]; dup {
|
|
for _, v := range filterVariants(hubName, newHubs[at].Variants, want) {
|
|
newHubs[at].Variants = append(newHubs[at].Variants, VariantRef{Model: v})
|
|
}
|
|
continue
|
|
}
|
|
|
|
builds := f.sortedChildren()
|
|
if len(builds) == 0 {
|
|
return nil, nil, fmt.Errorf("%s: no builds to hang a hub on", f.repo)
|
|
}
|
|
hubAt[hubName] = len(newHubs)
|
|
newHubs = append(newHubs, renderHub(hubName, f, builds[0], filterVariants(hubName, nil, want)))
|
|
}
|
|
|
|
var inserts []galleryedit.Insert
|
|
for _, name := range spliceOrder {
|
|
e := ix.Find(name)
|
|
items := filterVariants(name, e.Variants, wantByHub[name])
|
|
if len(items) == 0 {
|
|
continue
|
|
}
|
|
inserts = append(inserts, galleryedit.Insert{Entry: e.Pos, Variants: items})
|
|
}
|
|
return inserts, newHubs, nil
|
|
}
|
|
|
|
// hubVariants is a family's full build list, in ladder order, named as the hub
|
|
// must reference them after the merge.
|
|
func hubVariants(f *family, ix *IndexText, reused map[string]string, added map[string]bool) []string {
|
|
var out []string
|
|
|
|
// A hand-written *-apex entry is an ordinary build of these weights. It is
|
|
// never deleted, never renamed and never treated as a hub; it is simply
|
|
// referenced like any other rung.
|
|
if apex := slug(f.repoBase); ix.Find(apex) != nil {
|
|
out = append(out, apex)
|
|
}
|
|
for _, c := range f.sortedChildren() {
|
|
out = append(out, resolveVariant(c.entry.Name, reused, added))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderHub builds the hub for a family whose base model the gallery does not
|
|
// ship at all. It is named for the BASE model, never for the APEX repo.
|
|
//
|
|
// It carries one of the discovered builds as its own payload so it is a complete
|
|
// installable entry rather than a bare index pointing at other entries. That
|
|
// payload is what supplies overrides.backend, which matters beyond installation:
|
|
// the verifier can only judge the tagging rule for a backend it can read, so a
|
|
// hub carrying feature tags and no backend would escape the check in silence.
|
|
//
|
|
// The payload's own tags are kept rather than rebuilt from baseTags, so a hub
|
|
// whose payload configures a spec_type stays tagged for it and consistent with
|
|
// the overrides copied alongside.
|
|
func renderHub(name string, f *family, payload childBuild, variants []string) GalleryEntry {
|
|
e := payload.entry
|
|
e.Name = name
|
|
e.Description = fmt.Sprintf(
|
|
"%s. Quality ladder and quantization rungs published by %s and its unsloth counterpart; LocalAI picks the build that fits the hardware.",
|
|
HubLabel(f.repoBase, f.stem), f.repo)
|
|
|
|
e.Tags = append([]string{}, payload.entry.Tags...)
|
|
if f.hasMMProj && !hasTag(e.Tags, "vision") {
|
|
e.Tags = append(e.Tags, "vision")
|
|
}
|
|
|
|
e.Variants = nil
|
|
for _, v := range variants {
|
|
e.Variants = append(e.Variants, VariantRef{Model: v})
|
|
}
|
|
return e
|
|
}
|
|
|
|
func hasTag(tags []string, want string) bool {
|
|
for _, t := range tags {
|
|
if t == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveCounterpart probes the unsloth candidates in order and returns the
|
|
// first that publishes files.
|
|
//
|
|
// CounterpartCandidates is handed a BARE repo name: its cleaner does not strip
|
|
// an owner prefix, so passing "mudler/Foo-APEX-GGUF" would yield "mudler/Foo"
|
|
// and compose into the nonsense probe "unsloth/mudler/Foo".
|
|
//
|
|
// It also returns the candidates HuggingFace refused to describe. Those are
|
|
// indistinguishable from absent without credentials, so they are skipped, but
|
|
// they are named rather than dropped: one of them could be a real gated repo
|
|
// whose quants belong in the gallery.
|
|
func resolveCounterpart(client *http.Client, repoBase, stem string) (string, []GGUFFile, []string, error) {
|
|
var unavailable []string
|
|
for _, cand := range CounterpartCandidates(repoBase, stem) {
|
|
repo := unslothOwner + "/" + cand + "-GGUF"
|
|
files, unreadable, err := FetchOptionalRepoFiles(client, repo)
|
|
if err != nil {
|
|
return "", nil, unavailable, fmt.Errorf("probing %s: %w", repo, err)
|
|
}
|
|
if unreadable {
|
|
unavailable = append(unavailable, repo)
|
|
continue
|
|
}
|
|
if len(files) > 0 {
|
|
return repo, files, unavailable, nil
|
|
}
|
|
}
|
|
return "", nil, unavailable, nil
|
|
}
|
|
|
|
// reportUnclassified prints the files discovery turned into nothing.
|
|
//
|
|
// It is a set difference on COUNTS, not a re-match of filenames: re-matching
|
|
// would duplicate the tier regex from discover.go and the two copies would
|
|
// drift. The likeliest trigger is a typo or case change from a publishing script
|
|
// rather than a genuine sixth tier, and because generation falls back to the
|
|
// plain ladder when the imatrix one is empty, a repo whose imatrix files all
|
|
// fail to match silently downgrades the whole family instead of erroring. The
|
|
// downstream HTTP check cannot catch that: it validates URLs that were emitted,
|
|
// and an undiscovered tier emits none.
|
|
// It returns the census so the run can total it.
|
|
func reportUnclassified(repo string, files []GGUFFile, imatrix, plain []Tier) fileCensus {
|
|
mmprojCount, fullPrecision := 0, 0
|
|
for _, f := range files {
|
|
// The mmproj test comes first because projectors are themselves often
|
|
// published at f16 (mmproj-F16.gguf), and counting such a file in both
|
|
// buckets would understate the unclassified remainder.
|
|
if strings.HasPrefix(f.Name, "mmproj") {
|
|
mmprojCount++
|
|
continue
|
|
}
|
|
if IsFullPrecision(f.Name) {
|
|
fullPrecision++
|
|
}
|
|
}
|
|
|
|
classified := len(imatrix) + len(plain) + mmprojCount + fullPrecision
|
|
if classified >= len(files) {
|
|
return fileCensus{fullPrecision: fullPrecision}
|
|
}
|
|
fmt.Fprintf(os.Stderr, "UNCLASSIFIED %s: %d of %d .gguf files classified, %d unaccounted for\n",
|
|
repo, classified, len(files), len(files)-classified)
|
|
return fileCensus{unclassified: len(files) - classified, fullPrecision: fullPrecision}
|
|
}
|
|
|
|
// reportReuse splits Merge's single reused map into the two cases it conflates.
|
|
//
|
|
// A URI match means the gallery already ships exactly these weights, and
|
|
// pointing the parent at the existing entry is correct. A NAME match with a
|
|
// different URI means an unrelated entry happens to own the name, and
|
|
// referencing it would point the parent at different weights than were
|
|
// generated, substituting a build without saying so. Only the first is safe to
|
|
// wave through.
|
|
func reportReuse(existing *ExistingIndex, generated []GalleryEntry, reused map[string]string) {
|
|
byName := map[string]GalleryEntry{}
|
|
for _, e := range generated {
|
|
if _, seen := byName[e.Name]; !seen {
|
|
byName[e.Name] = e
|
|
}
|
|
}
|
|
|
|
var nameCollisions, uriMatches []string
|
|
for name, target := range reused {
|
|
gen := byName[name]
|
|
uri := ""
|
|
if len(gen.Files) > 0 {
|
|
uri = gen.Files[0].URI
|
|
}
|
|
|
|
switch {
|
|
case hasName(existing, name):
|
|
nameCollisions = append(nameCollisions,
|
|
fmt.Sprintf(" %s -> gallery entry of the same name (generated uri: %s)", name, orNone(uri)))
|
|
case target == name:
|
|
nameCollisions = append(nameCollisions,
|
|
fmt.Sprintf(" %s -> earlier entry of the same name in this batch (generated uri: %s)", name, orNone(uri)))
|
|
default:
|
|
uriMatches = append(uriMatches, fmt.Sprintf(" %s -> %s (same weights: %s)", name, target, orNone(uri)))
|
|
}
|
|
}
|
|
sort.Strings(nameCollisions)
|
|
sort.Strings(uriMatches)
|
|
|
|
fmt.Printf("\nNAME COLLISIONS (%d) - inspect each by hand, the target may hold different weights\n", len(nameCollisions))
|
|
for _, l := range nameCollisions {
|
|
fmt.Println(l)
|
|
}
|
|
fmt.Printf("\nURI MATCHES (%d) - the gallery or this batch already ships these exact weights\n", len(uriMatches))
|
|
for _, l := range uriMatches {
|
|
fmt.Println(l)
|
|
}
|
|
}
|
|
|
|
// reportHubs prints exactly what will be written where. The splices are the part
|
|
// a human has to read: they modify entries the gallery already ships, so the
|
|
// review needs the target, the line, and every added reference spelled out.
|
|
func reportHubs(ix *IndexText, inserts []galleryedit.Insert, newHubs []GalleryEntry) {
|
|
fmt.Printf("\nHUBS SPLICED (%d) - variants added to the EXISTING base model entry, nothing else touched\n", len(inserts))
|
|
for _, in := range inserts {
|
|
e := ix.Find(in.Entry.Name)
|
|
fmt.Printf(" %s (line %d, %d variant(s) already declared):\n", in.Entry.Name, in.Entry.StartLine+1, len(e.Variants))
|
|
for _, v := range in.Variants {
|
|
fmt.Printf(" + - model: %s\n", galleryedit.QuoteName(v))
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\nHUBS CREATED (%d) - the gallery ships no base model entry, so one is emitted for it\n", len(newHubs))
|
|
for _, h := range newHubs {
|
|
fmt.Printf(" %s:\n", h.Name)
|
|
for _, v := range h.Variants {
|
|
fmt.Printf(" - model: %s\n", v.Model)
|
|
}
|
|
}
|
|
}
|
|
|
|
// reportSkipped names the counterpart repos HuggingFace would not describe, and
|
|
// totals the other two silent-shortfall counters alongside them.
|
|
//
|
|
// A skipped repo is not the same as a clean 404. HuggingFace answers 401 for a
|
|
// nonexistent repo to an unauthenticated client, so the overwhelmingly likely
|
|
// reading is "there is no such counterpart", which is the normal case for the
|
|
// community merges. But a private or gated repo answers 401 too, and that one
|
|
// WOULD have quants worth shipping. Printing the list is what keeps that
|
|
// possibility auditable instead of silently discarded.
|
|
func reportSkipped(families []family) ([]string, fileCensus, int, int) {
|
|
var skipped []string
|
|
var census fileCensus
|
|
fullPrecisionRepos, unaccounted := 0, 0
|
|
for _, f := range families {
|
|
skipped = append(skipped, f.skippedRepos...)
|
|
census.add(f.census)
|
|
if f.census.fullPrecision > 0 {
|
|
fullPrecisionRepos++
|
|
}
|
|
unaccounted += f.unaccounted
|
|
}
|
|
sort.Strings(skipped)
|
|
|
|
fmt.Printf("\nREPOS SKIPPED AS UNAVAILABLE (%d) - HuggingFace answered 401/403, which is indistinguishable from absent without a token; check none of these is a real gated repo\n", len(skipped))
|
|
for _, r := range skipped {
|
|
fmt.Printf(" %s\n", r)
|
|
}
|
|
return skipped, census, fullPrecisionRepos, unaccounted
|
|
}
|
|
|
|
func hasName(ix *ExistingIndex, name string) bool {
|
|
_, ok := ix.ByName[name]
|
|
return ok
|
|
}
|
|
|
|
func orNone(s string) string {
|
|
if s == "" {
|
|
return "(no files)"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// writeEntries emits the additions.
|
|
//
|
|
// -apply does two things in one pass: it writes back the spliced lines, which
|
|
// differ from the original only by the variant lines galleryedit inserted, and
|
|
// then appends the new entries. New entries are APPENDED rather than merged into
|
|
// the structure, for the same reason the splice is textual: a YAML round trip
|
|
// over 40,000 lines would reflow the whole file into an unreviewable diff.
|
|
func writeEntries(add []GalleryEntry, lines []string, outPath string, apply bool, indexPath string) error {
|
|
if apply {
|
|
if err := os.WriteFile(indexPath, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("spliced %s\n", indexPath)
|
|
}
|
|
|
|
if len(add) == 0 {
|
|
fmt.Println("nothing to append")
|
|
return nil
|
|
}
|
|
|
|
blob, err := yaml.Marshal(add)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if outPath != "" {
|
|
if err := os.WriteFile(outPath, blob, 0o644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("wrote %d entries to %s\n", len(add), outPath)
|
|
}
|
|
|
|
if apply {
|
|
f, err := os.OpenFile(indexPath, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.Write(blob); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("appended %d entries to %s\n", len(add), indexPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// listAPEXRepos returns the mudler repos whose name marks them as APEX builds.
|
|
func listAPEXRepos(client *http.Client) ([]string, error) {
|
|
req, err := http.NewRequest(http.MethodGet, authorListURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "localai-apexentries/1.0")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("listing models: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var models []struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(body, &models); err != nil {
|
|
return nil, fmt.Errorf("decoding model list: %w", err)
|
|
}
|
|
|
|
var out []string
|
|
for _, m := range models {
|
|
if strings.Contains(m.ID, "APEX") {
|
|
out = append(out, m.ID)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
func restrict(repos []string, only string) []string {
|
|
want := map[string]bool{}
|
|
for _, r := range strings.Split(only, ",") {
|
|
if r = strings.TrimSpace(r); r != "" {
|
|
want[r] = true
|
|
}
|
|
}
|
|
|
|
var out []string
|
|
for _, r := range repos {
|
|
if want[r] {
|
|
out = append(out, r)
|
|
delete(want, r)
|
|
}
|
|
}
|
|
// A name in -only that matched nothing is a typo, not an empty result.
|
|
for r := range want {
|
|
fmt.Fprintf(os.Stderr, "WARNING: -only names %s, which is not an APEX repo of this author\n", r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sortTiers(tiers []Tier) {
|
|
sort.SliceStable(tiers, func(i, j int) bool { return rungRank[tiers[i].Label] < rungRank[tiers[j].Label] })
|
|
}
|
|
|
|
func tierLabels(tiers []Tier) string {
|
|
var out []string
|
|
for _, t := range tiers {
|
|
out = append(out, t.Label)
|
|
}
|
|
return strings.Join(out, ",")
|
|
}
|
|
|
|
func quantLabels(builds []QuantBuild) string {
|
|
var out []string
|
|
for _, b := range builds {
|
|
l := b.Quant
|
|
if b.Sharded {
|
|
l += fmt.Sprintf("(%d shards)", len(b.Files))
|
|
}
|
|
out = append(out, l)
|
|
}
|
|
return strings.Join(out, ",")
|
|
}
|
|
|
|
// slug turns a repo, tier or quant label into a gallery entry name component.
|
|
func slug(s string) string {
|
|
return strings.ReplaceAll(strings.ToLower(s), "_", "-")
|
|
}
|