* 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>
apexentries
Generates gallery entries for the mudler/*-APEX-GGUF HuggingFace repositories.
Each APEX repo becomes one family: one entry per quality rung the repo publishes and one per quantization rung its unsloth counterpart publishes, all gathered under the base model's entry. LocalAI's variant selector then picks the build that fits the hardware in front of it.
The hub is the base model entry, never a generated *-apex parent
Somebody looking for qwen3.6-35b-a3b must find every build of those weights
under that one name: the APEX imatrix rungs, the unsloth quant rungs and any
speculative build. A separate qwen3.6-35b-a3b-apex hub competing with the base
entry would split the family in two and leave whichever half the user did not
search for effectively invisible.
So the generator resolves the hub by stripping the -APEX, -MTP and -TQ
markers and looking the result up in the index, trying both the repo-derived and
the stem-derived candidate the same way CounterpartCandidates does. Then:
- The hub exists (14 of the 45 repos, resolving to 10 distinct entries).
Nothing new is emitted for the family root. A
variants:block is spliced into the entry that is already there, textually, leaving its description, icon, tags, overrides and files untouched. The line editing is shared with thevariantproposalsjob via.github/ci/galleryedit. - The hub is absent (the other 31). A new hub is emitted, named for the base
model and 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,
and that payload is what gives it an
overrides.backend. Without a declared backend the verifier would skip it, so a hub carrying feature tags would escape the tagging check in silence.
Several APEX repos routinely resolve to one base model, so both paths accumulate by hub name rather than assuming one family per hub.
Two references are always filtered out of a hub's list: anything the entry already declares, and the hub's own name. The self reference is not merely redundant. An unsloth rung whose weights the gallery already ships under the base name resolves, through the merge, straight back to the hub, and the verifier reads a self reference as a variant that declares variants of its own.
The four hand-written *-apex entries (qwen3.6-35b-a3b-apex,
gemma-4-26b-a4b-it-apex, qwen3.5-35b-a3b-apex,
nemotron-3-nano-omni-30b-a3b-reasoning-apex) are ordinary builds, not hubs.
They are referenced from their hub's variants list like any other rung, and are
never deleted or renamed.
Flags
| Flag | Default | Meaning |
|---|---|---|
-index <path> |
gallery/index.yaml |
Gallery index to dedup against. Read only, unless -apply is passed. |
-only <a,b,c> |
(all) | Comma-separated full repo names (mudler/Foo-APEX-GGUF) to restrict generation to. A name that matches nothing is reported as a warning, since it is a typo rather than an empty result. |
-out <path> |
(none) | Write the entries to add to this file. Nothing is written to the gallery. |
-apply |
false |
Splice the variants into -index and append the new entries to it. |
-verify <path> |
(none) | Verify a gallery index and exit. Ignores every other flag. |
Either -out or -apply is required, otherwise the run has nothing to do.
-apply splices variant lines into existing entries and appends new ones. It
never re-serialises the index: it is roughly 40,000 lines, and a YAML round trip
would reflow the whole file, drop the anchors and merge keys the gallery relies
on, and produce a diff nobody can review. On the three-family sample the splice
is 24 added lines across 3 hunks with zero deletions.
Discovery is by filename suffix, never by repo name
Builds come from the files a repo actually publishes. A filename is never
constructed from a repo name, because the two disagree:
mudler/gemma-4-26B-A4B-it-APEX-GGUF ships gemma-4-26B-A4B-APEX-*.gguf, and
five other repos likewise drop a suffix (-it, -2603) or a vendor prefix
(NVIDIA-) that the repo name carries. Composing a URL from the repo name would
produce a 404 for every one of them, and the 404 would only surface after the
entry shipped.
The quality ladder is matched on the trailing tier marker, -(I-)?(Quality| Balanced|Compact|Mini|Nano).gguf. The I- prefix marks the imatrix ladder. The
imatrix ladder is emitted when it is non-empty and the plain ladder is used only
as a fallback, because two of the 45 repos publish no imatrix tiers at all and
must still contribute. Eleven repos carry a fifth I-Nano rung, so nothing
assumes a fixed number of rungs.
Every run prints, per repo, the counts that discovery accounted for. If the
number of classified files is short of the number of .gguf files the repo
publishes, the shortfall is printed as UNCLASSIFIED. That check is a set
difference on counts rather than a second pass over filenames: a second matcher
would duplicate the tier regex and the two copies would drift. The failure it
catches is quiet. A publishing-script typo that breaks every imatrix filename in
a repo does not produce a short ladder; it makes the imatrix ladder empty, and
the fallback then downgrades the whole family to the plain ladder with nothing
said. A downstream HTTP check cannot catch it either, because it validates the
URLs that were emitted, and an undiscovered tier emits none.
The same reasoning applies to UNACCOUNTED QUANT, printed when the unsloth
counterpart demonstrably publishes a wanted quant that produced no build. It is
reported at discovery time because a dropped quant leaves no trace at all in the
finished gallery file.
sha256 always comes from the API
Every file stanza takes its sha256 from the HuggingFace models API
(lfs.sha256). A GGUF the API describes without one is a fatal error for that
family: the repo is reported by name and the run ends non-zero. It is never
substituted from another field, because that is exactly how a Xet hash ends up
masquerading as a content hash.
The dflash / mtp tagging rule
An entry is tagged dflash or mtp if and only if it configures the
matching spec_type:draft-<feature>. Variant ranking reads tags and nothing
else, so a tag that does not match the configuration either promotes a build
that is no faster or hides one that genuinely is.
A repo name is not configuration. mudler/Qwen3.6-35B-A3B-APEX-MTP-GGUF ships
weights that carry MTP heads; an entry that does not enable them is not an MTP
entry and is not tagged as one.
A generated hub inherits the tags of the build it carries as its payload, rather
than rebuilding them from the base set, so a hub whose payload configures a
spec_type stays tagged consistently with the overrides copied alongside it.
Reuse reporting: two categories, not one
Generated entries are deduped against the gallery and against the batch itself. The run prints the result under two separate headings, because the two cases are not equivalent:
- URI MATCHES mean the gallery, or an earlier entry in this batch, already ships exactly these weights. Pointing the hub at the existing entry is correct and needs no thought.
- NAME COLLISIONS mean an entry already owns the name but holds different weights. Referencing it would point the hub at a build other than the one generated. Every one of these must be inspected by hand.
The run then prints HUBS SPLICED, listing every reference that will be added to
an entry the gallery already ships along with the line it will be added at, and
HUBS CREATED for the families that get a new hub. The splices are the part a
review has to read closely, because they modify entries somebody else wrote.
Hubs are deliberately kept out of the merge. A new hub carries the family's top rung as its own payload, so URI dedup would fold the hub into that rung and the family would lose the very entry point this command exists to create.
Workflow: sample first, then the full set
Never run the full generation straight into the gallery. Generate a small, deliberately awkward sample, have it reviewed, then run the rest.
# 1. Sample three families that between them cover the awkward shapes:
# a standard four-rung repo, one with the extra I-Nano rung AND a file stem
# that differs from its repo name, and one whose unsloth counterpart shards
# its quants across subdirectories.
go run ./.github/ci/apexentries \
-index gallery/index.yaml \
-only mudler/Qwen3.6-35B-A3B-APEX-GGUF,mudler/gemma-4-26B-A4B-it-APEX-GGUF,mudler/Step-3.7-Flash-APEX-GGUF \
-out /tmp/sample.yaml
# 2. Verify the sample against the gallery it would join, splices included. Apply
# to a COPY, never to the real index, and check that the diff is only the
# intended variant lines. Compare the verifier output to the gallery's own
# baseline: what matters is that the sample adds no new problem, not that the
# total is zero.
cp gallery/index.yaml /tmp/index-copy.yaml
go run ./.github/ci/apexentries -index /tmp/index-copy.yaml -only <same list> -apply
diff -u gallery/index.yaml /tmp/index-copy.yaml # expect zero deletions
go run ./.github/ci/apexentries -verify gallery/index.yaml > /tmp/baseline.log 2>&1
go run ./.github/ci/apexentries -verify /tmp/index-copy.yaml > /tmp/spliced.log 2>&1
diff /tmp/baseline.log /tmp/spliced.log
# 3. Have a human review /tmp/sample.yaml and every reported name collision.
# 4. Only then, the full set.
go run ./.github/ci/apexentries -index gallery/index.yaml -apply
Tests
go test ./.github/ci/apexentries/
The shared line editor has its own package:
go test ./.github/ci/galleryedit/
.github/ci/ is invisible to go list ./..., so these specs are not covered by
make lint or the repository test run. .github/workflows/ci-tools-tests.yaml
names the package explicitly; keep that workflow in step with any package added
under .github/ci/.