Compare commits

..

2 Commits

Author SHA1 Message Date
Ettore Di Giacinto
f7c88770d3 fix(distributed): count staging verification as progress, not as a stall
Testing the progress-based cold-load deadline on the live cluster surfaced a
false positive. The stall window observed UPLOAD bytes only, but the staging
path has a phase that does real work while moving zero upload bytes: the
resumable-upload verify phase.

When a shard is already present on the worker from an earlier attempt, the
frontend HEADs it, hashes the local copy to confirm it matches, and skips the
transfer. Staging a 70 GB model with 56 GB already staged:

  17:27:34 INFO Upload skipped (file already exists with matching hash) ...
  17:28:20 INFO Upload skipped (file already exists with matching hash) ...
  17:29:07 INFO Upload skipped (file already exists with matching hash) ...
  ... six-plus consecutive minutes, no bytes uploaded at all

~45s per skipped ~4 GB shard. That is correct and desirable - it is what makes
resume work - but it was indistinguishable from a stall. At 45s per shard it
sits inside the 5m window, so the run in flight was fine; the problem is the
600 GB scale this machinery exists to enable, where one shard can plausibly hash
for longer than the window. The guard would then fire during verification of a
transfer that is working perfectly.

Verified mechanism: probeExisting() HEADs the worker and then calls
downloader.CalculateSHA(). The staging progress callback is only consulted
inside doUpload(), which the skip path never reaches, so observeLoadProgress was
called zero times for the whole verify phase.

Verification exposed a second, worse bug in the same path: CalculateSHA consults
no context at all. An expired cold load kept hashing to completion, compared the
hashes, and returned success - reporting a file as staged on a dead load. The
failure only surfaced on the NEXT file, whose HEAD died immediately. That is
exactly the shape of the red test here, which fails on shard 3.

Fix: hash in 1 MiB chunks via hashFileWithActivity(), ticking the cold-load
deadline per chunk and checking ctx per chunk. A successful HEAD also counts,
since a 200 with a content hash proves the worker is serving right now.

Counting hash progress does not make a dead transfer look alive: hashing is
bounded, terminating work proportional to file size, in probeExisting it runs
only after a HEAD proved the worker was up, and the 24h absolute cap still
bounds the whole hold. The alternative of simply widening the window was
rejected - it would reintroduce the size cliff this work removes.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
2026-07-21 18:00:17 +00:00
Ettore Di Giacinto
36f20f72f8 fix(distributed): make the cold-load hold scale with progress, not wall-clock
A 70 GB video checkpoint (longcat-video-avatar-1.5) could not be loaded on a
distributed cluster. The request failed with HTTP 500 after 1499.98s - exactly
the 25m00s cold-load ceiling - while staging was demonstrably healthy: 26 of 57
files and 39 GB transferred at a sustained ~26 MB/s, zero errors, no stalls. It
was not wedged, it was killed by a timer.

ModelLoadCeilingFor covers node selection, backend install, file staging and the
remote LoadModel. Install and load carry their own budgets; staging was covered
only by a FIXED 5-minute margin. But staging time is bytes over bandwidth, not a
constant: 70 GB at 26 MB/s needs ~45m against a 25m ceiling, so the failure is
deterministic for any sufficiently large model rather than a flake. Simply
raising the constant moves the cliff to the next model size - the deployment
target here is checkpoints of 600 GB and beyond.

The ceiling's real purpose is that "a wedged worker can never pin the lock
indefinitely". Progress, not elapsed time, is what distinguishes a wedged worker
from a large one. The hold is now a deadline that extends whenever the transfer
reports bytes and expires a 5-minute stall window after they stop:

- A large model transferring fine continues, for hours if needed.
- A worker that died mid-transfer still fails within the stall window.

Progress is observed at byte level on the transfer itself, via the existing
staging progress callback. Per-file completion would be too coarse - a single
600 GB shard would be indistinguishable from a stall for hours. The observation
point is back-pressured by the socket, so it reflects the network rather than
local disk reads. Observation is coarsened to one timer touch per stall/20 so
the per-read callback stays cheap.

The base budget (unchanged, and still derived from the install and load
timeouts) continues to cover the steps that report no progress, so
LOCALAI_NATS_MODEL_LOAD_TIMEOUT keeps working exactly as before. An absolute
cap of 24h bounds the hold even while progress keeps arriving, so a peer
trickling bytes forever cannot pin the advisory lock; 600 GB at the measured
26 MB/s is ~6.5h, so the cap sits far above any legitimate transfer.

Also fixes the incoherent layering the same error exposed: the resumable upload
carried a 1h retry budget nested inside the 25m ceiling, so the inner budget was
unreachable and the message still blamed it ("failed after 1 attempts within
1h0m0s budget") while the 25m parent was the actual killer. The upload now
adopts the caller's deadline when there is one, and applies its fixed budget
only when nothing above bounded it - which also stops a fixed 1h from
reintroducing the size cliff under the now-extendable parent.

This is the successor to #10968, where a hardcoded 5-minute LoadModel gRPC
timeout was replaced by this derived ceiling. Fixing the inner timeout exposed
the outer ceiling as the new binding constraint.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
2026-07-21 12:00:55 +00:00
150 changed files with 496 additions and 15033 deletions

12
.github/bump_deps.sh vendored
View File

@@ -1,8 +1,5 @@
#!/bin/bash
set -xe
source "$(dirname "${BASH_SOURCE[0]}")/gh_curl.sh"
REPO=$1
BRANCH=$2
VAR=$3
@@ -12,11 +9,10 @@ if [ -z "$FILE" ]; then
FILE="Makefile"
fi
# gh_curl follows redirects so a renamed/transferred upstream repo (GitHub
# answers 301) still resolves, and fails on HTTP errors rather than letting an
# error page reach sed below. `|| true` keeps a failed lookup from aborting the
# script at exit 22 with no context — the SHA guard below reports it instead.
LAST_COMMIT=$(gh_curl -H "Accept: application/vnd.github.VERSION.sha" "https://api.github.com/repos/$REPO/commits/$BRANCH" || true)
# -L so a renamed/transferred upstream repo (GitHub answers 301) still
# resolves instead of handing us the redirect body, and -f so an HTTP error
# aborts the run rather than letting an error page reach sed below.
LAST_COMMIT=$(curl -sfL -H "Accept: application/vnd.github.VERSION.sha" "https://api.github.com/repos/$REPO/commits/$BRANCH")
# Guard the sed input: anything that is not a bare 40-hex SHA (an API error
# body, an empty response) would otherwise be spliced into the Makefile pin —

13
.github/bump_docs.sh vendored
View File

@@ -1,18 +1,7 @@
#!/bin/bash
set -xe
source "$(dirname "${BASH_SOURCE[0]}")/gh_curl.sh"
REPO=$1
LATEST_TAG=$(gh_curl -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/releases/latest" | jq -r '.tag_name')
# jq prints the string "null" for a missing key, so a throttled or otherwise
# unexpected API response would otherwise be published as the docs version.
if [ -z "$LATEST_TAG" ] || [ "$LATEST_TAG" = "null" ]; then
echo "Refusing to bump docs version: could not resolve the latest release tag for $REPO." >&2
exit 1
fi
LATEST_TAG=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | jq -r '.tag_name')
cat <<< $(jq ".version = \"$LATEST_TAG\"" docs/data/version.json) > docs/data/version.json

View File

@@ -11,9 +11,6 @@
# darwin build can only use the exact vLLM version vllm-metal supports, so it may
# lag the Linux pin (requirements-cublas13-after.txt) until vllm-metal catches up.
set -xe
source "$(dirname "${BASH_SOURCE[0]}")/gh_curl.sh"
REPO=$1 # vllm-project/vllm-metal
FILE=$2 # backend/python/vllm/install.sh
VAR=$3 # VLLM_METAL_VERSION (used for the workflow's output file names)
@@ -25,12 +22,12 @@ fi
# vllm-metal ships frequent dev releases, all flagged as non-prerelease, so
# /releases/latest returns the newest one (with its cp312 wheel asset).
LATEST_TAG=$(gh_curl -H "Accept: application/vnd.github+json" \
LATEST_TAG=$(curl -sS -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/releases/latest" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])")
# The coupled vLLM source version lives in vllm-metal's installer at that tag.
NEW_VLLM_VERSION=$(gh_curl \
NEW_VLLM_VERSION=$(curl -fsSL \
"https://raw.githubusercontent.com/$REPO/$LATEST_TAG/install.sh" \
| grep -oE 'vllm_v="[0-9]+\.[0-9]+\.[0-9]+"' | head -1 | cut -d'"' -f2)

View File

@@ -9,9 +9,6 @@
# vars in Makefiles; this script handles the two-value rewrite specific to the
# vLLM requirements file.
set -xe
source "$(dirname "${BASH_SOURCE[0]}")/gh_curl.sh"
REPO=$1 # vllm-project/vllm
FILE=$2 # backend/python/vllm/requirements-cublas13-after.txt
VAR=$3 # VLLM_VERSION (used for output file names so the workflow can read them)
@@ -22,7 +19,7 @@ if [ -z "$FILE" ] || [ -z "$REPO" ] || [ -z "$VAR" ]; then
fi
# /releases/latest returns the most recent non-prerelease tag.
LATEST_TAG=$(gh_curl -H "Accept: application/vnd.github+json" \
LATEST_TAG=$(curl -sS -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/releases/latest" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])")

View File

@@ -1,194 +0,0 @@
# 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 the
`variantproposals` job 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.
```bash
# 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
```bash
go test ./.github/ci/apexentries/
```
The shared line editor has its own package:
```bash
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/`.

View File

@@ -1,70 +0,0 @@
package main
import (
"regexp"
"strings"
)
// tierRE matches the tier marker APEX repos put at the end of a weight
// filename. Discovery is by suffix because the stem is not predictable from
// the repo name: six of the 45 repos drop a suffix ("-it", "-2603") or a
// vendor prefix ("NVIDIA-") that the repo name carries.
var tierRE = regexp.MustCompile(`-(I-)?(Quality|Balanced|Compact|Mini|Nano)\.gguf$`)
// fullPrecisionRE matches the unquantized source weights an APEX repo publishes
// alongside its ladder, flat (-F16.gguf) or sharded across a numbered set
// (-F16-00001-of-00010.gguf). bf16 is accepted because some repos publish that
// instead, and the match is case-insensitive because the casing varies between
// publishing scripts.
//
// These are deliberately not tiers: they are the weights the ladder is quantized
// FROM, and generation is scoped to the ladder itself.
var fullPrecisionRE = regexp.MustCompile(`(?i)-b?f16(-\d{5}-of-\d{5})?\.gguf$`)
// IsFullPrecision reports whether a weight filename is an unquantized source.
func IsFullPrecision(name string) bool {
return fullPrecisionRE.MatchString(name)
}
// Tier is one discovered build of an APEX repo.
type Tier struct {
Label string
File GGUFFile
}
// DiscoverAPEXTiers splits a repo's weight files into the imatrix ladder and
// the plain ladder. mmproj files are never tiers.
func DiscoverAPEXTiers(files []GGUFFile) (imatrix, plain []Tier) {
for _, f := range files {
if strings.HasPrefix(f.Name, "mmproj") {
continue
}
m := tierRE.FindStringSubmatch(f.Name)
if m == nil {
continue
}
if m[1] != "" {
imatrix = append(imatrix, Tier{Label: "I-" + m[2], File: f})
continue
}
plain = append(plain, Tier{Label: m[2], File: f})
}
return imatrix, plain
}
// DiscoverMMProj returns the repo's projector file, if it publishes one. The
// name varies across repos (mmproj.gguf, mmproj-F16.gguf,
// mmproj-step3.7-flash-f16.gguf), so match the prefix rather than a fixed name.
func DiscoverMMProj(files []GGUFFile) (GGUFFile, bool) {
for _, f := range files {
if strings.HasPrefix(f.Name, "mmproj") {
return f, true
}
}
return GGUFFile{}, false
}
// FileStem returns a tier's filename with its tier suffix removed.
func FileStem(t Tier) string {
return tierRE.ReplaceAllString(t.File.Name, "")
}

View File

@@ -1,68 +0,0 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("DiscoverAPEXTiers", func() {
It("finds tiers regardless of how the stem relates to the repo name", func() {
// This repo is mudler/gemma-4-26B-A4B-it-APEX-GGUF but its files drop "-it".
files := []GGUFFile{
{Name: "gemma-4-26B-A4B-APEX-I-Quality.gguf", SHA256: "a"},
{Name: "gemma-4-26B-A4B-APEX-I-Nano.gguf", SHA256: "b"},
{Name: "gemma-4-26B-A4B-APEX-Quality.gguf", SHA256: "c"},
{Name: "mmproj-F16.gguf", SHA256: "d"},
}
imatrix, plain := DiscoverAPEXTiers(files)
Expect(labels(imatrix)).To(ConsistOf("I-Quality", "I-Nano"))
Expect(labels(plain)).To(ConsistOf("Quality"))
})
It("excludes mmproj from the tier list", func() {
files := []GGUFFile{{Name: "mmproj.gguf", SHA256: "d"}}
imatrix, plain := DiscoverAPEXTiers(files)
Expect(imatrix).To(BeEmpty())
Expect(plain).To(BeEmpty())
})
})
var _ = Describe("DiscoverMMProj", func() {
It("finds an mmproj whatever its suffix", func() {
files := []GGUFFile{
{Name: "Model-APEX-I-Mini.gguf", SHA256: "a"},
{Name: "mmproj-step3.7-flash-f16.gguf", SHA256: "b"},
}
got, ok := DiscoverMMProj(files)
Expect(ok).To(BeTrue())
Expect(got.Name).To(Equal("mmproj-step3.7-flash-f16.gguf"))
})
It("reports absence when the repo ships none", func() {
_, ok := DiscoverMMProj([]GGUFFile{{Name: "Model-APEX-Quality.gguf", SHA256: "a"}})
Expect(ok).To(BeFalse())
})
})
var _ = Describe("FileStem", func() {
It("strips the tier suffix", func() {
t := Tier{Label: "I-Quality", File: GGUFFile{Name: "gemma-4-26B-A4B-APEX-I-Quality.gguf"}}
Expect(FileStem(t)).To(Equal("gemma-4-26B-A4B-APEX"))
})
})
func labels(ts []Tier) []string {
out := make([]string, 0, len(ts))
for _, t := range ts {
out = append(out, t.Label)
}
return out
}

View File

@@ -1,130 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/mudler/LocalAI/pkg/httpclient"
)
// ErrNoSHA256 marks a GGUF the HuggingFace API describes without an
// lfs.sha256. Emitting an entry without a hash would ship an unverifiable
// download, and guessing one from another field is how a Xet hash ends up
// masquerading as a content hash, so this is fatal rather than skippable.
var ErrNoSHA256 = errors.New("gguf file has no lfs.sha256")
// GGUFFile is one .gguf sibling of a HuggingFace repo.
type GGUFFile struct {
Name string
Size int64
SHA256 string
}
type apiSibling struct {
RFilename string `json:"rfilename"`
Size int64 `json:"size"`
LFS *struct {
SHA256 string `json:"sha256"`
} `json:"lfs"`
}
type apiModel struct {
Siblings []apiSibling `json:"siblings"`
}
// ParseRepoFiles returns every .gguf sibling described by a models API body.
func ParseRepoFiles(body []byte) ([]GGUFFile, error) {
var m apiModel
if err := json.Unmarshal(body, &m); err != nil {
return nil, fmt.Errorf("decoding model response: %w", err)
}
var out []GGUFFile
for _, s := range m.Siblings {
if !strings.HasSuffix(s.RFilename, ".gguf") {
continue
}
if s.LFS == nil || s.LFS.SHA256 == "" {
return nil, fmt.Errorf("%s: %w", s.RFilename, ErrNoSHA256)
}
out = append(out, GGUFFile{Name: s.RFilename, Size: s.Size, SHA256: s.LFS.SHA256})
}
return out, nil
}
// FetchOptionalRepoFiles asks the models API for a repo the caller can do
// without, and reports separately whether the repo was merely unreadable.
//
// HuggingFace answers 401 Unauthorized, not 404, for a repo that does not exist
// when the request carries no credentials. Without a token there is therefore no
// way to tell "this repo was never published" from "this repo is private", so an
// optional probe has to treat 401 and 403 exactly like 404: whatever the reason,
// there is nothing here for us to read, so there is no counterpart.
//
// The second return value exists because that collapse is lossy in one
// direction: 401/403 can also mean a real, gated repo whose quants we would
// genuinely want. The caller reports those repos so a silently dropped
// counterpart is visible to a human rather than invisible.
func FetchOptionalRepoFiles(client *http.Client, repo string) ([]GGUFFile, bool, error) {
files, status, err := fetchRepoFiles(client, repo)
if err != nil && (status == http.StatusUnauthorized || status == http.StatusForbidden) {
return nil, true, nil
}
return files, false, err
}
// FetchRepoFiles asks the models API for one repo. A 404 yields (nil, nil) so
// that probing for an optional counterpart repo is not an error. Every other
// non-200, 401 and 403 included, is an error: for a repo the run REQUIRES there
// is no benign reading of "we cannot see it".
func FetchRepoFiles(client *http.Client, repo string) ([]GGUFFile, error) {
files, _, err := fetchRepoFiles(client, repo)
return files, err
}
// fetchRepoFiles does the request and returns the HTTP status alongside the
// result, so the optional and required callers can apply different policies to
// the same response without duplicating the request.
func fetchRepoFiles(client *http.Client, repo string) ([]GGUFFile, int, error) {
url := fmt.Sprintf("https://huggingface.co/api/models/%s?blobs=true", repo)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("User-Agent", "localai-apexentries/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, resp.StatusCode, nil
}
if resp.StatusCode != http.StatusOK {
return nil, resp.StatusCode, fmt.Errorf("%s: unexpected status %d", repo, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
files, err := ParseRepoFiles(body)
return files, resp.StatusCode, err
}
// newHTTPClient builds the client used against the HuggingFace API. It goes
// through pkg/httpclient rather than a bare &http.Client{} because the std
// client follows redirects and forwards custom credential headers to the
// redirect target on a cross-host hop (GHSA-3mj3-57v2-4636). This caller sends
// only a User-Agent today, but it talks to an external API that could start
// redirecting, and an HF_TOKEN header here later would then leak.
func newHTTPClient() *http.Client {
return httpclient.NewWithTimeout(60 * time.Second)
}

View File

@@ -1,142 +0,0 @@
package main
import (
"bytes"
"io"
"net/http"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestApexEntries(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "apexentries")
}
// stubTransport answers every request with one canned status and body, so the
// status handling of the fetchers can be exercised without reaching the real
// HuggingFace API.
type stubTransport struct {
status int
body string
}
func (t stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: t.status,
Body: io.NopCloser(bytes.NewBufferString(t.body)),
Header: make(http.Header),
Request: req,
}, nil
}
func stubClient(status int, body string) *http.Client {
return &http.Client{Transport: stubTransport{status: status, body: body}}
}
const oneGGUFBody = `{"siblings":[{"rfilename":"Model-APEX-I-Quality.gguf","size":10,"lfs":{"sha256":"aa","size":10}}]}`
var _ = Describe("FetchOptionalRepoFiles", func() {
// HuggingFace answers 401 rather than 404 for a repo that does not exist
// when the client carries no credentials, so an optional probe cannot tell
// "absent" from "unauthorized" and must treat both as "no counterpart".
It("treats a 401 as an absent repo and flags it as unavailable", func() {
files, unavailable, err := FetchOptionalRepoFiles(stubClient(http.StatusUnauthorized, ""), "unsloth/Nope-GGUF")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(BeEmpty())
Expect(unavailable).To(BeTrue())
})
It("treats a 403 as an absent repo and flags it as unavailable", func() {
files, unavailable, err := FetchOptionalRepoFiles(stubClient(http.StatusForbidden, ""), "unsloth/Gated-GGUF")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(BeEmpty())
Expect(unavailable).To(BeTrue())
})
// A clean 404 is an unambiguous absence, so it must NOT be reported as
// unavailable: the whole point of the flag is to separate the ambiguous
// case a human may need to look at from the settled one.
It("treats a 404 as an absent repo without flagging it as unavailable", func() {
files, unavailable, err := FetchOptionalRepoFiles(stubClient(http.StatusNotFound, ""), "unsloth/Nope-GGUF")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(BeEmpty())
Expect(unavailable).To(BeFalse())
})
It("parses a 200 body as usual", func() {
files, unavailable, err := FetchOptionalRepoFiles(stubClient(http.StatusOK, oneGGUFBody), "unsloth/Real-GGUF")
Expect(err).ToNot(HaveOccurred())
Expect(unavailable).To(BeFalse())
Expect(files).To(HaveLen(1))
Expect(files[0].Name).To(Equal("Model-APEX-I-Quality.gguf"))
Expect(files[0].SHA256).To(Equal("aa"))
})
// Tolerating 401/403 must not widen into tolerating everything: a 500 is a
// broken API, not evidence about whether the repo exists.
It("still errors on a 500", func() {
_, _, err := FetchOptionalRepoFiles(stubClient(http.StatusInternalServerError, ""), "unsloth/Real-GGUF")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unexpected status 500"))
})
})
var _ = Describe("FetchRepoFiles", func() {
// The APEX repo itself is not optional. A 401 there means the repo the run
// was asked to publish cannot be read, which is a real failure and must not
// be quietly downgraded to "no files".
It("errors on a 401 for a required repo", func() {
_, err := FetchRepoFiles(stubClient(http.StatusUnauthorized, ""), "mudler/Model-APEX-GGUF")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unexpected status 401"))
})
It("errors on a 403 for a required repo", func() {
_, err := FetchRepoFiles(stubClient(http.StatusForbidden, ""), "mudler/Model-APEX-GGUF")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unexpected status 403"))
})
It("still treats a 404 as an absent repo", func() {
files, err := FetchRepoFiles(stubClient(http.StatusNotFound, ""), "mudler/Model-APEX-GGUF")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(BeEmpty())
})
})
var _ = Describe("ParseRepoFiles", func() {
It("returns gguf siblings with their lfs sha256", func() {
body := []byte(`{"siblings":[
{"rfilename":"Model-APEX-I-Quality.gguf","size":10,"lfs":{"sha256":"aa","size":10}},
{"rfilename":"README.md"},
{"rfilename":"mmproj.gguf","size":5,"lfs":{"sha256":"bb","size":5}}
]}`)
files, err := ParseRepoFiles(body)
Expect(err).ToNot(HaveOccurred())
Expect(files).To(HaveLen(2))
Expect(files[0].Name).To(Equal("Model-APEX-I-Quality.gguf"))
Expect(files[0].SHA256).To(Equal("aa"))
Expect(files[1].Name).To(Equal("mmproj.gguf"))
})
It("reports a gguf that carries no lfs sha256", func() {
body := []byte(`{"siblings":[{"rfilename":"mmproj.gguf","size":5}]}`)
_, err := ParseRepoFiles(body)
Expect(err).To(MatchError(ErrNoSHA256))
})
})

View File

@@ -1,143 +0,0 @@
package main
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
"github.com/mudler/LocalAI/.github/ci/galleryedit"
)
// IndexText is the gallery index seen as text: the entries it declares plus the
// exact lines each one occupies, which is what splicing a variants block into an
// entry the gallery already ships requires.
//
// It is a second, narrower read of the same file LoadExisting parses. The two
// answer different questions: LoadExisting answers "do these weights already
// exist anywhere", this one answers "where in the file does this entry live".
type IndexText struct {
Lines []string
Entries []*indexEntry
byName map[string]*indexEntry
}
// indexEntry is one entry of the index: its name, the variants it already
// declares, and its coordinates in the file.
type indexEntry struct {
Name string `yaml:"name"`
Variants []VariantRef `yaml:"variants"`
Pos galleryedit.Entry `yaml:"-"`
}
// LoadIndexText reads the gallery index for editing.
func LoadIndexText(path string) (*IndexText, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseIndexText(string(raw))
}
// ParseIndexText pairs the decoded entries with the top level list items the
// text actually contains.
//
// If the two views disagree on how many entries there are then every line number
// a splice would compute is suspect, and the failure mode is writing a variants
// block into the wrong model. The parse refuses instead.
func ParseIndexText(text string) (*IndexText, error) {
var entries []*indexEntry
if err := yaml.Unmarshal([]byte(text), &entries); err != nil {
return nil, fmt.Errorf("decoding gallery index: %w", err)
}
lines, starts := galleryedit.Scan(text)
if len(starts) != len(entries) {
return nil, fmt.Errorf("gallery index has %d decoded entries but %d top level list items; refusing to edit by line number",
len(entries), len(starts))
}
ix := &IndexText{Lines: lines, Entries: entries, byName: map[string]*indexEntry{}}
for i, e := range entries {
if e == nil {
return nil, fmt.Errorf("gallery index list item %d is empty; refusing to edit by line number", i)
}
end := len(lines)
if i+1 < len(starts) {
end = starts[i+1]
}
e.Pos = galleryedit.Entry{Name: e.Name, StartLine: starts[i], EndLine: end}
// First occurrence wins, matching the gallery's own resolution.
key := strings.ToLower(e.Name)
if _, seen := ix.byName[key]; !seen {
ix.byName[key] = e
}
}
return ix, nil
}
// Find looks an entry up by name, case insensitively.
func (ix *IndexText) Find(name string) *indexEntry {
return ix.byName[strings.ToLower(name)]
}
// ResolveHub returns the gallery name of a family's hub and whether the gallery
// already ships an entry under it.
//
// The hub 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: 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 invisible.
//
// Both candidates are tried for the same reason CounterpartCandidates tries
// both. The repo name and the published file stem disagree for several of these
// repos, and either one may be what the base entry was named after.
func ResolveHub(ix *IndexText, repoBase, stem string) (name string, exists bool) {
candidates := CounterpartCandidates(repoBase, stem)
for _, c := range candidates {
if n := slug(c); ix.Find(n) != nil {
return n, true
}
}
// Nothing matched, so the family needs a hub of its own under the repo
// derived name, which is the more reliable of the two.
return slug(candidates[0]), false
}
// HubLabel is the human-cased base model name, for prose rather than lookup.
func HubLabel(repoBase, stem string) string {
return CounterpartCandidates(repoBase, stem)[0]
}
// filterVariants drops the references a hub must not carry: itself, and anything
// it already lists.
//
// The self reference is not merely redundant. A hub that names itself makes the
// verifier resolve the reference back to the hub, see that the hub declares
// variants, and report a variant that declares variants of its own. It arises
// for real rather than in theory: an unsloth rung whose weights the gallery
// already ships under the base model name resolves, through Merge, straight back
// to the hub that is about to reference it.
func filterVariants(hub string, already []VariantRef, want []string) []string {
seen := map[string]bool{strings.ToLower(hub): true}
for _, v := range already {
seen[strings.ToLower(v.Model)] = true
}
var out []string
for _, w := range want {
key := strings.ToLower(w)
if seen[key] {
continue
}
seen[key] = true
out = append(out, w)
}
return out
}

View File

@@ -1,795 +0,0 @@
// 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), "_", "-")
}

View File

@@ -1,344 +0,0 @@
package main
import (
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/.github/ci/galleryedit"
)
func mustIndex(text string) *IndexText {
ix, err := ParseIndexText(text)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return ix
}
// buildOf renders a realistic child so the specs exercise the payload a hub
// actually inherits rather than a bare name.
func buildOf(name, repo, file string, rank int) childBuild {
return childBuild{
rank: rank,
entry: RenderChild(ChildInput{
Name: name,
Repo: repo,
Template: entryTemplate,
Weights: []GGUFFile{{Name: file, SHA256: "aa"}},
BaseTags: baseTags,
}),
}
}
var _ = Describe("ResolveHub", func() {
It("picks the base model name over the APEX name, even when both are in the gallery", func() {
// The hub is the entry a user searches for. If the *-apex entry were
// chosen the family would be gathered under a name nobody looks up, and
// the base entry would go on advertising only its own build.
ix := mustIndex("- name: qwen3.6-35b-a3b\n url: u\n- name: qwen3.6-35b-a3b-apex\n url: u\n")
name, exists := ResolveHub(ix, "Qwen3.6-35B-A3B-APEX", "Qwen3.6-35B-A3B-APEX")
Expect(name).To(Equal("qwen3.6-35b-a3b"))
Expect(exists).To(BeTrue())
})
It("falls back to the stem-derived candidate when the repo-derived one is absent", func() {
// gemma's repo says "-it" and its published files do not, so only one of
// the two candidates can match whatever the base entry was named after.
ix := mustIndex("- name: gemma-4-26b-a4b\n url: u\n")
name, exists := ResolveHub(ix, "gemma-4-26B-A4B-it-APEX", "gemma-4-26B-A4B-APEX")
Expect(name).To(Equal("gemma-4-26b-a4b"))
Expect(exists).To(BeTrue())
})
It("reports the base name as absent rather than settling for the APEX entry", func() {
ix := mustIndex("- name: qwen3.5-35b-a3b-apex\n url: u\n")
name, exists := ResolveHub(ix, "Qwen3.5-35B-A3B-APEX", "Qwen3.5-35B-A3B-APEX")
Expect(name).To(Equal("qwen3.5-35b-a3b"))
Expect(exists).To(BeFalse())
})
It("strips the MTP and TQ markers as well as APEX", func() {
ix := mustIndex("- name: qwen3.6-35b-a3b\n url: u\n")
name, exists := ResolveHub(ix, "Qwen3.6-35B-A3B-APEX-MTP", "Qwen3.6-35B-A3B-APEX-MTP")
Expect(name).To(Equal("qwen3.6-35b-a3b"))
Expect(exists).To(BeTrue())
})
})
var _ = Describe("planHubs", func() {
noReuse := map[string]string{}
allAdded := func(names ...string) map[string]bool {
out := map[string]bool{}
for _, n := range names {
out[n] = true
}
return out
}
It("splices into the existing base entry instead of emitting an *-apex parent", func() {
ix := mustIndex("- name: step-3.7-flash\n url: u\n- name: other\n url: u\n")
fams := []family{{
repo: "mudler/Step-3.7-Flash-APEX-GGUF",
repoBase: "Step-3.7-Flash-APEX",
stem: "Step-3.7-Flash-APEX",
children: []childBuild{buildOf("step-3.7-flash-apex-i-quality", "mudler/Step-3.7-Flash-APEX-GGUF", "a.gguf", 0)},
}}
inserts, newHubs, err := planHubs(fams, ix, noReuse, allAdded("step-3.7-flash-apex-i-quality"))
Expect(err).ToNot(HaveOccurred())
Expect(newHubs).To(BeEmpty())
Expect(inserts).To(HaveLen(1))
Expect(inserts[0].Entry.Name).To(Equal("step-3.7-flash"))
Expect(inserts[0].Variants).To(Equal([]string{"step-3.7-flash-apex-i-quality"}))
})
It("merges into an entry that already declares variants, without repeating one", func() {
// The gallery's qwen3.6-35b-a3b already lists its APEX build. Re-adding it
// would put a duplicate key's worth of noise in the diff and a duplicate
// reference in the entry.
ix := mustIndex("- name: qwen3.6-35b-a3b\n variants:\n - model: qwen3.6-35b-a3b-apex\n url: u\n" +
"- name: qwen3.6-35b-a3b-apex\n url: u\n")
fams := []family{{
repo: "mudler/Qwen3.6-35B-A3B-APEX-GGUF",
repoBase: "Qwen3.6-35B-A3B-APEX",
stem: "Qwen3.6-35B-A3B-APEX",
children: []childBuild{buildOf("qwen3.6-35b-a3b-apex-i-quality", "mudler/Qwen3.6-35B-A3B-APEX-GGUF", "a.gguf", 0)},
}}
inserts, newHubs, err := planHubs(fams, ix, noReuse, allAdded("qwen3.6-35b-a3b-apex-i-quality"))
Expect(err).ToNot(HaveOccurred())
Expect(newHubs).To(BeEmpty())
Expect(inserts[0].Variants).To(Equal([]string{"qwen3.6-35b-a3b-apex-i-quality"}))
out, err := galleryedit.Apply(ix.Lines, inserts)
Expect(err).ToNot(HaveOccurred())
Expect(strings.Count(strings.Join(out, "\n"), "variants:")).To(Equal(1))
Expect(out).To(HaveLen(len(ix.Lines) + 1))
})
It("never lets the hub reference itself", func() {
// An unsloth rung whose weights the gallery already ships under the base
// name resolves, through Merge, straight back to the hub. The verifier
// reads a self reference as a variant that declares variants of its own.
ix := mustIndex("- name: step-3.7-flash\n url: u\n")
fams := []family{{
repo: "mudler/Step-3.7-Flash-APEX-GGUF",
repoBase: "Step-3.7-Flash-APEX",
stem: "Step-3.7-Flash-APEX",
children: []childBuild{buildOf("step-3.7-flash-ud-q4-k-m", "unsloth/Step-3.7-Flash-GGUF", "a.gguf", 100)},
}}
inserts, _, err := planHubs(fams, ix, map[string]string{"step-3.7-flash-ud-q4-k-m": "step-3.7-flash"}, map[string]bool{})
Expect(err).ToNot(HaveOccurred())
Expect(inserts).To(BeEmpty())
})
It("emits a hub named for the base model when the gallery has none", func() {
ix := mustIndex("- name: qwen3.5-35b-a3b-apex\n url: u\n")
fams := []family{{
repo: "mudler/Qwen3.5-35B-A3B-APEX-GGUF",
repoBase: "Qwen3.5-35B-A3B-APEX",
stem: "Qwen3.5-35B-A3B-APEX",
hasMMProj: true,
children: []childBuild{
buildOf("qwen3.5-35b-a3b-apex-i-quality", "mudler/Qwen3.5-35B-A3B-APEX-GGUF", "a.gguf", 0),
buildOf("qwen3.5-35b-a3b-ud-q6-k", "unsloth/Qwen3.5-35B-A3B-GGUF", "b.gguf", 102),
},
}}
inserts, newHubs, err := planHubs(fams, ix, noReuse,
allAdded("qwen3.5-35b-a3b-apex-i-quality", "qwen3.5-35b-a3b-ud-q6-k"))
Expect(err).ToNot(HaveOccurred())
Expect(inserts).To(BeEmpty())
Expect(newHubs).To(HaveLen(1))
hub := newHubs[0]
Expect(hub.Name).To(Equal("qwen3.5-35b-a3b"))
Expect(hub.Name).ToNot(HaveSuffix("-apex"))
// A hand-written *-apex entry is an ordinary build, referenced like any
// other rung and never deleted or renamed.
Expect(hub.Variants).To(Equal([]VariantRef{
{Model: "qwen3.5-35b-a3b-apex"},
{Model: "qwen3.5-35b-a3b-apex-i-quality"},
{Model: "qwen3.5-35b-a3b-ud-q6-k"},
}))
// The verifier skips entries with no declared backend, so a hub without
// one would escape the tagging check in silence.
Expect(hub.Overrides).To(HaveKeyWithValue("backend", "llama-cpp"))
Expect(hub.Files).ToNot(BeEmpty())
Expect(hub.Tags).To(ContainElement("vision"))
})
It("gathers two APEX repos that share one base model under a single hub", func() {
ix := mustIndex("- name: unrelated\n url: u\n")
fams := []family{
{
repo: "mudler/Solo-APEX-GGUF",
repoBase: "Solo-APEX",
stem: "Solo-APEX",
children: []childBuild{buildOf("solo-apex-i-quality", "mudler/Solo-APEX-GGUF", "a.gguf", 0)},
},
{
repo: "mudler/Solo-APEX-MTP-GGUF",
repoBase: "Solo-APEX-MTP",
stem: "Solo-APEX-MTP",
children: []childBuild{buildOf("solo-apex-mtp-i-quality", "mudler/Solo-APEX-MTP-GGUF", "b.gguf", 0)},
},
}
_, newHubs, err := planHubs(fams, ix, noReuse, allAdded("solo-apex-i-quality", "solo-apex-mtp-i-quality"))
Expect(err).ToNot(HaveOccurred())
Expect(newHubs).To(HaveLen(1))
Expect(newHubs[0].Name).To(Equal("solo"))
Expect(newHubs[0].Variants).To(Equal([]VariantRef{
{Model: "solo-apex-i-quality"},
{Model: "solo-apex-mtp-i-quality"},
}))
})
})
var _ = Describe("hubVariants", func() {
It("orders builds by quality rung rather than discovery order", func() {
// DiscoverAPEXTiers preserves input order and the HF API returns siblings
// alphabetically, so an unsorted list reads I-Balanced, I-Compact, I-Mini,
// I-Nano, I-Quality. Selection ignores authored order; this is for the
// human reading the file.
f := family{repoBase: "X-APEX", stem: "X-APEX", children: []childBuild{
{rank: rungRank["I-Nano"], entry: GalleryEntry{Name: "x-i-nano"}},
{rank: 100, entry: GalleryEntry{Name: "x-ud-q4-k-m"}},
{rank: rungRank["I-Quality"], entry: GalleryEntry{Name: "x-i-quality"}},
{rank: rungRank["I-Compact"], entry: GalleryEntry{Name: "x-i-compact"}},
}}
got := hubVariants(&f, mustIndex("- name: x\n url: u\n"), map[string]string{}, map[string]bool{})
Expect(got).To(Equal([]string{"x-i-quality", "x-i-compact", "x-i-nano", "x-ud-q4-k-m"}))
})
})
var _ = Describe("ParseIndexText", func() {
It("refuses to edit by line number when the two views of the file disagree", func() {
_, err := ParseIndexText("- name: one\n url: u\n-\n")
Expect(err).To(MatchError(ContainSubstring("empty")))
})
It("records the line range of each entry", func() {
ix := mustIndex("- name: first\n url: u\n- name: second\n url: u\n")
Expect(ix.Find("FIRST").Pos.StartLine).To(Equal(0))
Expect(ix.Find("first").Pos.EndLine).To(Equal(2))
Expect(ix.Find("second").Pos.StartLine).To(Equal(2))
})
})
var _ = Describe("resolveVariant", func() {
It("keeps an entry that was emitted even when it is also in reused", func() {
// A within-batch name collision records reused[name] = name while the
// FIRST entry of that name is still in add. Treating presence in reused as
// "dropped" would emit nothing for it.
added := map[string]bool{"dup": true}
reused := map[string]string{"dup": "dup"}
Expect(resolveVariant("dup", reused, added)).To(Equal("dup"))
})
It("redirects a reused name at the entry that stands in for it", func() {
added := map[string]bool{}
reused := map[string]string{"generated": "already-in-gallery"}
Expect(resolveVariant("generated", reused, added)).To(Equal("already-in-gallery"))
})
})
var _ = Describe("slug", func() {
It("lowercases and turns quant underscores into hyphens", func() {
Expect(slug("UD-Q4_K_M")).To(Equal("ud-q4-k-m"))
Expect(slug("gemma-4-26B-A4B-it-APEX")).To(Equal("gemma-4-26b-a4b-it-apex"))
Expect(slug("I-Nano")).To(Equal("i-nano"))
})
})
var _ = Describe("sortTiers", func() {
It("puts the imatrix ladder in descending quality order", func() {
tiers := []Tier{
{Label: "I-Balanced"}, {Label: "I-Compact"}, {Label: "I-Mini"},
{Label: "I-Nano"}, {Label: "I-Quality"},
}
sortTiers(tiers)
Expect(tierLabels(tiers)).To(Equal("I-Quality,I-Balanced,I-Compact,I-Mini,I-Nano"))
})
})
var _ = Describe("restrict", func() {
It("keeps only the named repos", func() {
got := restrict([]string{"mudler/A-APEX-GGUF", "mudler/B-APEX-GGUF"}, "mudler/B-APEX-GGUF")
Expect(got).To(Equal([]string{"mudler/B-APEX-GGUF"}))
})
It("returns nothing when the filter matches nothing", func() {
Expect(restrict([]string{"mudler/A-APEX-GGUF"}, "mudler/typo")).To(BeEmpty())
})
})
var _ = Describe("reportUnclassified", func() {
// One real imatrix rung is always present so the specs measure how the
// remaining files are bucketed, not an empty-repo edge case.
tier := Tier{Label: "I-Quality", File: GGUFFile{Name: "Model-APEX-I-Quality.gguf"}}
censusOf := func(names ...string) fileCensus {
files := []GGUFFile{tier.File}
for _, n := range names {
files = append(files, GGUFFile{Name: n})
}
return reportUnclassified("mudler/Model-APEX-GGUF", files, []Tier{tier}, nil)
}
It("counts a flat full-precision source as excluded, not unclassified", func() {
got := censusOf("Carnice-MoE-35B-A3B-F16.gguf")
Expect(got.fullPrecision).To(Equal(1))
Expect(got.unclassified).To(Equal(0))
})
It("counts every shard of a sharded full-precision source as excluded", func() {
got := censusOf(
"MiniMax-M2.7-APEX-F16-00001-of-00003.gguf",
"MiniMax-M2.7-APEX-F16-00002-of-00003.gguf",
"MiniMax-M2.7-APEX-F16-00003-of-00003.gguf",
)
Expect(got.fullPrecision).To(Equal(3))
Expect(got.unclassified).To(Equal(0))
})
It("treats bf16 the same as f16, in either case", func() {
got := censusOf("Model-APEX-BF16.gguf", "Model-APEX-bf16-00001-of-00002.gguf", "Model-APEX-f16.gguf")
Expect(got.fullPrecision).To(Equal(3))
Expect(got.unclassified).To(Equal(0))
})
It("still reports a genuinely unknown filename as unclassified", func() {
got := censusOf("Model-APEX-Turbo.gguf")
Expect(got.unclassified).To(Equal(1))
Expect(got.fullPrecision).To(Equal(0))
})
It("separates the two kinds when a repo publishes both", func() {
got := censusOf("Model-APEX-F16.gguf", "Model-APEX-Turbo.gguf")
Expect(got.fullPrecision).To(Equal(1))
Expect(got.unclassified).To(Equal(1))
})
})

View File

@@ -1,143 +0,0 @@
package main
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
const (
hfShorthandPrefix = "huggingface://"
hfResolvePrefix = "https://huggingface.co/"
hfResolveInfix = "/resolve/main/"
)
// canonicalURI reduces the two interchangeable spellings of a HuggingFace file
// to one key, so a generated resolve/main URI dedups against the shorthand the
// gallery uses for the majority of its entries.
//
// The repo is exactly the first two path segments; everything after is the file
// path, which may itself contain slashes because sharded quants live in a
// subdirectory. Anything that is not recognisably one of the two forms is
// returned unchanged rather than guessed at, so mirrors and other hosts still
// dedup on their literal string.
func canonicalURI(uri string) string {
switch {
case strings.HasPrefix(uri, hfShorthandPrefix):
rest := strings.TrimPrefix(uri, hfShorthandPrefix)
owner, after, ok := strings.Cut(rest, "/")
if !ok {
return uri
}
name, file, ok := strings.Cut(after, "/")
if !ok || owner == "" || name == "" || file == "" {
return uri
}
return hfShorthandPrefix + owner + "/" + name + "/" + file
case strings.HasPrefix(uri, hfResolvePrefix):
rest := strings.TrimPrefix(uri, hfResolvePrefix)
repo, file, ok := strings.Cut(rest, hfResolveInfix)
if !ok || file == "" {
return uri
}
// A repo is owner/name and nothing more; a longer prefix means this is
// some other huggingface.co URL that must not be rewritten.
owner, name, ok := strings.Cut(repo, "/")
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
return uri
}
return hfShorthandPrefix + repo + "/" + file
default:
return uri
}
}
// ExistingIndex is the lookup built from the current gallery: entry names, and
// which entry claims each weight URI.
type ExistingIndex struct {
ByName map[string]int
ByURI map[string]string
}
// LoadExisting reads the gallery index for dedup purposes only. It is
// deliberately not used to rewrite the file: the index is 40,000 lines, and a
// YAML round trip would reflow the whole thing into an unreviewable diff.
func LoadExisting(path string) (*ExistingIndex, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var entries []struct {
Name string `yaml:"name"`
Files []struct {
URI string `yaml:"uri"`
} `yaml:"files"`
}
if err := yaml.Unmarshal(raw, &entries); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
ix := &ExistingIndex{ByName: map[string]int{}, ByURI: map[string]string{}}
for i, e := range entries {
ix.ByName[e.Name] = i
for _, f := range e.Files {
if f.URI != "" {
ix.ByURI[canonicalURI(f.URI)] = e.Name
}
}
}
return ix, nil
}
// Merge splits generated entries into those to add and those already covered.
// reused maps a generated name to the existing entry that stands in for it, so
// a parent can reference what is already there instead of duplicating weights.
// Several APEX repos share one base model, so the same counterpart rungs are
// generated more than once in a batch. The batch has to dedup against itself as
// well as against the gallery, tracked locally because the caller may reuse the
// ExistingIndex it passed in.
func Merge(existing *ExistingIndex, generated []GalleryEntry) (add []GalleryEntry, reused map[string]string) {
reused = map[string]string{}
batchNames := map[string]string{}
batchURIs := map[string]string{}
// Canonicalized into a local copy rather than in place: an ExistingIndex may
// be hand-built or reused by the caller, so Merge must not rewrite it.
existingURIs := make(map[string]string, len(existing.ByURI))
for uri, owner := range existing.ByURI {
existingURIs[canonicalURI(uri)] = owner
}
for _, e := range generated {
// Name is checked before URI: a name collision must block the add
// whatever the weights say, since duplicate names corrupt the index.
if _, clash := existing.ByName[e.Name]; clash {
reused[e.Name] = e.Name
continue
}
if claimant, clash := batchNames[e.Name]; clash {
reused[e.Name] = claimant
continue
}
if len(e.Files) > 0 {
uri := canonicalURI(e.Files[0].URI)
if owner, ok := existingURIs[uri]; ok {
reused[e.Name] = owner
continue
}
if claimant, ok := batchURIs[uri]; ok {
reused[e.Name] = claimant
continue
}
batchURIs[uri] = e.Name
}
batchNames[e.Name] = e.Name
add = append(add, e)
}
return add, reused
}

View File

@@ -1,183 +0,0 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Merge", func() {
It("drops a generated entry whose weight URI already exists and reports the existing name", func() {
existing := &ExistingIndex{
ByName: map[string]int{"qwen3.6-35b-a3b-apex": 0},
ByURI: map[string]string{
"https://huggingface.co/mudler/X-APEX-GGUF/resolve/main/X-APEX-I-Quality.gguf": "qwen3.6-35b-a3b-apex",
},
}
gen := []GalleryEntry{{
Name: "x-apex-i-quality",
Files: []EntryFile{{URI: "https://huggingface.co/mudler/X-APEX-GGUF/resolve/main/X-APEX-I-Quality.gguf"}},
}}
add, reused := Merge(existing, gen)
Expect(add).To(BeEmpty())
Expect(reused).To(HaveKeyWithValue("x-apex-i-quality", "qwen3.6-35b-a3b-apex"))
})
It("keeps a generated entry whose weights are new", func() {
existing := &ExistingIndex{ByName: map[string]int{}, ByURI: map[string]string{}}
gen := []GalleryEntry{{
Name: "x-apex-i-mini",
Files: []EntryFile{{URI: "https://huggingface.co/mudler/X-APEX-GGUF/resolve/main/X-APEX-I-Mini.gguf"}},
}}
add, reused := Merge(existing, gen)
Expect(add).To(HaveLen(1))
Expect(reused).To(BeEmpty())
})
It("refuses to add an entry whose name collides with an existing one", func() {
existing := &ExistingIndex{
ByName: map[string]int{"x-apex-i-mini": 0},
ByURI: map[string]string{},
}
gen := []GalleryEntry{{
Name: "x-apex-i-mini",
Files: []EntryFile{{URI: "https://huggingface.co/mudler/X-APEX-GGUF/resolve/main/other.gguf"}},
}}
add, reused := Merge(existing, gen)
Expect(add).To(BeEmpty())
Expect(reused).To(HaveKeyWithValue("x-apex-i-mini", "x-apex-i-mini"))
})
// The gallery records most of its URIs in huggingface:// shorthand while
// render.go only ever emits the resolve/main form, so without
// canonicalization the majority of the file is invisible to the dedup.
It("matches a generated https URI against the shorthand form recorded in the gallery", func() {
existing := &ExistingIndex{
ByName: map[string]int{"foo-gguf-q8-0": 0},
ByURI: map[string]string{
"huggingface://unsloth/Foo-GGUF/Foo-Q8_0.gguf": "foo-gguf-q8-0",
},
}
gen := []GalleryEntry{{
Name: "foo-apex-q8-0",
Files: []EntryFile{{URI: "https://huggingface.co/unsloth/Foo-GGUF/resolve/main/Foo-Q8_0.gguf"}},
}}
add, reused := Merge(existing, gen)
Expect(add).To(BeEmpty())
Expect(reused).To(HaveKeyWithValue("foo-apex-q8-0", "foo-gguf-q8-0"))
})
It("matches a generated shorthand URI against the https form recorded in the gallery", func() {
existing := &ExistingIndex{
ByName: map[string]int{"foo-gguf-q8-0": 0},
ByURI: map[string]string{
"https://huggingface.co/unsloth/Foo-GGUF/resolve/main/Foo-Q8_0.gguf": "foo-gguf-q8-0",
},
}
gen := []GalleryEntry{{
Name: "foo-apex-q8-0",
Files: []EntryFile{{URI: "huggingface://unsloth/Foo-GGUF/Foo-Q8_0.gguf"}},
}}
add, reused := Merge(existing, gen)
Expect(add).To(BeEmpty())
Expect(reused).To(HaveKeyWithValue("foo-apex-q8-0", "foo-gguf-q8-0"))
})
// Sharded quants live under a subdirectory, so the file path carries slashes
// of its own and only the first two segments are the repo.
It("matches across both forms when the file path has a subdirectory", func() {
existing := &ExistingIndex{
ByName: map[string]int{"model-ud-q4-k-m": 0},
ByURI: map[string]string{
"huggingface://unsloth/Model-GGUF/UD-Q4_K_M/Model-UD-Q4_K_M-00001-of-00002.gguf": "model-ud-q4-k-m",
},
}
gen := []GalleryEntry{{
Name: "model-apex-ud-q4-k-m",
Files: []EntryFile{{URI: "https://huggingface.co/unsloth/Model-GGUF/resolve/main/UD-Q4_K_M/Model-UD-Q4_K_M-00001-of-00002.gguf"}},
}}
add, reused := Merge(existing, gen)
Expect(add).To(BeEmpty())
Expect(reused).To(HaveKeyWithValue("model-apex-ud-q4-k-m", "model-ud-q4-k-m"))
})
// Several APEX repos share one base model, so the same unsloth rungs are
// generated more than once in a single batch.
It("adds only the first of two generated entries sharing a name", func() {
existing := &ExistingIndex{ByName: map[string]int{}, ByURI: map[string]string{}}
gen := []GalleryEntry{
{
Name: "shared-rung-q8-0",
Files: []EntryFile{{URI: "https://huggingface.co/unsloth/Shared-GGUF/resolve/main/Shared-Q8_0.gguf"}},
},
{
Name: "shared-rung-q8-0",
Files: []EntryFile{{URI: "https://huggingface.co/unsloth/Other-GGUF/resolve/main/Other-Q8_0.gguf"}},
},
}
add, reused := Merge(existing, gen)
Expect(add).To(HaveLen(1))
Expect(add[0].Files[0].URI).To(Equal("https://huggingface.co/unsloth/Shared-GGUF/resolve/main/Shared-Q8_0.gguf"))
Expect(reused).To(HaveKeyWithValue("shared-rung-q8-0", "shared-rung-q8-0"))
})
It("adds only the first of two generated entries sharing a primary URI", func() {
existing := &ExistingIndex{ByName: map[string]int{}, ByURI: map[string]string{}}
gen := []GalleryEntry{
{
Name: "shared-rung-from-apex",
Files: []EntryFile{{URI: "https://huggingface.co/unsloth/Shared-GGUF/resolve/main/Shared-Q8_0.gguf"}},
},
{
Name: "shared-rung-from-apex-mtp",
Files: []EntryFile{{URI: "huggingface://unsloth/Shared-GGUF/Shared-Q8_0.gguf"}},
},
}
add, reused := Merge(existing, gen)
Expect(add).To(HaveLen(1))
Expect(add[0].Name).To(Equal("shared-rung-from-apex"))
Expect(reused).To(HaveKeyWithValue("shared-rung-from-apex-mtp", "shared-rung-from-apex"))
})
// Anything that is not a HuggingFace URI must survive untouched, so an
// unrecognised scheme still dedups against the very same string.
It("leaves a URI in neither recognised form alone and still dedups it exactly", func() {
existing := &ExistingIndex{
ByName: map[string]int{"mirrored-model": 0},
ByURI: map[string]string{
"https://mirror.example.com/weights/Model-Q8_0.gguf": "mirrored-model",
},
}
gen := []GalleryEntry{
{
Name: "mirrored-apex",
Files: []EntryFile{{URI: "https://mirror.example.com/weights/Model-Q8_0.gguf"}},
},
{
Name: "elsewhere-apex",
Files: []EntryFile{{URI: "https://mirror.example.com/weights/Other-Q8_0.gguf"}},
},
}
add, reused := Merge(existing, gen)
Expect(add).To(HaveLen(1))
Expect(add[0].Name).To(Equal("elsewhere-apex"))
Expect(reused).To(HaveKeyWithValue("mirrored-apex", "mirrored-model"))
})
})

View File

@@ -1,175 +0,0 @@
package main
import (
"fmt"
"path"
"strings"
)
// EntryFile is one downloadable file of a gallery entry.
type EntryFile struct {
Filename string `yaml:"filename"`
SHA256 string `yaml:"sha256"`
URI string `yaml:"uri"`
}
// GalleryEntry is the subset of a gallery entry this generator writes.
//
// Named GalleryEntry rather than Entry because the test files dot-import
// Ginkgo, whose table DSL exports an Entry that a package-level Entry would
// collide with. The yaml tags are what the gallery index sees, so the Go
// identifier is free to differ.
type GalleryEntry struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Description string `yaml:"description,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Overrides map[string]any `yaml:"overrides,omitempty"`
Files []EntryFile `yaml:"files,omitempty"`
Variants []VariantRef `yaml:"variants,omitempty"`
}
// VariantRef mirrors the gallery's variant reference: a name and nothing else.
type VariantRef struct {
Model string `yaml:"model"`
}
// ChildInput is everything needed to render one non-parent entry.
type ChildInput struct {
Name string
Repo string
// DraftRepo is the repo publishing the drafter, when it is not the repo
// publishing the weights. Speculative pairings routinely cross repos, so
// the drafter cannot be assumed to sit next to the weights. Empty means
// same-repo, which is how the *-APEX-MTP-GGUF repos ship.
DraftRepo string
Template string
Weights []GGUFFile
MMProj *GGUFFile
SpecType string
DraftFile *GGUFFile
BaseTags []string
}
// specTuning is the acceptance-window tuning each spec type ships with, copied
// from the hand-written entries that already run these two mechanisms rather
// than invented here. The two differ because the drafters differ: self-drafted
// MTP heads produce a short, high-confidence proposal (15+ hand-written entries
// use 6 with a 0.75 floor), while a separate DFlash drafter is cheap enough to
// run far ahead unconditionally (the five hand-written dflash entries use 15 and
// set no floor).
var specTuning = map[string][]string{
"draft-mtp": {"spec_n_max:6", "spec_p_min:0.75"},
"draft-dflash": {"spec_n_max:15"},
}
func hfURI(repo, file string) string {
return fmt.Sprintf("https://huggingface.co/%s/resolve/main/%s", repo, file)
}
// localPath is where a downloaded file lands.
//
// The hand-written entries namespace by the repo's BARE name
// (llama-cpp/models/<repo>/<file>), which is not unique. LiquidAI/LFM2.5-8B-A1B-GGUF
// and unsloth/LFM2.5-8B-A1B-GGUF share a basename, so both claim
// llama-cpp/models/LFM2.5-8B-A1B-GGUF/, and installing the second after the first
// either overwrites weights whose recorded sha256 belongs to the other file or is
// skipped as already present. Two owners publishing the same model name is the
// normal case for quantizers, not an edge case, so the owner has to be in the path.
//
// The owner becomes its own path segment rather than being folded into the
// directory name: owner/repo is unique on HuggingFace and "/" cannot occur inside
// either half, so this is the only form that is collision-proof by construction.
// It still reads as the hand-written convention with the owner restored, and the
// extra depth is already present in the index for sharded builds.
func localPath(kind, repo, file string) string {
// path.Dir yields "." for a repo named without an owner, which path.Join
// drops, so such a caller keeps the historical two-segment layout.
return path.Join("llama-cpp", kind, path.Dir(repo), path.Base(repo), file)
}
// RenderChild builds one child entry.
//
// The dflash/mtp tag is added if and only if this entry sets a spec_type,
// because variant ranking reads tags and nothing else, and a tag that does not
// match what the entry configures either promotes a build that is no faster or
// hides one that is.
func RenderChild(in ChildInput) GalleryEntry {
e := GalleryEntry{
Name: in.Name,
URL: fmt.Sprintf("github:mudler/LocalAI/gallery/%s@master", in.Template),
Tags: append([]string{}, in.BaseTags...),
Overrides: map[string]any{},
}
// gallery/virtual.yaml carries no backend, so nothing else would name an
// engine for these entries. Matching the hand-written entries on
// known_usecases too: LocalAI would fall back to the backend defaults, but
// generated entries should not read differently from their neighbours.
e.Overrides["backend"] = "llama-cpp"
e.Overrides["known_usecases"] = []string{"chat"}
options := []string{"use_jinja:true"}
for _, w := range in.Weights {
e.Files = append(e.Files, EntryFile{
Filename: localPath("models", in.Repo, w.Name),
SHA256: w.SHA256,
URI: hfURI(in.Repo, w.Name),
})
}
e.Overrides["parameters"] = map[string]any{
"model": localPath("models", in.Repo, in.Weights[0].Name),
}
if in.MMProj != nil {
// An explicit known_usecases SUPPRESSES the backend-default fallback in
// core/gallery/models_types.go, so a multimodal entry left at chat-only
// never matches FilterGalleryModelsByUsecase(FLAG_VISION) or
// FilterGalleryModelsByMultimodal and vanishes from the UI's vision and
// multimodal filters. 19 of the 45 APEX repos ship an mmproj.
e.Overrides["known_usecases"] = []string{"chat", "vision"}
e.Overrides["mmproj"] = localPath("mmproj", in.Repo, in.MMProj.Name)
e.Files = append(e.Files, EntryFile{
Filename: localPath("mmproj", in.Repo, in.MMProj.Name),
SHA256: in.MMProj.SHA256,
URI: hfURI(in.Repo, in.MMProj.Name),
})
}
// A spec type is configured independently of a drafter FILE. Weights that
// carry their own MTP heads need no second download, and requiring one left
// the *-APEX-MTP-GGUF builds shipping the larger heads-bearing weights with
// the heads switched off: a strictly bigger download at the same speed,
// ranked identically to the plain rung at the same tier.
if in.SpecType != "" {
options = append(options, "spec_type:"+in.SpecType)
options = append(options, specTuning[in.SpecType]...)
// The tag is derived from the spec type this entry sets and from nothing
// else. Variant ranking reads tags only, so a tag taken from a repo or
// entry NAME would promote a build that is no faster whenever the name
// and the configuration disagree.
e.Tags = append(e.Tags, strings.TrimPrefix(in.SpecType, "draft-"))
}
if in.SpecType != "" && in.DraftFile != nil {
// Fall back to the weights repo so pairings that publish the drafter
// alongside the weights keep working without restating the repo.
draftRepo := in.DraftRepo
if draftRepo == "" {
draftRepo = in.Repo
}
draftPath := localPath("models", draftRepo, in.DraftFile.Name)
e.Overrides["draft_model"] = draftPath
e.Overrides["flash_attention"] = "on"
e.Files = append(e.Files, EntryFile{
Filename: draftPath,
SHA256: in.DraftFile.SHA256,
URI: hfURI(draftRepo, in.DraftFile.Name),
})
}
e.Overrides["options"] = options
return e
}

View File

@@ -1,249 +0,0 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("RenderChild", func() {
It("tags an entry that configures draft-dflash", func() {
e := RenderChild(ChildInput{
Name: "qwen3.5-9b-dflash",
Repo: "mudler/Example-APEX-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Example-APEX-I-Quality.gguf", SHA256: "a"}},
SpecType: "draft-dflash",
DraftFile: &GGUFFile{Name: "Example-DFlash.Q8_0.gguf", SHA256: "b"},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Tags).To(ContainElement("dflash"))
Expect(e.Tags).ToNot(ContainElement("mtp"))
Expect(e.Overrides["options"]).To(ContainElement("spec_type:draft-dflash"))
Expect(e.Overrides["draft_model"]).ToNot(BeNil())
})
It("does not tag an MTP-named repo that configures no speculation", func() {
// mudler/Qwen3.6-35B-A3B-APEX-MTP-GGUF ships MTP-bearing weights. Weights
// that carry the heads are not an entry that enables them, and tagging it
// would win the feature axis without being any faster.
e := RenderChild(ChildInput{
Name: "qwen3.6-35b-a3b-apex-mtp-i-quality",
Repo: "mudler/Qwen3.6-35B-A3B-APEX-MTP-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Qwen3.6-35B-A3B-APEX-MTP-I-Quality.gguf", SHA256: "a"}},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Tags).ToNot(ContainElement("mtp"))
Expect(e.Tags).ToNot(ContainElement("dflash"))
Expect(e.Overrides).ToNot(HaveKey("draft_model"))
})
It("lists every shard of a sharded build and points the model at the first", func() {
e := RenderChild(ChildInput{
Name: "step-3.7-flash-ud-q4-k-m",
Repo: "unsloth/Step-3.7-Flash-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{
{Name: "UD-Q4_K_M/Step-3.7-Flash-UD-Q4_K_M-00001-of-00002.gguf", SHA256: "a"},
{Name: "UD-Q4_K_M/Step-3.7-Flash-UD-Q4_K_M-00002-of-00002.gguf", SHA256: "b"},
},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Files).To(HaveLen(2))
params, ok := e.Overrides["parameters"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(params["model"]).To(HaveSuffix("00001-of-00002.gguf"))
Expect(e.Files[0].URI).To(Equal(
"https://huggingface.co/unsloth/Step-3.7-Flash-GGUF/resolve/main/UD-Q4_K_M/Step-3.7-Flash-UD-Q4_K_M-00001-of-00002.gguf"))
})
It("wires mmproj when the repo publishes one", func() {
e := RenderChild(ChildInput{
Name: "example-i-mini",
Repo: "mudler/Example-APEX-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Example-APEX-I-Mini.gguf", SHA256: "a"}},
MMProj: &GGUFFile{Name: "mmproj-F16.gguf", SHA256: "c"},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Overrides["mmproj"]).ToNot(BeNil())
Expect(e.Files).To(HaveLen(2))
})
It("names the engine and the usecases the hand-written entries name", func() {
// gallery/virtual.yaml supplies no backend, so an entry that omits one
// names no engine at all and cannot load.
e := RenderChild(ChildInput{
Name: "example-i-mini",
Repo: "mudler/Example-APEX-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Example-APEX-I-Mini.gguf", SHA256: "a"}},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Overrides["backend"]).To(Equal("llama-cpp"))
Expect(e.Overrides["known_usecases"]).To(ContainElement("chat"))
})
It("draws the drafter from DraftRepo when the pairing spans two repos", func() {
// unsloth/Qwen3-4B-GGUF pairs with a drafter published separately by
// AtomicChat, so a drafter URI built from the weights repo 404s.
e := RenderChild(ChildInput{
Name: "qwen3-4b-dflash",
Repo: "unsloth/Qwen3-4B-GGUF",
DraftRepo: "AtomicChat/Qwen3-4B-DFlash-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Qwen3-4B-Q4_K_M.gguf", SHA256: "a"}},
SpecType: "draft-dflash",
DraftFile: &GGUFFile{Name: "Qwen3-4B-DFlash.Q8_0.gguf", SHA256: "b"},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Files[0].URI).To(Equal(
"https://huggingface.co/unsloth/Qwen3-4B-GGUF/resolve/main/Qwen3-4B-Q4_K_M.gguf"))
Expect(e.Files[1].URI).To(Equal(
"https://huggingface.co/AtomicChat/Qwen3-4B-DFlash-GGUF/resolve/main/Qwen3-4B-DFlash.Q8_0.gguf"))
Expect(e.Files[1].Filename).To(Equal(
"llama-cpp/models/AtomicChat/Qwen3-4B-DFlash-GGUF/Qwen3-4B-DFlash.Q8_0.gguf"))
Expect(e.Overrides["draft_model"]).To(Equal(
"llama-cpp/models/AtomicChat/Qwen3-4B-DFlash-GGUF/Qwen3-4B-DFlash.Q8_0.gguf"))
})
It("falls back to the weights repo for the drafter when DraftRepo is empty", func() {
// The *-APEX-MTP-GGUF repos ship the drafter alongside the weights.
e := RenderChild(ChildInput{
Name: "example-apex-dflash",
Repo: "mudler/Example-APEX-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Example-APEX-I-Quality.gguf", SHA256: "a"}},
SpecType: "draft-dflash",
DraftFile: &GGUFFile{Name: "Example-DFlash.Q8_0.gguf", SHA256: "b"},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Files[1].URI).To(Equal(
"https://huggingface.co/mudler/Example-APEX-GGUF/resolve/main/Example-DFlash.Q8_0.gguf"))
Expect(e.Files[1].Filename).To(Equal(
"llama-cpp/models/mudler/Example-APEX-GGUF/Example-DFlash.Q8_0.gguf"))
})
})
var _ = Describe("RenderChild known_usecases", func() {
It("declares vision alongside chat when the entry carries an mmproj", func() {
// An explicit known_usecases suppresses the backend-default fallback, so a
// chat-only multimodal entry disappears from the UI's vision filter.
e := RenderChild(ChildInput{
Name: "example-i-quality",
Repo: "mudler/Example-APEX-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Example-APEX-I-Quality.gguf", SHA256: "a"}},
MMProj: &GGUFFile{Name: "mmproj-F16.gguf", SHA256: "c"},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Overrides["known_usecases"]).To(ConsistOf("chat", "vision"))
})
It("leaves a text-only entry at chat", func() {
e := RenderChild(ChildInput{
Name: "example-i-quality",
Repo: "mudler/Example-APEX-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Example-APEX-I-Quality.gguf", SHA256: "a"}},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Overrides["known_usecases"]).To(ConsistOf("chat"))
})
})
var _ = Describe("localPath", func() {
It("keeps two repos with the same basename but different owners apart", func() {
// LiquidAI and unsloth both publish LFM2.5-8B-A1B-GGUF. A path built from
// the bare repo name gives both the same local file, so installing the
// second overwrites or skips the first and one of them then serves bytes
// that do not match its recorded sha256.
liquid := RenderChild(ChildInput{
Name: "lfm2.5-8b-a1b-i-quality",
Repo: "LiquidAI/LFM2.5-8B-A1B-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "LFM2.5-8B-A1B-Q8_0.gguf", SHA256: "33ab3b8c"}},
BaseTags: []string{"llm", "gguf"},
})
unsloth := RenderChild(ChildInput{
Name: "lfm2.5-8b-a1b-q8-0",
Repo: "unsloth/LFM2.5-8B-A1B-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "LFM2.5-8B-A1B-Q8_0.gguf", SHA256: "ec11666b"}},
BaseTags: []string{"llm", "gguf"},
})
Expect(liquid.Files[0].Filename).ToNot(Equal(unsloth.Files[0].Filename))
Expect(unsloth.Files[0].Filename).To(Equal(
"llama-cpp/models/unsloth/LFM2.5-8B-A1B-GGUF/LFM2.5-8B-A1B-Q8_0.gguf"))
})
It("namespaces the mmproj by owner too", func() {
e := RenderChild(ChildInput{
Name: "example-i-quality",
Repo: "mudler/Example-APEX-GGUF",
Template: "virtual.yaml",
Weights: []GGUFFile{{Name: "Example-APEX-I-Quality.gguf", SHA256: "a"}},
MMProj: &GGUFFile{Name: "mmproj-F16.gguf", SHA256: "c"},
BaseTags: []string{"llm", "gguf"},
})
Expect(e.Overrides["mmproj"]).To(Equal(
"llama-cpp/mmproj/mudler/Example-APEX-GGUF/mmproj-F16.gguf"))
})
})
var _ = Describe("MTP builds", func() {
renderTier := func(repo string) GalleryEntry {
return RenderChild(ChildInput{
Name: "example-i-quality",
Repo: repo,
Template: "virtual.yaml",
SpecType: SpecTypeForRepo(repo),
Weights: []GGUFFile{{Name: "Example-I-Quality.gguf", SHA256: "a"}},
BaseTags: []string{"llm", "gguf"},
})
}
It("turns MTP on for a build off an APEX-MTP repo", func() {
// These weights retain the model's own MTP heads, so shipping them with
// speculation off is a strictly larger download at the same speed,
// ranked identically to the plain rung at the same tier.
e := renderTier("mudler/Qwen3.6-35B-A3B-APEX-MTP-GGUF")
Expect(e.Overrides["options"]).To(ContainElements(
"spec_type:draft-mtp", "spec_n_max:6", "spec_p_min:0.75"))
Expect(e.Tags).To(ContainElement("mtp"))
})
It("needs no drafter file, because the heads travel with the weights", func() {
e := renderTier("mudler/Qwen3.6-35B-A3B-APEX-MTP-GGUF")
Expect(e.Overrides).ToNot(HaveKey("draft_model"))
Expect(e.Files).To(HaveLen(1))
})
It("leaves a build off a plain APEX repo alone", func() {
e := renderTier("mudler/Qwen3.6-35B-A3B-APEX-GGUF")
Expect(e.Tags).ToNot(ContainElement("mtp"))
Expect(e.Overrides["options"]).To(ConsistOf("use_jinja:true"))
})
It("leaves an unsloth counterpart rung alone", func() {
// The counterpart quantizes the plain weights; nothing there carries heads.
e := renderTier("unsloth/Qwen3.6-35B-A3B-GGUF")
Expect(e.Tags).ToNot(ContainElement("mtp"))
Expect(e.Overrides["options"]).To(ConsistOf("use_jinja:true"))
})
})

View File

@@ -1,71 +0,0 @@
package main
import (
"regexp"
"sort"
"strings"
)
// WantedQuants is the fixed unsloth subset this generator emits. It is a
// deliberate subset: unsloth publishes north of 20 quants per repo, and the
// selector needs useful fitness points rather than every rung.
var WantedQuants = []string{"UD-Q4_K_M", "UD-Q5_K_M", "UD-Q6_K", "Q8_0"}
var shardRE = regexp.MustCompile(`-(\d{5})-of-(\d{5})\.gguf$`)
// QuantBuild is one unsloth quantization, which may be a single file or an
// ordered set of shards.
type QuantBuild struct {
Quant string
Files []GGUFFile
Sharded bool
}
// CounterpartCandidates returns the unsloth repo base names worth probing, most
// likely first. Both derivations are needed: the repo name finds
// unsloth/gemma-4-26B-A4B-it-GGUF, while the file stem is what matches for
// repos whose stem is the canonical model name.
func CounterpartCandidates(repoName, fileStem string) []string {
clean := func(s string) string {
s = strings.TrimSuffix(s, "-GGUF")
s = regexp.MustCompile(`-(MTP|TQ)$`).ReplaceAllString(s, "")
s = strings.TrimSuffix(s, "-APEX")
return regexp.MustCompile(`-(MTP|TQ)$`).ReplaceAllString(s, "")
}
out := []string{clean(repoName)}
if stem := clean(fileStem); stem != out[0] {
out = append(out, stem)
}
return out
}
// DiscoverUnslothQuants returns the wanted quants a repo publishes, handling
// both the flat single-file layout and the sharded layout where a quant lives
// in its own subdirectory.
func DiscoverUnslothQuants(files []GGUFFile) []QuantBuild {
var out []QuantBuild
for _, q := range WantedQuants {
var flat []GGUFFile
var shards []GGUFFile
for _, f := range files {
switch {
case !strings.Contains(f.Name, "/") && strings.HasSuffix(f.Name, "-"+q+".gguf"):
flat = append(flat, f)
case strings.HasPrefix(f.Name, q+"/") && shardRE.MatchString(f.Name):
shards = append(shards, f)
}
}
switch {
case len(flat) > 0:
out = append(out, QuantBuild{Quant: q, Files: flat})
case len(shards) > 0:
sort.Slice(shards, func(i, j int) bool { return shards[i].Name < shards[j].Name })
out = append(out, QuantBuild{Quant: q, Files: shards, Sharded: true})
}
}
return out
}

View File

@@ -1,75 +0,0 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("CounterpartCandidates", func() {
It("offers both the repo-derived and stem-derived names", func() {
// mudler/gemma-4-26B-A4B-it-APEX-GGUF ships gemma-4-26B-A4B-APEX-*.gguf,
// and only the repo-derived name finds unsloth/gemma-4-26B-A4B-it-GGUF.
got := CounterpartCandidates("gemma-4-26B-A4B-it-APEX-GGUF", "gemma-4-26B-A4B-APEX")
Expect(got).To(Equal([]string{"gemma-4-26B-A4B-it", "gemma-4-26B-A4B"}))
})
It("strips the MTP marker", func() {
got := CounterpartCandidates("Qwopus3.6-35B-A3B-v1-APEX-MTP-GGUF", "Qwopus3.6-35B-A3B-v1-APEX-MTP")
Expect(got[0]).To(Equal("Qwopus3.6-35B-A3B-v1"))
})
It("strips the TQ marker", func() {
// This is the branch that folds mudler/Qwen3.5-35B-A3B-APEX-TQ-GGUF into
// the qwen3.5-35b-a3b hub. Without it the probe is
// unsloth/Qwen3.5-35B-A3B-TQ-GGUF, which does not exist, so the family
// silently loses every unsloth rung.
got := CounterpartCandidates("Qwen3.5-35B-A3B-APEX-TQ-GGUF", "Qwen3.5-35B-A3B-APEX-TQ")
Expect(got).To(Equal([]string{"Qwen3.5-35B-A3B"}))
})
It("does not repeat a candidate when both derivations agree", func() {
got := CounterpartCandidates("Qwen3.6-35B-A3B-APEX-GGUF", "Qwen3.6-35B-A3B-APEX")
Expect(got).To(Equal([]string{"Qwen3.6-35B-A3B"}))
})
})
var _ = Describe("DiscoverUnslothQuants", func() {
It("finds flat single-file quants", func() {
files := []GGUFFile{
{Name: "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf", SHA256: "a"},
{Name: "Qwen3.6-35B-A3B-UD-IQ1_M.gguf", SHA256: "b"},
}
got := DiscoverUnslothQuants(files)
Expect(got).To(HaveLen(1))
Expect(got[0].Quant).To(Equal("UD-Q4_K_M"))
Expect(got[0].Sharded).To(BeFalse())
Expect(got[0].Files).To(HaveLen(1))
})
It("collects a sharded quant from its subdirectory in shard order", func() {
files := []GGUFFile{
{Name: "UD-Q4_K_M/Step-3.7-Flash-UD-Q4_K_M-00002-of-00002.gguf", SHA256: "b"},
{Name: "UD-Q4_K_M/Step-3.7-Flash-UD-Q4_K_M-00001-of-00002.gguf", SHA256: "a"},
}
got := DiscoverUnslothQuants(files)
Expect(got).To(HaveLen(1))
Expect(got[0].Quant).To(Equal("UD-Q4_K_M"))
Expect(got[0].Sharded).To(BeTrue())
Expect(got[0].Files).To(HaveLen(2))
Expect(got[0].Files[0].Name).To(HaveSuffix("00001-of-00002.gguf"))
})
It("ignores quants outside the wanted subset", func() {
files := []GGUFFile{{Name: "Model-UD-IQ2_XXS.gguf", SHA256: "a"}}
Expect(DiscoverUnslothQuants(files)).To(BeEmpty())
})
})

View File

@@ -1,312 +0,0 @@
package main
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type verifyEntry struct {
Name string `yaml:"name"`
Tags []string `yaml:"tags"`
Variants []VariantRef `yaml:"variants"`
Overrides struct {
// Backend scopes the checks that only hold for one engine. An entry that
// declares none takes its configuration from the referenced url: template,
// which this verifier never reads, so it cannot be judged either way.
Backend string `yaml:"backend"`
Options []string `yaml:"options"`
// MMProj and DraftModel name the files that are not weights. They are
// the only signal for it: a drafter lands in the same models/ prefix as
// the weights, so the path alone cannot tell them apart.
MMProj string `yaml:"mmproj"`
DraftModel string `yaml:"draft_model"`
} `yaml:"overrides"`
Files []struct {
Filename string `yaml:"filename"`
SHA256 string `yaml:"sha256"`
URI string `yaml:"uri"`
} `yaml:"files"`
}
// Verify checks the invariants the variants schema and the tagging rule
// require. It returns every problem rather than the first, so one run tells the
// author everything that needs fixing.
func Verify(path string) []string {
raw, err := os.ReadFile(path)
if err != nil {
return []string{fmt.Sprintf("reading %s: %v", path, err)}
}
var entries []verifyEntry
if err := yaml.Unmarshal(raw, &entries); err != nil {
return []string{fmt.Sprintf("parsing %s: %v", path, err)}
}
var problems []string
byName := map[string]verifyEntry{}
for _, e := range entries {
if _, seen := byName[e.Name]; seen {
problems = append(problems, fmt.Sprintf("duplicate entry name: %s", e.Name))
continue
}
byName[e.Name] = e
}
for _, e := range entries {
for _, v := range e.Variants {
target, ok := byName[v.Model]
if !ok {
problems = append(problems, fmt.Sprintf("%s: variant %q does not exist", e.Name, v.Model))
continue
}
if len(target.Variants) > 0 {
problems = append(problems, fmt.Sprintf("%s: variant %q declares variants of its own", e.Name, v.Model))
}
}
for _, f := range e.Files {
if requiresSHA256(f.Filename) && f.SHA256 == "" {
problems = append(problems, fmt.Sprintf("%s: file %s has no sha256", e.Name, f.Filename))
}
}
problems = append(problems, checkWeightCount(e)...)
problems = append(problems, checkFeatureTag(e, "dflash")...)
problems = append(problems, checkFeatureTag(e, "mtp")...)
}
problems = append(problems, checkPathCollisions(entries)...)
return problems
}
// checkPathCollisions catches two different upstream files claiming one local
// path. The install layer keys on the local filename, so whichever entry is
// installed second either overwrites weights the first entry recorded a
// different sha256 for or is skipped as already present. Either way some entry
// afterwards serves bytes that do not match its own checksum, and nothing at
// install time says so.
//
// This is an index-wide invariant rather than a per-entry one: neither entry is
// wrong on its own and the collision exists only in their pairing. The usual
// source is a path scheme built from the repo's BARE name, because two owners
// publishing the same model name is routine for quantizers.
//
// Sharing a path is fine when the uri is the same, which is how several entries
// legitimately reuse one projector. Files with no uri are skipped: there is
// nothing to compare.
func checkPathCollisions(entries []verifyEntry) []string {
type source struct{ uri, entry string }
first := map[string]source{}
reported := map[string]bool{}
var problems []string
for _, e := range entries {
for _, f := range e.Files {
if f.Filename == "" || f.URI == "" {
continue
}
prev, seen := first[f.Filename]
if !seen {
first[f.Filename] = source{uri: f.URI, entry: e.Name}
continue
}
if prev.uri == f.URI || reported[f.Filename] {
continue
}
// Reported once per path however many entries pile onto it, so one
// heavily reused filename cannot bury the rest of the report.
reported[f.Filename] = true
problems = append(problems, fmt.Sprintf(
"local path %s is claimed by two different uris: %s (%s) and %s (%s)",
f.Filename, prev.uri, prev.entry, f.URI, e.Name))
}
}
return problems
}
// auxiliaryExtensions are the metadata formats an entry ships beside its
// weights, where an unverified download is a nuisance rather than a hole.
//
// The exclusion is stated as a list of metadata formats on purpose. Requiring
// the checksum only on a blessed list of weight formats would silently exempt
// every format nobody has shipped yet, and it already exempted safetensors
// weights, which are downloaded and loaded exactly like GGUF ones.
var auxiliaryExtensions = []string{".json", ".txt", ".md"}
// requiresSHA256 reports whether an unverified download of this file would be
// a supply-chain hole rather than a cosmetic gap.
func requiresSHA256(filename string) bool {
for _, ext := range auxiliaryExtensions {
if strings.HasSuffix(filename, ext) {
return false
}
}
return true
}
// checkWeightCount catches an entry carrying two whole models. The flat-match
// branch in DiscoverUnslothQuants appends every match, so a quant label that is
// a suffix of another one (Q8_0 of UD-Q8_0) collects both files into one build
// while the rendered model: points at only the first. The result downloads
// twice the bytes and serves whichever file sorted first, silently.
//
// Shards are exempt because a sharded build is legitimately many files.
//
// The collision is a property of llama-cpp quant discovery, so the check is
// scoped to that backend. Multi-component TTS, ASR and diffusion engines ship an
// encoder, a decoder and a vocoder as one model, and there the second GGUF is
// the design rather than a bug.
func checkWeightCount(e verifyEntry) []string {
if e.Overrides.Backend != "llama-cpp" {
return nil
}
var weights []string
for _, f := range e.Files {
switch {
case !strings.HasSuffix(f.Filename, ".gguf"):
case shardRE.MatchString(f.Filename):
case f.Filename == e.Overrides.MMProj:
case f.Filename == e.Overrides.DraftModel:
default:
weights = append(weights, f.Filename)
}
}
if len(weights) > 1 {
return []string{fmt.Sprintf("%s: more than one weight file: %s", e.Name, strings.Join(weights, ", "))}
}
return nil
}
// checkFeatureTag enforces the rule in both directions. A tag without the
// configuration promotes a build that is no faster; configuration without the
// tag leaves a genuinely faster build ranked as plain.
//
// It only speaks about backends whose declaration it can actually read, because
// a rule applied where the evidence is invisible reports noise rather than bugs.
func checkFeatureTag(e verifyEntry, feature string) []string {
decl, configured, judgeable := featureDeclaration(e, feature)
if !judgeable {
return nil
}
tagged := false
for _, t := range e.Tags {
if t == feature {
tagged = true
break
}
}
switch {
case tagged && !configured:
return []string{fmt.Sprintf("%s: tagged %s but sets no %s", e.Name, feature, decl)}
case configured && !tagged:
return []string{fmt.Sprintf("%s: sets %s but is not tagged %s", e.Name, decl, feature)}
}
return nil
}
// featureDeclaration implements the per-backend table in
// .agents/adding-gallery-models.md. It returns the declaration the backend uses
// to configure the feature, whether the entry carries it, and whether this
// verifier is in a position to answer at all.
func featureDeclaration(e verifyEntry, feature string) (decl string, configured, judgeable bool) {
switch e.Overrides.Backend {
case "llama-cpp":
decl = "spec_type:draft-" + feature
for _, o := range e.Overrides.Options {
if strings.TrimSpace(o) == decl {
return decl, true, true
}
}
return decl, false, true
case "ds4":
// ds4 carries the MTP heads in the weights and turns them on with
// mtp_path / mtp_draft. It has no dflash counterpart, so dflash is not a
// question that can be asked of a ds4 entry.
if feature != "mtp" {
return "", false, false
}
decl = "mtp_path:"
for _, o := range e.Overrides.Options {
o = strings.TrimSpace(o)
if strings.HasPrefix(o, "mtp_path:") || strings.HasPrefix(o, "mtp_draft:") {
return decl, true, true
}
}
return decl, false, true
default:
// sglang configures the feature with speculative_algorithm: in the
// referenced gallery/*.yaml, and an entry that declares no backend takes
// its whole configuration from its url: template. Verify reads one index
// file and follows neither, so it must not judge these in either
// direction.
return "", false, false
}
}
// UnaccountedQuants reports a wanted quant the repo demonstrably publishes but
// that discovery produced no build for. The layout that triggers it today is
// root-level shards, which match neither branch of DiscoverUnslothQuants; no
// counterpart ships that way yet, but a batch generator must not drop a build
// with nothing said about it.
func UnaccountedQuants(files []GGUFFile, builds []QuantBuild) []string {
built := map[string]bool{}
for _, b := range builds {
built[b.Quant] = true
}
var problems []string
for _, q := range WantedQuants {
if built[q] {
continue
}
for _, f := range files {
if filePublishesQuant(f.Name, q) {
problems = append(problems, fmt.Sprintf("quant %s is published upstream (%s) but produced no build", q, f.Name))
break
}
}
}
return problems
}
// filePublishesQuant reports whether an upstream file is a publication of
// quant q. It anchors on the quant label the way DiscoverUnslothQuants does,
// as the trailing token of the base name or as the sharding subdirectory, so
// the diagnostic and the discovery it audits cannot disagree about what a file
// is.
//
// An unanchored match would reproduce the very collision this diagnostic warns
// about: Q8_0 is a substring of UD-Q8_0, so a repo publishing only UD-Q8_0
// would be reported as publishing an unbuilt Q8_0, which it does not, and
// UD-Q8_0 is not a wanted quant at all.
func filePublishesQuant(name, q string) bool {
if strings.HasPrefix(name, q+"/") {
return true
}
base := name[strings.LastIndex(name, "/")+1:]
// Shard numbering sits between the quant label and the extension, so it has
// to come off before the label can be read as the trailing token. Root-level
// shards are the layout that matches neither branch of
// DiscoverUnslothQuants, and so the layout this diagnostic mainly catches.
base = shardRE.ReplaceAllString(base, ".gguf")
if !strings.HasSuffix(base, "-"+q+".gguf") {
return false
}
// UD- is unsloth's dynamic-quant modifier, and UD-<q> is a distinct quant
// label rather than a publication of <q>.
return !strings.HasSuffix(base, "-UD-"+q+".gguf")
}

View File

@@ -1,480 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Verify", func() {
write := func(body string) string {
dir := GinkgoT().TempDir()
p := filepath.Join(dir, "index.yaml")
Expect(os.WriteFile(p, []byte(body), 0o600)).To(Succeed())
return p
}
It("passes a sound index", func() {
Expect(Verify(write(`
- name: parent
variants:
- model: child
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
- name: child
files:
- filename: b.gguf
sha256: bb
uri: https://example.com/b.gguf
`))).To(BeEmpty())
})
It("reports a variant pointing at a missing entry", func() {
Expect(Verify(write(`
- name: parent
variants:
- model: ghost
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
`))).To(ContainElement(ContainSubstring("ghost")))
})
It("reports a variant that itself declares variants", func() {
Expect(Verify(write(`
- name: parent
variants:
- model: child
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
- name: child
variants:
- model: grandchild
files:
- filename: b.gguf
sha256: bb
uri: https://example.com/b.gguf
- name: grandchild
files:
- filename: c.gguf
sha256: cc
uri: https://example.com/c.gguf
`))).To(ContainElement(ContainSubstring("declares variants of its own")))
})
It("reports duplicate entry names", func() {
Expect(Verify(write(`
- name: dup
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
- name: dup
files:
- filename: b.gguf
sha256: bb
uri: https://example.com/b.gguf
`))).To(ContainElement(ContainSubstring("duplicate entry name")))
})
It("reports a file with no sha256", func() {
Expect(Verify(write(`
- name: one
files:
- filename: a.gguf
uri: https://example.com/a.gguf
`))).To(ContainElement(ContainSubstring("no sha256")))
})
It("reports an entry tagged dflash without a matching spec_type", func() {
Expect(Verify(write(`
- name: liar
tags:
- dflash
overrides:
backend: llama-cpp
options:
- use_jinja:true
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
`))).To(ContainElement(ContainSubstring("tagged dflash")))
})
It("reports an entry configuring spec_type without the tag", func() {
Expect(Verify(write(`
- name: shy
overrides:
backend: llama-cpp
options:
- spec_type:draft-mtp
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
`))).To(ContainElement(ContainSubstring("not tagged mtp")))
})
// ds4 carries the MTP heads in the weights and names them with mtp_path, so
// the rule holds there in a different vocabulary rather than not at all.
It("reports a ds4 entry configuring mtp_path without the tag", func() {
Expect(Verify(write(`
- name: ds4-shy
overrides:
backend: ds4
options:
- mtp_path:model-mtp.gguf
- mtp_draft:2
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
`))).To(ContainElement(ContainSubstring("not tagged mtp")))
})
It("reports a ds4 entry tagged mtp that configures no mtp_path", func() {
Expect(Verify(write(`
- name: ds4-liar
tags:
- mtp
overrides:
backend: ds4
options:
- context_size:4096
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
`))).To(ContainElement(ContainSubstring("tagged mtp")))
})
It("accepts a ds4 entry that both configures mtp_path and carries the tag", func() {
Expect(Verify(write(`
- name: ds4-honest
tags:
- mtp
overrides:
backend: ds4
options:
- mtp_path:model-mtp.gguf
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
`))).To(BeEmpty())
})
// sglang declares speculative_algorithm in the referenced gallery/*.yaml,
// which Verify never reads, so it may not judge such an entry either way.
It("says nothing about an sglang entry tagged mtp", func() {
Expect(Verify(write(`
- name: sglang-mtp
tags:
- mtp
overrides:
backend: sglang
files: []
`))).To(BeEmpty())
})
It("says nothing about the tag on an entry with no declared backend", func() {
Expect(Verify(write(`
- name: templated
tags:
- mtp
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
`))).To(BeEmpty())
})
// The flat-match branch in unsloth.go appends every match, so a repo
// publishing both a plain and a UD Q8_0 renders one entry holding two full
// models while model: points at only the first.
It("reports an entry holding more than one non-shard weight file", func() {
Expect(Verify(write(`
- name: greedy
overrides:
backend: llama-cpp
options:
- use_jinja:true
parameters:
model: llama-cpp/models/repo/Model-Q8_0.gguf
files:
- filename: llama-cpp/models/repo/Model-Q8_0.gguf
sha256: aa
uri: https://example.com/a.gguf
- filename: llama-cpp/models/repo/Model-UD-Q8_0.gguf
sha256: bb
uri: https://example.com/b.gguf
`))).To(ContainElement(ContainSubstring("more than one weight file")))
})
It("accepts many shards alongside an mmproj and a drafter", func() {
Expect(Verify(write(`
- name: sharded
tags:
- mtp
overrides:
backend: llama-cpp
options:
- spec_type:draft-mtp
mmproj: llama-cpp/mmproj/repo/mm.gguf
draft_model: llama-cpp/models/repo/Model-draft.gguf
files:
- filename: llama-cpp/models/repo/Model-00001-of-00002.gguf
sha256: aa
uri: https://example.com/a.gguf
- filename: llama-cpp/models/repo/Model-00002-of-00002.gguf
sha256: bb
uri: https://example.com/b.gguf
- filename: llama-cpp/mmproj/repo/mm.gguf
sha256: cc
uri: https://example.com/c.gguf
- filename: llama-cpp/models/repo/Model-draft.gguf
sha256: dd
uri: https://example.com/d.gguf
`))).To(BeEmpty())
})
// Multi-component TTS and ASR engines legitimately ship an encoder, a
// tokenizer, a vocoder and so on as one model, so the collision the weight
// count catches does not exist for them.
It("accepts a multi-component non-llama-cpp entry declaring five weights", func() {
Expect(Verify(write(`
- name: multi
overrides:
backend: qwen3-tts-cpp
files:
- filename: talker.gguf
sha256: aa
uri: https://example.com/a.gguf
- filename: tokenizer.gguf
sha256: bb
uri: https://example.com/b.gguf
- filename: vocoder.gguf
sha256: cc
uri: https://example.com/c.gguf
- filename: encoder.gguf
sha256: dd
uri: https://example.com/d.gguf
- filename: vae.gguf
sha256: ee
uri: https://example.com/e.gguf
`))).To(BeEmpty())
})
It("says nothing about the weight count of an entry with no declared backend", func() {
Expect(Verify(write(`
- name: templated-weights
files:
- filename: model-Q4_K_M.gguf
sha256: aa
uri: https://example.com/a.gguf
- filename: model-mmproj-f16.gguf
sha256: bb
uri: https://example.com/b.gguf
`))).To(BeEmpty())
})
It("says nothing about an auxiliary metadata file carrying no sha256", func() {
Expect(Verify(write(`
- name: aux
files:
- filename: a.gguf
sha256: aa
uri: https://example.com/a.gguf
- filename: params.json
sha256: ""
uri: https://example.com/params.json
`))).To(BeEmpty())
})
// safetensors weights are downloaded and loaded exactly like GGUF weights,
// so an unverified one is the same supply-chain hole.
It("reports a safetensors weight carrying no sha256", func() {
Expect(Verify(write(`
- name: vae
files:
- filename: wan_2.1_vae.safetensors
sha256: ""
uri: https://example.com/vae.safetensors
`))).To(ContainElement(ContainSubstring("no sha256")))
})
It("says nothing about a txt or md file carrying no sha256", func() {
Expect(Verify(write(`
- name: docs
files:
- filename: notes.txt
sha256: ""
uri: https://example.com/notes.txt
- filename: README.md
sha256: ""
uri: https://example.com/README.md
`))).To(BeEmpty())
})
})
var _ = Describe("UnaccountedQuants", func() {
// A quant published only as root-level shards matches neither branch in
// DiscoverUnslothQuants, so without this diagnostic the build would vanish
// from a batch run with nothing said about it.
It("reports a wanted quant upstream publishes but discovery dropped", func() {
files := []GGUFFile{
{Name: "Model-UD-Q4_K_M-00001-of-00003.gguf", SHA256: "aa"},
{Name: "Model-UD-Q4_K_M-00002-of-00003.gguf", SHA256: "bb"},
{Name: "Model-UD-Q4_K_M-00003-of-00003.gguf", SHA256: "cc"},
}
Expect(UnaccountedQuants(files, DiscoverUnslothQuants(files))).
To(ContainElement(ContainSubstring("UD-Q4_K_M")))
})
It("says nothing when every published wanted quant produced a build", func() {
files := []GGUFFile{
{Name: "Model-UD-Q4_K_M.gguf", SHA256: "aa"},
{Name: "UD-Q6_K/Model-UD-Q6_K-00001-of-00002.gguf", SHA256: "bb"},
{Name: "UD-Q6_K/Model-UD-Q6_K-00002-of-00002.gguf", SHA256: "cc"},
}
Expect(UnaccountedQuants(files, DiscoverUnslothQuants(files))).To(BeEmpty())
})
It("says nothing about a wanted quant the repo does not publish at all", func() {
files := []GGUFFile{{Name: "Model-UD-Q4_K_M.gguf", SHA256: "aa"}}
Expect(UnaccountedQuants(files, DiscoverUnslothQuants(files))).To(BeEmpty())
})
// UD-Q8_0 is its own quant label and is not a wanted one. Reading it as a
// publication of Q8_0 is the substring collision this diagnostic exists to
// warn about, and subdirectory-sharded UD quants are the normal unsloth
// layout for large repos, so the false positive would fire on every batch.
It("does not read a subdirectory-sharded UD-Q8_0 as a published Q8_0", func() {
files := []GGUFFile{
{Name: "UD-Q8_0/Model-UD-Q8_0-00001-of-00002.gguf", SHA256: "aa"},
{Name: "UD-Q8_0/Model-UD-Q8_0-00002-of-00002.gguf", SHA256: "bb"},
}
Expect(UnaccountedQuants(files, DiscoverUnslothQuants(files))).To(BeEmpty())
})
// A quant in its own subdirectory but not shard-numbered matches neither
// branch of DiscoverUnslothQuants, so it is genuinely published and
// genuinely undiscovered.
It("reports a wanted quant published in its own subdirectory without shard numbering", func() {
files := []GGUFFile{{Name: "Q8_0/Model-Q8_0.gguf", SHA256: "aa"}}
Expect(UnaccountedQuants(files, DiscoverUnslothQuants(files))).
To(ContainElement(ContainSubstring("quant Q8_0 is published upstream")))
})
// builds is empty on purpose: it isolates the file-to-quant match from
// whatever DiscoverUnslothQuants would have made of the same file.
It("matches the flat single-file layout", func() {
files := []GGUFFile{{Name: "Model-Q8_0.gguf", SHA256: "aa"}}
Expect(UnaccountedQuants(files, nil)).
To(ConsistOf(ContainSubstring("quant Q8_0 is published upstream")))
})
})
var _ = Describe("Verify local path collisions", func() {
write := func(body string) string {
dir := GinkgoT().TempDir()
p := filepath.Join(dir, "index.yaml")
Expect(os.WriteFile(p, []byte(body), 0o600)).To(Succeed())
return p
}
It("reports one local path claimed by two different uris", func() {
// The shape that shipped: LiquidAI and unsloth both publish
// LFM2.5-8B-A1B-GGUF, so a path built from the bare repo name gives both
// entries the same local file under two different checksums.
Expect(Verify(write(`
- name: lfm2.5-8b-a1b
files:
- filename: llama-cpp/models/LFM2.5-8B-A1B-GGUF/LFM2.5-8B-A1B-Q8_0.gguf
sha256: 33ab3b8c
uri: https://huggingface.co/LiquidAI/LFM2.5-8B-A1B-GGUF/resolve/main/LFM2.5-8B-A1B-Q8_0.gguf
- name: lfm2.5-8b-a1b-q8-0
files:
- filename: llama-cpp/models/LFM2.5-8B-A1B-GGUF/LFM2.5-8B-A1B-Q8_0.gguf
sha256: ec11666b
uri: https://huggingface.co/unsloth/LFM2.5-8B-A1B-GGUF/resolve/main/LFM2.5-8B-A1B-Q8_0.gguf
`))).To(ContainElement(SatisfyAll(
ContainSubstring("claimed by two different uris"),
ContainSubstring("lfm2.5-8b-a1b-q8-0"),
)))
})
It("accepts two entries reusing one file from the same uri", func() {
// Sibling builds of one repo legitimately share a projector.
Expect(Verify(write(`
- name: a
files:
- filename: llama-cpp/mmproj/mudler/Example-GGUF/mmproj-F16.gguf
sha256: cc
uri: https://huggingface.co/mudler/Example-GGUF/resolve/main/mmproj-F16.gguf
- name: b
files:
- filename: llama-cpp/mmproj/mudler/Example-GGUF/mmproj-F16.gguf
sha256: cc
uri: https://huggingface.co/mudler/Example-GGUF/resolve/main/mmproj-F16.gguf
`))).To(BeEmpty())
})
It("reports a collision once however many entries pile onto the path", func() {
problems := Verify(write(`
- name: a
files:
- filename: shared.gguf
sha256: aa
uri: https://example.com/a.gguf
- name: b
files:
- filename: shared.gguf
sha256: bb
uri: https://example.com/b.gguf
- name: c
files:
- filename: shared.gguf
sha256: cc
uri: https://example.com/c.gguf
`))
var collisions int
for _, p := range problems {
if strings.Contains(p, "claimed by two different uris") {
collisions++
}
}
Expect(collisions).To(Equal(1))
})
It("says nothing about files that carry no uri", func() {
// A hand-written entry may record only a checksum. There is no upstream
// to compare, so the check cannot conclude anything either way.
Expect(Verify(write(`
- name: a
files:
- filename: shared.gguf
sha256: aa
- name: b
files:
- filename: shared.gguf
sha256: bb
`))).To(BeEmpty())
})
})

View File

@@ -1,152 +0,0 @@
// Package galleryedit splices variant references into the LocalAI gallery index
// as TEXT.
//
// Re-serialising the index through a YAML marshaller would reflow 40,000 lines,
// drop the anchors and merge keys the gallery relies on, and produce a diff no
// reviewer could read, which makes a pull request worthless even when the
// content inside it is right. Every generator that adds variants to an entry the
// gallery already ships therefore edits lines, and they share this package so
// that two of them cannot drift apart on where a variants block belongs.
package galleryedit
import (
"fmt"
"regexp"
"sort"
"strings"
)
var (
entryStart = regexp.MustCompile(`^-(?: |$)`)
inlineName = regexp.MustCompile(`^- (?:&\S+ )?name:`)
keyName = regexp.MustCompile(`^ name:`)
keyVariants = regexp.MustCompile(`^ variants:\s*(.*)$`)
variantItem = regexp.MustCompile(`^ - `)
unsafeInName = regexp.MustCompile(`[:#{}\[\],&*?|>'"%@` + "`" + `]|^\s|\s$`)
)
// Entry is the positional view of one gallery entry: what it is called and
// which lines it occupies. Nothing about what the entry MEANS belongs here, so
// each caller keeps its own semantic decode and only hands over the coordinates.
type Entry struct {
Name string
// StartLine and EndLine bound the entry, zero based and half open.
StartLine int
EndLine int
}
// Insert is one entry's pending variants addition. The caller owns the contents
// of Variants: this package neither orders nor deduplicates them, because the
// right order and the right dedup rule differ between generators.
type Insert struct {
Entry Entry
Variants []string
}
// Scan splits index text into lines and reports the line each top level list
// item begins on.
func Scan(text string) (lines []string, starts []int) {
lines = strings.Split(text, "\n")
for i, line := range lines {
if entryStart.MatchString(line) {
starts = append(starts, i)
}
}
return lines, starts
}
// Apply splices every insert into the index lines and returns the new text.
func Apply(lines []string, inserts []Insert) ([]string, error) {
type edit struct {
at int
remove int
insert []string
}
var edits []edit
for _, in := range inserts {
if len(in.Variants) == 0 {
continue
}
items := make([]string, 0, len(in.Variants))
for _, v := range in.Variants {
items = append(items, " - model: "+QuoteName(v))
}
at, remove, err := insertionPoint(lines, in.Entry)
if err != nil {
return nil, err
}
block := items
if remove > 0 || !hasVariantsKey(lines, in.Entry) {
block = append([]string{" variants:"}, items...)
}
edits = append(edits, edit{at: at, remove: remove, insert: block})
}
// Applying from the bottom up keeps every line number computed against the
// original text valid while earlier edits are still pending.
sort.Slice(edits, func(i, j int) bool { return edits[i].at > edits[j].at })
out := append([]string(nil), lines...)
for _, e := range edits {
tail := append([]string(nil), out[e.at+e.remove:]...)
out = append(out[:e.at], append(append([]string(nil), e.insert...), tail...)...)
}
return out, nil
}
func hasVariantsKey(lines []string, e Entry) bool {
for i := e.StartLine; i < e.EndLine; i++ {
if keyVariants.MatchString(lines[i]) {
return true
}
}
return false
}
// insertionPoint reports where new variant items belong, and how many existing
// lines the insertion replaces.
//
// An entry with no variants key gets one right after its name, which is where
// the hand-written families put it. An entry with an empty "variants: []" has
// that line replaced by a block. An entry with a block gets its items appended.
func insertionPoint(lines []string, e Entry) (at int, remove int, err error) {
for i := e.StartLine; i < e.EndLine; i++ {
m := keyVariants.FindStringSubmatch(lines[i])
if m == nil {
continue
}
if strings.TrimSpace(m[1]) == "[]" {
return i, 1, nil
}
if strings.TrimSpace(m[1]) != "" {
return 0, 0, fmt.Errorf("entry %q writes its variants inline (%q); this job only edits block lists", e.Name, strings.TrimSpace(m[1]))
}
last := i
for j := i + 1; j < e.EndLine && variantItem.MatchString(lines[j]); j++ {
last = j
}
return last + 1, 0, nil
}
if inlineName.MatchString(lines[e.StartLine]) {
return e.StartLine + 1, 0, nil
}
for i := e.StartLine; i < e.EndLine; i++ {
if keyName.MatchString(lines[i]) {
return i + 1, 0, nil
}
}
return 0, 0, fmt.Errorf("entry %q has no name line to anchor the insertion to", e.Name)
}
// QuoteName quotes a variant reference when the name would otherwise change
// meaning as bare YAML. Config-suffixed names carry a ":" and always need it.
func QuoteName(name string) string {
if unsafeInName.MatchString(name) {
return `"` + strings.ReplaceAll(name, `"`, `\"`) + `"`
}
return name
}

View File

@@ -2,41 +2,119 @@ package main
import (
"fmt"
"regexp"
"sort"
"strings"
)
"github.com/mudler/LocalAI/.github/ci/galleryedit"
var (
inlineName = regexp.MustCompile(`^- (?:&\S+ )?name:`)
keyName = regexp.MustCompile(`^ name:`)
keyVariants = regexp.MustCompile(`^ variants:\s*(.*)$`)
variantItem = regexp.MustCompile(`^ - `)
unsafeInName = regexp.MustCompile(`[:#{}\[\],&*?|>'"%@` + "`" + `]|^\s|\s$`)
)
// ApplyFamilies writes the proposed variant lists into the index text.
//
// The line editing itself lives in galleryedit, shared with the apexentries
// generator. Both jobs add variants to entries the gallery already ships, and a
// second answer to "where does a variants block go" would drift from this one;
// see that package for why the edit is textual rather than a YAML round trip.
// The edit is textual on purpose. Re-serialising the index through a YAML
// marshaller would reflow 40,000 lines, drop the anchors and merge keys the
// gallery relies on, and produce a diff no reviewer could read, which would
// make the pull request worthless even when the proposals inside it are right.
func ApplyFamilies(ix *Index, families []Family) ([]string, error) {
byName, _ := ix.ByName()
var inserts []galleryedit.Insert
type edit struct {
at int
remove int
insert []string
ordinal int
}
var edits []edit
for _, f := range families {
entry, ok := byName[strings.ToLower(f.Parent)]
if !ok {
return nil, fmt.Errorf("parent %q is not in the index", f.Parent)
}
variants := make([]string, 0, len(f.Proposals))
items := make([]string, 0, len(f.Proposals))
for _, p := range f.Proposals {
variants = append(variants, p.Variant)
items = append(items, " - model: "+quoteName(p.Variant))
}
inserts = append(inserts, galleryedit.Insert{
Entry: galleryedit.Entry{
Name: entry.Name,
StartLine: entry.StartLine,
EndLine: entry.EndLine,
},
Variants: variants,
})
at, remove, err := insertionPoint(ix, entry)
if err != nil {
return nil, err
}
insert := items
if remove > 0 || !hasVariantsKey(ix, entry) {
insert = append([]string{" variants:"}, items...)
}
edits = append(edits, edit{at: at, remove: remove, insert: insert, ordinal: entry.Index})
}
return galleryedit.Apply(ix.Lines, inserts)
// Applying from the bottom up keeps every line number computed against the
// original text valid while earlier edits are still pending.
sort.Slice(edits, func(i, j int) bool { return edits[i].at > edits[j].at })
lines := append([]string(nil), ix.Lines...)
for _, e := range edits {
tail := append([]string(nil), lines[e.at+e.remove:]...)
lines = append(lines[:e.at], append(append([]string(nil), e.insert...), tail...)...)
}
return lines, nil
}
func hasVariantsKey(ix *Index, e *GalleryEntry) bool {
for i := e.StartLine; i < e.EndLine; i++ {
if keyVariants.MatchString(ix.Lines[i]) {
return true
}
}
return false
}
// insertionPoint reports where new variant items belong, and how many existing
// lines the insertion replaces.
//
// An entry with no variants key gets one right after its name, which is where
// the hand-written families put it. An entry with an empty "variants: []" has
// that line replaced by a block. An entry with a block gets its items appended.
func insertionPoint(ix *Index, e *GalleryEntry) (at int, remove int, err error) {
for i := e.StartLine; i < e.EndLine; i++ {
m := keyVariants.FindStringSubmatch(ix.Lines[i])
if m == nil {
continue
}
if strings.TrimSpace(m[1]) == "[]" {
return i, 1, nil
}
if strings.TrimSpace(m[1]) != "" {
return 0, 0, fmt.Errorf("entry %q writes its variants inline (%q); this job only edits block lists", e.Name, strings.TrimSpace(m[1]))
}
last := i
for j := i + 1; j < e.EndLine && variantItem.MatchString(ix.Lines[j]); j++ {
last = j
}
return last + 1, 0, nil
}
if inlineName.MatchString(ix.Lines[e.StartLine]) {
return e.StartLine + 1, 0, nil
}
for i := e.StartLine; i < e.EndLine; i++ {
if keyName.MatchString(ix.Lines[i]) {
return i + 1, 0, nil
}
}
return 0, 0, fmt.Errorf("entry %q has no name line to anchor the insertion to", e.Name)
}
// quoteName quotes a variant reference when the name would otherwise change
// meaning as bare YAML. Config-suffixed names carry a ":" and always need it.
func quoteName(name string) string {
if unsafeInName.MatchString(name) {
return `"` + strings.ReplaceAll(name, `"`, `\"`) + `"`
}
return name
}

View File

@@ -8,8 +8,6 @@ import (
"strings"
"gopkg.in/yaml.v3"
"github.com/mudler/LocalAI/.github/ci/galleryedit"
)
// File is the subset of a gallery file entry the proposer reads.
@@ -59,6 +57,7 @@ type Index struct {
}
var (
entryStart = regexp.MustCompile(`^-(?: |$)`)
anchorStart = regexp.MustCompile(`^- &(\S+)`)
mergeStart = regexp.MustCompile(`^- !!merge <<: \*(\S+)`)
)
@@ -83,7 +82,13 @@ func ParseIndex(text string) (*Index, error) {
return nil, fmt.Errorf("decoding gallery index: %w", err)
}
lines, starts := galleryedit.Scan(text)
lines := strings.Split(text, "\n")
var starts []int
for i, line := range lines {
if entryStart.MatchString(line) {
starts = append(starts, i)
}
}
if len(starts) != len(entries) {
return nil, fmt.Errorf("gallery index has %d decoded entries but %d top level list items; refusing to edit by line number", len(entries), len(starts))
}

39
.github/gh_curl.sh vendored
View File

@@ -1,39 +0,0 @@
#!/bin/bash
# Shared curl wrapper for the nightly dependency-bump scripts.
#
# The bump workflow fans out to ~25 parallel matrix jobs, each querying
# api.github.com. Anonymous API calls are capped at 60/hour per source IP and
# GitHub-hosted runners egress through shared NAT addresses, so a random handful
# of jobs were getting rate-limited (HTTP 403 -> curl exit 22, empty response)
# every single night. Authenticating with GITHUB_TOKEN lifts the ceiling to
# 1000/hour; the retries absorb whatever transient blips remain.
# Wraps curl with GitHub auth (when a token is present) plus retry/timeout
# hardening. Callers pass their own headers and the URL.
gh_curl() {
# The bump scripts run under `set -x`; without this the Authorization header
# would be echoed into the job log on every call.
local had_xtrace=0
case "$-" in
*x*) had_xtrace=1; set +x ;;
esac
local args=(
--silent --show-error --location --fail
# --retry-all-errors so 403 rate-limit responses are retried too; plain
# --retry only covers 408/429/5xx. curl honours Retry-After when sent.
--retry 5 --retry-delay 3 --retry-all-errors
--connect-timeout 15 --max-time 60
)
if [ -n "${GITHUB_TOKEN:-}" ]; then
args+=(--header "Authorization: Bearer ${GITHUB_TOKEN}")
fi
curl "${args[@]}" "$@"
local rc=$?
if [ "$had_xtrace" -eq 1 ]; then
set -x
fi
return $rc
}

View File

@@ -6,14 +6,6 @@ on:
- master
pull_request:
# Supersede an in-flight run when a PR gets a new push. Keyed on the PR number
# so every push to the same PR shares a group; on a master push the key falls
# back to github.sha (unique per commit) and cancel-in-progress is false, so
# master runs never cancel each other -- each commit is built on its own.
concurrency:
group: ci-build-test-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build-test:
runs-on: ubuntu-latest

View File

@@ -115,12 +115,6 @@ jobs:
- uses: actions/checkout@v7
- name: Bump dependencies 🔧
id: bump
env:
# This job fans out to ~25 parallel matrix entries, all querying
# api.github.com from runner IPs that share the 60/hour anonymous
# rate limit. Authenticating raises it to 1000/hour, which is what
# kept a random handful of these red every night.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bash .github/bump_deps.sh ${{ matrix.repository }} ${{ matrix.branch }} ${{ matrix.variable }} ${{ matrix.file }}
{
@@ -157,8 +151,6 @@ jobs:
- uses: actions/checkout@v7
- name: Bump vLLM cu130 wheel pin 🔧
id: bump
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bash .github/bump_vllm_wheel.sh vllm-project/vllm backend/python/vllm/requirements-cublas13-after.txt VLLM_VERSION
{
@@ -195,8 +187,6 @@ jobs:
- uses: actions/checkout@v7
- name: Bump vllm-metal pin 🔧
id: bump
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bash .github/bump_vllm_metal.sh vllm-project/vllm-metal backend/python/vllm/install.sh VLLM_METAL_VERSION
{

View File

@@ -15,10 +15,6 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Bump dependencies 🔧
env:
# Authenticated API calls get 1000 req/hour instead of the 60/hour
# anonymous cap that is shared across every job on the runner's IP.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bash .github/bump_docs.sh ${{ matrix.repository }}
- name: Create Pull Request

View File

@@ -1,39 +0,0 @@
---
# The packages under .github/ci/ are invisible to `go list ./...`, so neither
# `make lint` nor the repository test run ever touches them. Their specs are
# dead weight until a workflow names each package explicitly.
name: 'CI tool tests'
on:
pull_request:
paths:
- '.github/ci/**'
- '.github/workflows/ci-tools-tests.yaml'
push:
branches:
- master
paths:
- '.github/ci/**'
jobs:
ci-tools:
name: 'Test the .github/ci generators'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
# The discovery heuristics are the risky part of these tools. A regression
# produces confident, wrong gallery entries, which is worse than no tool.
- name: 'Test the APEX entry generator'
run: go test ./.github/ci/apexentries/
- name: 'Test the variant proposer'
run: go test ./.github/ci/variantproposals/
# Shared by both generators above. Its behaviour is exercised through their
# specs; this step exists so a break in the shared package fails under its
# own name rather than as a puzzling failure in whichever caller ran first.
- name: 'Test the shared gallery editor'
run: go test ./.github/ci/galleryedit/

View File

@@ -7,19 +7,6 @@ on:
schedule:
- cron: '0 0 * * 0'
# `push:` is deliberately unfiltered, so this fires on every push to every
# branch and there is no pull_request event to key on -- the usual
# `github.event.pull_request.number || github.sha` idiom used elsewhere would
# key on the unique-per-commit sha and dedup nothing. Group on the ref instead
# so successive pushes to the same feature branch supersede one another.
#
# Cancelling is safe here: the only output is a SARIF upload, and code scanning
# tracks the latest result per ref, so a superseded scan has nothing to lose.
# master is excluded anyway -- every commit on master gets its own scan.
concurrency:
group: ci-secscan-${{ github.ref }}-${{ github.repository }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
tests:
runs-on: ubuntu-latest

View File

@@ -1,11 +1,6 @@
name: 'Yamllint GitHub Actions'
on:
- pull_request
concurrency:
group: ci-yamllint-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
yamllint:
name: 'Yamllint'

7
.gitignore vendored
View File

@@ -111,10 +111,3 @@ core/http/react-ui/test-results/
# the realtime-conformance gate; only the .fizz sources are authoritative.
formal-verification/*.json
formal-verification/out/
# `go build ./.github/ci/apexentries` drops a binary of the package name into
# whatever directory it runs in, one `git add -A` away from being committed.
# Both paths are anchored: an unanchored `apexentries` would also match the
# package directory itself and untrack the source.
/apexentries
/.github/ci/apexentries/apexentries

View File

@@ -1,7 +1,7 @@
# Pinned to the HEAD of the `prism` branch on https://github.com/PrismML-Eng/llama.cpp.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
BONSAI_VERSION?=7529fdaaf99ffdc5ca71ace9c7409a56b27ad92f
BONSAI_VERSION?=9fcaed763ccda38ea81068ad9d7f991aaddca451
LLAMA_REPO?=https://github.com/PrismML-Eng/llama.cpp
CMAKE_ARGS?=

View File

@@ -76,13 +76,12 @@ elseif(DS4_GPU STREQUAL "cpu")
set(DS4_OBJS "${DS4_DIR}/ds4_cpu.o")
endif()
# Upstream splits distributed inference, tensor-parallel transport, the SSD
# expert cache, and layer placement into GPU-agnostic translation units. Link
# them regardless of DS4_GPU.
# ds4.c now references ds4_distributed.c (distributed inference) and ds4_ssd.c
# (SSD expert-cache), each split into its own translation unit upstream. Both
# are GPU-agnostic objects shared by every GPU mode, so link them in regardless
# of DS4_GPU.
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_distributed.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_tp.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_ssd.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_layer_pack.o")
add_executable(${TARGET}
grpc-server.cpp

View File

@@ -1,10 +1,10 @@
# ds4 backend Makefile.
#
# Upstream pin lives below as DS4_VERSION?=efdadd41e20134af4f3381e1ed90e96fe4faef6f
# Upstream pin lives below as DS4_VERSION?=80ebbc396aee40eedc1d829222f3362d10fa4c6c
# (.github/bump_deps.sh) can find and update it - matches the
# llama-cpp / ik-llama-cpp / turboquant convention.
DS4_VERSION?=efdadd41e20134af4f3381e1ed90e96fe4faef6f
DS4_VERSION?=80ebbc396aee40eedc1d829222f3362d10fa4c6c
DS4_REPO?=https://github.com/antirez/ds4
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
@@ -18,19 +18,20 @@ UNAME_S := $(shell uname -s)
CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
# Upstream splits distributed inference, tensor-parallel transport, the SSD
# expert cache, and layer placement into GPU-agnostic translation units. They
# are shared by every GPU mode, so append them unconditionally below.
# ds4_distributed.o and ds4_ssd.o are GPU-agnostic translation units that
# ds4.c/ds4_cpu.o now reference (upstream split distributed inference and the
# SSD expert-cache into their own .c files). Both objects are shared by every
# GPU mode, so they are appended unconditionally below.
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS += -DDS4_GPU=cuda
DS4_OBJ_TARGET := ds4.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
DS4_OBJ_TARGET := ds4.o ds4_cuda.o ds4_distributed.o ds4_ssd.o
else ifeq ($(UNAME_S),Darwin)
CMAKE_ARGS += -DDS4_GPU=metal
DS4_OBJ_TARGET := ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
DS4_OBJ_TARGET := ds4.o ds4_metal.o ds4_distributed.o ds4_ssd.o
else
# CPU reference path (Linux only - macOS CPU path is broken by VM bug per ds4 README).
CMAKE_ARGS += -DDS4_GPU=cpu
DS4_OBJ_TARGET := ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
DS4_OBJ_TARGET := ds4_cpu.o ds4_distributed.o ds4_ssd.o
endif
ifneq ($(NATIVE),true)
@@ -55,11 +56,11 @@ ds4:
# the right per-platform compile flags (Objective-C/Metal on Darwin, nvcc on Linux+CUDA).
ds4/ds4.o: ds4
ifeq ($(BUILD_TYPE),cublas)
+$(MAKE) -C ds4 ds4.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+$(MAKE) -C ds4 ds4.o ds4_cuda.o ds4_distributed.o ds4_ssd.o
else ifeq ($(UNAME_S),Darwin)
+$(MAKE) -C ds4 ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+$(MAKE) -C ds4 ds4.o ds4_metal.o ds4_distributed.o ds4_ssd.o
else
+$(MAKE) -C ds4 ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+$(MAKE) -C ds4 ds4_cpu.o ds4_distributed.o ds4_ssd.o
endif
grpc-server: ds4/ds4.o

View File

@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=e5357286c0d433cd4384e82ed7e2b6d655f57087
IK_LLAMA_VERSION?=9d07d8681ece159a89fb4e16a1f9c9f3a5fac20f
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=

View File

@@ -1,7 +1,7 @@
# Pinned to the HEAD of feature/turboquant-kv-cache on https://github.com/TheTom/llama-cpp-turboquant.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
TURBOQUANT_VERSION?=c26cbdffcf6fc9b7430cd6b117757e9a3f70b7ea
TURBOQUANT_VERSION?=7d9715f1f071fa07c7b2ad3dbfd320b314139e65
LLAMA_REPO?=https://github.com/TheTom/llama-cpp-turboquant
CMAKE_ARGS?=

View File

@@ -1,18 +1,50 @@
hip: port the turboquant CUDA additions that ggml's HIP shim doesn't cover
The turboquant fork creates backend events with plain cudaEventCreate,
which ggml's HIP shim does not alias (it only aliases
cudaEventCreateWithFlags). Use cudaEventCreateWithFlags(...,
cudaEventDisableTiming), exactly as the rest of this file does.
The turboquant fork adds/modifies a few ggml-cuda.cu spots with CUDA APIs
that ggml's HIP (and MUSA) compatibility layer does not provide, breaking
the -gpu-rocm-hipblas-turboquant build:
CUDA builds are unaffected. Drop this patch once the fork HIP-ports the
event creation; apply-patches.sh fails fast if the anchor goes stale.
1. ggml_cuda_copy2d_across_devices() (host-staged cross-device copy for
split mul_mat output) uses the CUDA 3D-peer copy APIs
cudaMemcpy3DPeerParms / make_cudaPitchedPtr / make_cudaExtent /
cudaMemcpy3DPeerAsync. HIP genuinely does not support these (see the
fork's own comment "HIP does not support cudaMemcpy3DPeerAsync"), so
guard the peer fast path with #if !defined(GGML_USE_HIP) &&
!defined(GGML_USE_MUSA) -- matching how the fork already guards the
same API for the sibling 2D copy -- and fall through to the existing
cudaMemcpyAsync staging fallback below (functionally identical,
slightly slower on multi-GPU ROCm).
2. ggml_backend_cuda_device_event_new() creates its event with plain
cudaEventCreate, which ggml's HIP shim does not alias (it only aliases
cudaEventCreateWithFlags). Use cudaEventCreateWithFlags(...,
cudaEventDisableTiming) -- exactly what the rest of this file already
does (cf. lines ~1034, ~3461) and HIP-safe.
CUDA builds are unaffected. Drop the relevant hunk once the fork HIP-ports
these; apply-patches.sh fails fast if an anchor goes stale.
diff --git a/ggml/src/ggml-cuda/ggml-cuda.cu b/ggml/src/ggml-cuda/ggml-cuda.cu
index 7d35c1a..2908acb 100644
index 0427e6b..6352e6a 100644
--- a/ggml/src/ggml-cuda/ggml-cuda.cu
+++ b/ggml/src/ggml-cuda/ggml-cuda.cu
@@ -5795,7 +5795,7 @@ static ggml_backend_event_t ggml_backend_cuda_device_event_new(ggml_backend_dev_
@@ -1933,6 +1933,7 @@ static cudaError_t ggml_cuda_copy2d_across_devices(
size_t width, size_t height, cudaStream_t dst_stream, cudaStream_t src_stream) {
const auto & info = ggml_cuda_info();
+#if !defined(GGML_USE_HIP) && !defined(GGML_USE_MUSA) // 3D-peer copy types unmapped by ggml's HIP/MUSA shim; use staging fallback below
if (info.peer_access[src_device][dst_device]) {
cudaMemcpy3DPeerParms p = {};
p.dstDevice = dst_device;
@@ -1942,6 +1943,7 @@ static cudaError_t ggml_cuda_copy2d_across_devices(
p.extent = make_cudaExtent(width, height, 1);
return cudaMemcpy3DPeerAsync(&p, dst_stream);
}
+#endif // !defined(GGML_USE_HIP) && !defined(GGML_USE_MUSA)
// Fallback: stage all rows through a single contiguous pinned buffer
int prev_device = ggml_cuda_get_device();
@@ -5714,7 +5716,7 @@ static ggml_backend_event_t ggml_backend_cuda_device_event_new(ggml_backend_dev_
ggml_cuda_set_device(dev_ctx->device);
cudaEvent_t event;

View File

@@ -1,6 +1,6 @@
# ced sound-classification backend Makefile.
#
# Upstream pin lives below as CED_VERSION?=db5aae02973a745722d6fbd2157cab1999106777
# Upstream pin lives below as CED_VERSION?=<sha> so .github/bump_deps.sh can find
# and update it (matches the parakeet-cpp / whisper.cpp convention).
#
# Local dev shortcut: symlink an out-of-tree ced.cpp shared build + header and
@@ -9,7 +9,7 @@
# ln -sf /path/to/ced.cpp/include/ced_capi.h .
# go build -o ced-grpc .
CED_VERSION?=db5aae02973a745722d6fbd2157cab1999106777
CED_VERSION?=c04ac14b7992d00584d9e812c9bb6268598a6ce7
CED_REPO?=https://github.com/localai-org/ced.cpp
GOCMD?=go

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
CRISPASR_VERSION?=3ab5f4ac13685966b47cc75dc7fd02f3c4a51beb
CRISPASR_VERSION?=5fca47ecf05cd68bb0075f8a00fe04da06f208d0
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -10,7 +10,7 @@ JOBS?=$(shell nproc --ignore=1)
# this on `master` always picks up the latest C-API surface (incl. the
# per-detection accessor functions used by golocateanythingcpp.go).
LOCATEANYTHING_REPO?=https://github.com/mudler/locate-anything.cpp.git
LOCATEANYTHING_VERSION?=77376ab332de918220f7a7e391542eefb5407c9f
LOCATEANYTHING_VERSION?=ade2634f7f79b56121125e5885628744795a478f
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# omnivoice.cpp version
OMNIVOICE_REPO?=https://github.com/ServeurpersoCom/omnivoice.cpp
OMNIVOICE_VERSION?=4f33af825d66e6ef1cb185e87b4589cacf747291
OMNIVOICE_VERSION?=f39cc4a3af988091f662313b336dddf8c83a3fb5
SO_TARGET?=libgomnivoicecpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# qwentts.cpp version
QWEN3TTS_REPO?=https://github.com/ServeurpersoCom/qwentts.cpp
QWEN3TTS_CPP_VERSION?=82cd05b9f3a175612dc89fd6943e610fab096ef5
QWEN3TTS_CPP_VERSION?=e93292bee1778854ab7dcb2d325ffe531fef910f
SO_TARGET?=libgoqwen3ttscpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# stablediffusion.cpp (ggml)
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
STABLEDIFFUSION_GGML_VERSION?=8a51eb92848c1327a5aaeff5ad81a7a9a2435255
STABLEDIFFUSION_GGML_VERSION?=ea4e566ccffa10f853ecc3f29e74b1820bc91beb
CMAKE_ARGS+=-DGGML_MAX_NAME=128

View File

@@ -401,16 +401,16 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
"sample_query": request.text or "",
"sample_mode": True,
"thinking": True,
"vocal_language": request.language or "en",
"vocal_language": request.language or request.GetLanguage() or "en",
"instrumental": request.instrumental if request.HasField("instrumental") else False,
}
else:
caption = request.caption or request.text
caption = request.caption or request.GetCaption() or request.text
payload = {
"prompt": caption,
"lyrics": request.lyrics or request.lyrics or "",
"thinking": request.think if request.HasField("think") else False,
"vocal_language": request.language or "en",
"vocal_language": request.language or request.GetLanguage() or "en",
}
if request.HasField("bpm"):
payload["bpm"] = request.bpm

View File

@@ -398,80 +398,40 @@ function ensureVenv() {
function runProtogen() {
ensureVenv
# The protoc that grpcio-tools bundles stamps a Protobuf "gencode" version
# into backend_pb2.py, and Protobuf refuses to import a stub whose gencode is
# newer than the installed runtime. Left unpinned, grpcio-tools resolves to
# the newest release (1.82.1, gencode 7.35.0) which a backend holding the
# runtime lower (vLLM resolves protobuf to 6.33.6) then cannot import,
# crashing with "grpc service not ready" before it ever loads a model.
#
# Resolve the generator in a THROWAWAY environment so the backend's own venv
# keeps exactly the dependency set its requirements files declared. The
# generator is a build tool and has no business editing the runtime deps: the
# spec below is chosen to fit the venv as it stands, never to change it.
# Falls back to unpinned when neither version can be detected.
# See mudler/LocalAI#10718, #10940.
local protobuf_version grpcio_version protogen_env protogen_python
protobuf_version="$(python -c 'import importlib.metadata as m; print(m.version("protobuf"))' 2>/dev/null || true)"
grpcio_version="$(python -c 'import importlib.metadata as m; print(m.version("grpcio"))' 2>/dev/null || true)"
# The stubs impose TWO independent constraints on the generator, and both
# have to hold or the backend dies on import:
#
# backend_pb2.py needs protobuf runtime >= gencode
# backend_pb2_grpc.py needs installed grpcio >= grpcio-tools
#
# Bound grpcio-tools from both sides and let the resolver find the newest
# version that satisfies them. The protobuf ceiling makes it back off to an
# older grpcio-tools when the runtime is behind, which is what bounds the
# gencode; the grpcio ceiling keeps the _grpc stub loadable. Pinning only one
# side is what made the earlier attempts fail, in both directions.
local -a protogen_spec=("grpcio-tools")
if [ -n "${grpcio_version}" ]; then
protogen_spec=("grpcio-tools<=${grpcio_version}")
# Match grpcio-tools to the grpcio already installed by the backend's
# requirements. grpcio and grpcio-tools are released in lockstep, and the
# protoc that grpcio-tools bundles stamps a Protobuf "gencode" version into
# backend_pb2.py. Left unpinned, `uv pip install grpcio-tools` pulls the
# newest release, whose newer gencode (e.g. 7.35.0) trips Protobuf's
# runtime >= gencode guarantee at import time when a backend caps the
# protobuf runtime lower (vLLM pins it to 6.33.6), crashing the backend with
# "grpc service not ready" before it ever loads a model. Pinning
# grpcio-tools to the installed grpcio version keeps the gencode in step with
# the runtime. Backends whose protobuf runtime trails grpcio can set
# GRPCIO_TOOLS_VERSION to the newest generator their runtime accepts. Falls
# back to unpinned when grpcio isn't installed yet.
# See mudler/LocalAI#10718.
local grpcio_tools_spec="grpcio-tools"
local grpcio_version
if [ -n "${GRPCIO_TOOLS_VERSION:-}" ]; then
grpcio_tools_spec="grpcio-tools==${GRPCIO_TOOLS_VERSION}"
else
grpcio_version="$(python -c 'import importlib.metadata as m; print(m.version("grpcio"))' 2>/dev/null || true)"
fi
if [ -n "${protobuf_version}" ]; then
protogen_spec+=("protobuf<=${protobuf_version}")
if [ "${grpcio_tools_spec}" = "grpcio-tools" ] && [ -n "${grpcio_version}" ]; then
grpcio_tools_spec="grpcio-tools==${grpcio_version}"
fi
protogen_env="$(mktemp -d)"
if [ "x${USE_PIP}" == "xtrue" ]; then
python -m venv "${protogen_env}"
"${protogen_env}/bin/pip" install "${protogen_spec[@]}"
pip install "${grpcio_tools_spec}"
else
uv venv "${protogen_env}"
VIRTUAL_ENV="${protogen_env}" uv pip install "${protogen_spec[@]}"
uv pip install "${grpcio_tools_spec}"
fi
protogen_python="${protogen_env}/bin/python"
pushd "${EDIR}" >/dev/null
# Drop the cached bytecode along with the sources. CPython validates a
# .pyc against the source's mtime *and size*, both of which can be
# unchanged across a regeneration (the gencode triple is the same width
# whether it reads 7.35.0 or 6.33.5), so a stale backend_pb2.pyc can be
# reused in place of the stub we just generated.
rm -f backend_pb2.py backend_pb2.pyi backend_pb2_grpc.py
rm -rf __pycache__
# Generate with the throwaway toolchain; the output is plain Python and
# carries no dependency on the interpreter that produced it.
"${protogen_python}" -m grpc_tools.protoc -I../../ -I./ --python_out=. --grpc_python_out=. backend.proto
# Verify with the BACKEND's python, which is the interpreter that has to
# import these at model load. Fail the build rather than ship a backend
# that cannot import its own stubs: otherwise the gencode/runtime
# mismatch only surfaces in a released image, as an opaque
# "grpc service not ready".
# Check BOTH stubs: backend_pb2 catches a gencode ahead of the protobuf
# runtime, backend_pb2_grpc catches generated code ahead of the installed
# grpcio. Checking only the former lets the latter reach CI, or users.
if ! python -c 'import backend_pb2, backend_pb2_grpc' >/dev/null; then
echo "runProtogen: generated stubs are not importable by the backend venv (protobuf ${protobuf_version:-unknown}, grpcio ${grpcio_version:-unknown})" >&2
exit 1
fi
# use the venv python (ensures correct interpreter & sys.path)
python -m grpc_tools.protoc -I../../ -I./ --python_out=. --grpc_python_out=. backend.proto
popd >/dev/null
rm -rf "${protogen_env}"
}

View File

@@ -20,8 +20,6 @@ Enforcement is deliberately narrow: it compares two strings and never inspects
the model itself.
"""
import asyncio
import inspect
import threading
import grpc
@@ -183,40 +181,6 @@ class ModelIdentityInterceptor(grpc.ServerInterceptor):
return _rebuild(handler, guard)
_STREAM_DONE = object()
def _next_or_done(iterator):
"""next(iterator), returning the _STREAM_DONE sentinel at exhaustion.
StopIteration must not propagate out of a function run via run_in_executor:
it cannot travel through a Future and would surface as an opaque error.
"""
try:
return next(iterator)
except StopIteration:
return _STREAM_DONE
async def _call_behavior(behavior, request, context):
"""Invoke a unary servicer behavior without blocking the event loop.
Native async behavior is awaited directly. A sync behavior -- many backends
define `def LoadModel` / `def Embedding`, not `async def` -- is dispatched to
a worker thread so a slow load/inference cannot freeze all aio RPC handling,
mirroring grpc.aio's own sync-handler adaptation. A callable wrapper that
returns an awaitable is supported too: the (cheap) call runs in the thread,
then the awaitable is awaited back on the loop.
"""
if inspect.iscoroutinefunction(behavior):
return await behavior(request, context)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, behavior, request, context)
if inspect.isawaitable(result):
result = await result
return result
class AsyncModelIdentityInterceptor(grpc.aio.ServerInterceptor):
"""Async counterpart for backends running grpc.aio servers."""
@@ -236,11 +200,7 @@ class AsyncModelIdentityInterceptor(grpc.aio.ServerInterceptor):
original = handler.unary_unary
async def record(request, context):
# A backend's LoadModel may be a plain sync method (many define
# `def LoadModel`, not `async def`). Dispatch it so it neither
# crashes with "object <T> can't be used in 'await'" nor runs its
# (potentially slow) body on the event loop thread.
result = await _call_behavior(original, request, context)
result = await original(request, context)
if getattr(result, "success", True):
self.state.record(getattr(request, "Model", ""))
return result
@@ -254,21 +214,8 @@ class AsyncModelIdentityInterceptor(grpc.aio.ServerInterceptor):
message = self.state.mismatch(getattr(request, "ModelIdentity", ""))
if message is not None:
await context.abort(grpc.StatusCode.NOT_FOUND, message)
# A sync backend yields a plain generator, an async one an async
# generator. Async: iterate directly. Sync: pull each item via a
# worker thread so a slow producer doesn't block the event loop
# (and so StopIteration can't escape through a Future).
stream = original_stream(request, context)
if hasattr(stream, "__aiter__"):
async for response in stream:
yield response
else:
loop = asyncio.get_running_loop()
while True:
item = await loop.run_in_executor(None, _next_or_done, stream)
if item is _STREAM_DONE:
break
yield item
async for response in original_stream(request, context):
yield response
return _rebuild(handler, guard_stream)
@@ -278,6 +225,6 @@ class AsyncModelIdentityInterceptor(grpc.aio.ServerInterceptor):
message = self.state.mismatch(getattr(request, "ModelIdentity", ""))
if message is not None:
await context.abort(grpc.StatusCode.NOT_FOUND, message)
return await _call_behavior(original_unary, request, context)
return await original_unary(request, context)
return _rebuild(handler, guard)

View File

@@ -9,9 +9,7 @@ failure modes are silent: enforcement that is wired up but never installed, and
enforcement that rejects requests it should serve.
"""
import asyncio
import os
import threading
import unittest
import grpc
@@ -66,13 +64,6 @@ def _handler(behavior, response_streaming=False):
return grpc.unary_unary_rpc_method_handler(behavior)
def _const_continuation(handler):
async def continuation(_):
return handler
return continuation
class TestInterceptorInstalled(unittest.TestCase):
"""The wiring, which is where this can silently do nothing.
@@ -313,176 +304,5 @@ class TestModalityMethods(unittest.TestCase):
self.assertNotIn(method, model_identity._GUARDED_METHODS)
class TestAsyncInterceptorBehavior(unittest.TestCase):
"""The grpc.aio counterpart, which had no behavioral coverage.
AsyncModelIdentityInterceptor wraps a backend's own servicer behavior. That
behavior may be sync or async: several backends define `def LoadModel` and
`def Embedding` (not `async def`), and grpc.aio's dispatch adapts both. The
interceptor invokes the behavior itself, so if it awaits unconditionally it
breaks every sync method it guards with "object <T> can't be used in
'await'". These tests exercise both shapes; the sync ones are the regression.
"""
def setUp(self):
self.interceptor = model_identity.AsyncModelIdentityInterceptor()
def _wrap(self, method, handler):
async def continuation(_):
return handler
return asyncio.run(
self.interceptor.intercept_service(continuation, _FakeCallDetails(method))
)
def _load(self, behavior, model):
wrapped = self._wrap("/backend.Backend/LoadModel", _handler(behavior))
return asyncio.run(wrapped.unary_unary(_Request(Model=model), _FakeContext()))
def _call_unary(self, behavior, identity):
wrapped = self._wrap("/backend.Backend/Predict", _handler(behavior))
return asyncio.run(
wrapped.unary_unary(_Request(ModelIdentity=identity), _FakeContext())
)
def _drain_stream(self, behavior, identity):
wrapped = self._wrap(
"/backend.Backend/PredictStream", _handler(behavior, response_streaming=True)
)
async def drain():
out = []
async for item in wrapped.unary_stream(
_Request(ModelIdentity=identity), _FakeContext()
):
out.append(item)
return out
return asyncio.run(drain())
# --- LoadModel: sync behavior is the regression, async must still work ---
def test_load_records_with_sync_behavior(self):
self._load(lambda request, context: _Result(), "a.gguf")
self.assertEqual(self.interceptor.state.loaded, "a.gguf")
def test_load_records_with_async_behavior(self):
async def behavior(request, context):
return _Result()
self._load(behavior, "a.gguf")
self.assertEqual(self.interceptor.state.loaded, "a.gguf")
def test_failed_sync_load_records_nothing(self):
self._load(lambda request, context: _Result(success=False), "a.gguf")
self.assertEqual(self.interceptor.state.loaded, "")
# --- guarded unary: sync and async behaviors both served / rejected ---
def test_guard_serves_sync_behavior(self):
self._load(lambda request, context: _Result(), "a.gguf")
result = self._call_unary(lambda request, context: "served", "a.gguf")
self.assertEqual(result, "served")
def test_guard_serves_async_behavior(self):
self._load(lambda request, context: _Result(), "a.gguf")
async def behavior(request, context):
return "served"
self.assertEqual(self._call_unary(behavior, "a.gguf"), "served")
def test_guard_rejects_mismatch(self):
self._load(lambda request, context: _Result(), "a.gguf")
with self.assertRaises(_Aborted):
self._call_unary(lambda request, context: "served", "b.gguf")
# --- guarded stream: sync generator and async generator both work ---
def test_guard_stream_serves_sync_generator(self):
self._load(lambda request, context: _Result(), "a.gguf")
def behavior(request, context):
yield "a"
yield "b"
self.assertEqual(self._drain_stream(behavior, "a.gguf"), ["a", "b"])
def test_guard_stream_serves_async_generator(self):
self._load(lambda request, context: _Result(), "a.gguf")
async def behavior(request, context):
yield "a"
yield "b"
self.assertEqual(self._drain_stream(behavior, "a.gguf"), ["a", "b"])
# --- sync behavior must not run on the event-loop thread ---
#
# Awaiting a sync method's return fixed the TypeError, but calling the
# (possibly slow) sync behavior on the event loop still froze all aio RPC
# handling. These record the thread each behavior runs on and assert it is a
# worker thread, not the loop thread.
def _run_capturing_loop_thread(self, method, handler, request):
captured = {}
async def run():
captured["loop"] = threading.get_ident()
wrapped = await self.interceptor.intercept_service(
_const_continuation(handler), _FakeCallDetails(method)
)
behavior = wrapped.unary_stream if handler.response_streaming else wrapped.unary_unary
if handler.response_streaming:
async for _ in behavior(request, _FakeContext()):
pass
else:
await behavior(request, _FakeContext())
asyncio.run(run())
return captured["loop"]
def test_sync_load_runs_off_the_event_loop(self):
ran = {}
def behavior(request, context):
ran["thread"] = threading.get_ident()
return _Result()
loop_thread = self._run_capturing_loop_thread(
"/backend.Backend/LoadModel", _handler(behavior), _Request(Model="a.gguf")
)
self.assertIn("thread", ran)
self.assertNotEqual(ran["thread"], loop_thread)
def test_sync_guarded_unary_runs_off_the_event_loop(self):
self._load(lambda request, context: _Result(), "a.gguf")
ran = {}
def behavior(request, context):
ran["thread"] = threading.get_ident()
return "served"
loop_thread = self._run_capturing_loop_thread(
"/backend.Backend/Predict", _handler(behavior), _Request(ModelIdentity="a.gguf")
)
self.assertNotEqual(ran["thread"], loop_thread)
def test_sync_stream_next_runs_off_the_event_loop(self):
self._load(lambda request, context: _Result(), "a.gguf")
ran = {}
def behavior(request, context):
ran["thread"] = threading.get_ident()
yield "a"
loop_thread = self._run_capturing_loop_thread(
"/backend.Backend/PredictStream",
_handler(behavior, response_streaming=True),
_Request(ModelIdentity="a.gguf"),
)
self.assertNotEqual(ran["thread"], loop_thread)
if __name__ == "__main__":
unittest.main()

View File

@@ -60,9 +60,3 @@ if [ "x${USE_PIP}" == "xtrue" ]; then
else
uv pip install "protobuf>=5.29.0"
fi
# Regenerate the stubs against the protobuf runtime settled on just above. The
# pin exists because transitive deps drag protobuf down; generating before that
# pin is applied is what stamps a gencode the runtime then rejects.
# See mudler/LocalAI#10718.
runProtogen

View File

@@ -68,11 +68,3 @@ if [ ! -x "${QUANTIZE_BIN}" ] && ! command -v llama-quantize &>/dev/null; then
echo "Warning: cmake not found — llama-quantize will not be available. Install cmake or provide llama-quantize on PATH."
fi
fi
# The stubs generated at the end of installRequirements were built against the
# protobuf runtime as it stood before the installs above, which resolve their own
# dependencies and can move that runtime. Regenerate now that the dependency set
# is final, so the gencode stamped into backend_pb2.py cannot be newer than the
# runtime that ships. Same failure mode as mudler/LocalAI#10718; runProtogen
# clears the previous stubs first, so this is idempotent.
runProtogen

View File

@@ -41,11 +41,3 @@ else
uv pip install "gguf>=0.16.0"
}
fi
# The stubs generated at the end of installRequirements were built against the
# protobuf runtime as it stood before the installs above, which resolve their own
# dependencies and can move that runtime. Regenerate now that the dependency set
# is final, so the gencode stamped into backend_pb2.py cannot be newer than the
# runtime that ships. Same failure mode as mudler/LocalAI#10718; runProtogen
# clears the previous stubs first, so this is idempotent.
runProtogen

View File

@@ -38,12 +38,4 @@ if [ ! -d VibeVoice ]; then
else
uv pip install ${EXTRA_PIP_INSTALL_FLAGS:-} .
fi
fi
# The stubs generated at the end of installRequirements were built against the
# protobuf runtime as it stood before the installs above, which resolve their own
# dependencies and can move that runtime. Regenerate now that the dependency set
# is final, so the gencode stamped into backend_pb2.py cannot be newer than the
# runtime that ships. Same failure mode as mudler/LocalAI#10718; runProtogen
# clears the previous stubs first, so this is idempotent.
runProtogen
fi

View File

@@ -87,9 +87,3 @@ else
fi
cd ..
# vllm-omni lands after installRequirements has already generated the protobuf
# stubs, and it re-resolves the protobuf runtime as it installs. Regenerate now
# that the dependency set is final, so the stubs cannot be newer than the runtime
# that ships. Same failure mode as mudler/LocalAI#10718.
runProtogen

View File

@@ -3,6 +3,12 @@ set -e
EXTRA_PIP_INSTALL_FLAGS="--no-build-isolation"
# grpcio 1.82 bundles protoc 7.x, while vLLM's resolved protobuf runtime is
# still 6.33.x. Generate the backend stubs with the newest grpcio-tools release
# that emits protobuf 6.x gencode; the newer grpcio runtime can consume those
# stubs, but protobuf 6.x cannot import 7.x gencode.
export GRPCIO_TOOLS_VERSION="1.78.0"
# Avoid to overcommit the CPU during build
# https://github.com/vllm-project/vllm/issues/20079
# https://docs.vllm.ai/en/v0.8.3/serving/env_vars.html
@@ -119,7 +125,7 @@ if [ "$(uname -s)" = "Darwin" ]; then
# can rewrite it. Darwin therefore follows vllm-metal and can lag the Linux
# vllm pin (requirements-cublas13-after.txt, bumped independently against
# vllm/vllm) until vllm-metal supports a newer vLLM.
VLLM_METAL_VERSION="v0.3.0.dev20260722081849"
VLLM_METAL_VERSION="v0.3.0.dev20260720105820"
# The coupled vLLM source version is whatever this vllm-metal release builds
# against -- it declares it in its own installer as `vllm_v=`. Derive it from
@@ -259,11 +265,3 @@ elif [ "x${BUILD_TYPE}" == "x" ] && [ "x${FROM_SOURCE:-}" == "xtrue" ]; then
else
installRequirements
fi
# installRequirements generates the protobuf stubs at the end of its own run, but
# most branches above install vllm *after* it, and vllm re-resolves the protobuf
# runtime when it lands. Stubs generated against the pre-vllm runtime can end up
# newer than the runtime that finally ships, which is exactly the gencode 7.35.0 /
# runtime 6.33.6 crash in mudler/LocalAI#10718. Regenerate once the dependency set
# is final; runProtogen clears the previous stubs first, so this is idempotent.
runProtogen

View File

@@ -356,18 +356,7 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
PrefixConfig: prefixCfg,
Pressure: pressure,
SharedModels: cfg.Distributed.SharedModels,
// A closure over the live ApplicationConfig, NOT a snapshot: the
// runtime setting (distributed_disk_headroom_check) mutates this exact
// member, so a snapshot here would make the toggle a no-op until
// restart. env/CLI sets the boot value, POST /api/settings overrides it
// live, and this is the single member both write.
DiskHeadroomEnabled: func() bool { return !cfg.Distributed.DiskHeadroomDisabled },
// RAW, not OrDefault: zero means "derive the budget per model from the
// checkpoint size" (config.ModelLoadTimeoutForSize), which is what makes
// a 70 GB video checkpoint work without the operator first hitting a
// DeadlineExceeded and going looking for a knob. A non-zero value here is
// an explicit override and is used verbatim.
ModelLoadTimeout: cfg.Distributed.ModelLoadTimeout,
ModelLoadTimeout: cfg.Distributed.ModelLoadTimeoutOrDefault(),
// Cap how long a cold load may hold the per-model advisory lock. Derived
// from BOTH configured budgets it has to cover, so raising either the
// install timeout (slow links pulling multi-GB images) or the model load
@@ -383,13 +372,6 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
// replica, not just the one performing the transfer. Without this, a
// /api/operations poll that round-robins onto a peer sees no staging row and
// the progress flickers. The origin publishes; peers mirror via the wildcard.
// A silently disabled safety check is how the original incident stayed
// invisible for sixteen minutes. Say so once, loudly, at startup.
if cfg.Distributed.DiskHeadroomDisabled {
xlog.Info("Disk-headroom admission check is DISABLED: node selection will ignore whether a worker can store the model, and staging may fail with ENOSPC partway through a transfer",
"knob", config.FlagDiskHeadroomCheck, "env", "LOCALAI_DISTRIBUTED_DISK_HEADROOM_CHECK")
}
router.StagingTracker().SetPublisher(natsClient)
if _, err := router.StagingTracker().SubscribeBroadcasts(natsClient); err != nil {
xlog.Warn("Failed to subscribe to staging progress broadcasts", "error", err)

View File

@@ -113,34 +113,11 @@ var _ = Describe("companion artifact backend options", func() {
Expect(opts.Options).To(Equal([]string{"attention_backend:sdpa"}))
})
It("names the source repository when the companion is not resolved yet", func() {
// A companion that reaches load time WITHOUT a resolved snapshot must not
// vanish silently: emitting no option lets the backend fall back to its own
// hardcoded default, which is how a distributed longcat-video worker ended
// up trying to load the wrong base model and failing "base_model must point
// to a LongCat-Video checkpoint". Naming the DECLARED repository instead
// points the backend at the artifact the config actually asked for. The
// snapshot path (the staged, no-download fast path) is still preferred
// whenever the companion IS resolved.
It("skips a companion that has not been resolved yet", func() {
cfg := configWithCompanion()
cfg.Artifacts[1].Resolved = nil
opts := grpcModelOpts(cfg, "/models")
value, found := optionValue(opts.Options, "base_model")
Expect(found).To(BeTrue())
Expect(value).To(Equal("meituan-longcat/LongCat-Video"))
// The fallback is a repo reference, never a models-relative snapshot path.
Expect(value).ToNot(ContainSubstring(".artifacts"))
})
It("prefers the resolved snapshot path over the source repository", func() {
opts := grpcModelOpts(configWithCompanion(), "/models")
value, found := optionValue(opts.Options, "base_model")
Expect(found).To(BeTrue())
expected, err := modelartifacts.RelativeSnapshotPath(companionKey)
Expect(err).NotTo(HaveOccurred())
Expect(value).To(Equal(expected))
// The resolved fast path must never degrade to a bare repo id.
Expect(value).ToNot(Equal("meituan-longcat/LongCat-Video"))
_, found := optionValue(opts.Options, "base_model")
Expect(found).To(BeFalse())
})
})

View File

@@ -294,13 +294,6 @@ func EffectiveBatchSize(c config.ModelConfig) int {
//
// An option the author set explicitly always wins: pinning a companion to a
// local checkout has to beat the managed snapshot.
//
// A companion that is declared but NOT resolved falls back to its source
// repository id rather than being dropped: a dropped companion is invisible to
// the backend, which then loads its own hardcoded default and fails far away
// from the cause. The repo-id fallback trades the staging fast path (the weights
// are fetched on the worker) for correctness, and logs a warning so the missing
// controller-side resolution is diagnosable.
func withCompanionArtifactOptions(options []string, artifacts []modelartifacts.Spec) []string {
configured := make(map[string]struct{}, len(options))
for _, option := range options {
@@ -313,46 +306,19 @@ func withCompanionArtifactOptions(options []string, artifacts []modelartifacts.S
// reallocate away from) the config's own slice.
combined := slices.Clone(options)
for _, artifact := range artifacts {
if artifact.Target != modelartifacts.TargetCompanion {
if artifact.Target != modelartifacts.TargetCompanion || artifact.Resolved == nil {
continue
}
if _, exists := configured[artifact.Name]; exists {
xlog.Debug("keeping the configured companion option over the managed snapshot", "artifact", artifact.Name)
continue
}
// Preferred fast path: a resolved companion is surfaced as its staged,
// models-relative snapshot directory. Staging materializes exactly this
// path on a remote worker and the backend resolves it under its own
// ModelPath, so the weights are never fetched again at load time.
if artifact.Resolved != nil {
if snapshot, err := modelartifacts.RelativeSnapshotPath(artifact.Resolved.CacheKey); err == nil {
xlog.Debug("surfacing resolved companion snapshot to the backend", "artifact", artifact.Name, "path", snapshot)
combined = append(combined, artifact.Name+":"+snapshot)
continue
} else {
xlog.Warn("companion artifact has an unusable cache key; falling back to its source repository", "artifact", artifact.Name, "error", err)
}
}
// Fallback: the companion reached load time without a resolved snapshot
// (its resolved state never made it into the config the loader is serving
// from, e.g. after a controller restart or a peer-replica config reload).
// Emitting nothing here is what makes the failure so hard to see: the
// backend then falls back to its OWN hardcoded default companion, which on
// a distributed longcat-video worker meant fetching the wrong base model
// and failing "base_model must point to a LongCat-Video checkpoint". Name
// the DECLARED repository instead, so the backend at least fetches the
// artifact the config actually asked for. It is a warn because it means the
// no-download fast path was lost: the controller-side materialization or
// persistence for this companion needs investigating.
if repo := strings.TrimSpace(artifact.Source.Repo); repo != "" {
xlog.Warn("companion artifact is not resolved on the controller; the backend will fetch it by repository id (no staging fast path)",
"artifact", artifact.Name, "repo", repo)
combined = append(combined, artifact.Name+":"+repo)
snapshot, err := modelartifacts.RelativeSnapshotPath(artifact.Resolved.CacheKey)
if err != nil {
xlog.Warn("skipping companion artifact with an unusable cache key", "artifact", artifact.Name, "error", err)
continue
}
xlog.Warn("companion artifact is neither resolved nor has a source repository; the backend will get no option for it", "artifact", artifact.Name)
combined = append(combined, artifact.Name+":"+snapshot)
}
return combined
}

View File

@@ -69,8 +69,6 @@ type RunCMD struct {
CORS bool `env:"LOCALAI_CORS,CORS" help:"" group:"api"`
CORSAllowOrigins string `env:"LOCALAI_CORS_ALLOW_ORIGINS,CORS_ALLOW_ORIGINS" group:"api"`
DisableCSRF bool `env:"LOCALAI_DISABLE_CSRF" help:"Disable CSRF middleware (enabled by default)" group:"api"`
DisableHTTPCompression bool `env:"LOCALAI_DISABLE_HTTP_COMPRESSION" default:"false" help:"Disable gzip compression of HTTP responses (enabled by default). Streaming endpoints are never compressed" group:"api"`
HTTPCompressionMinLength int `env:"LOCALAI_HTTP_COMPRESSION_MIN_LENGTH" default:"1024" help:"Minimum response size in bytes before gzip compression is applied" group:"api"`
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
APIKeys []string `env:"LOCALAI_API_KEY,API_KEY" help:"List of API Keys to enable API authentication. When this is set, all the requests must be authenticated with one of these API keys" group:"api"`
DisableWebUI bool `env:"LOCALAI_DISABLE_WEBUI,DISABLE_WEBUI" default:"false" help:"Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface" group:"api"`
@@ -158,36 +156,35 @@ type RunCMD struct {
DefaultAPIKeyExpiry string `env:"LOCALAI_DEFAULT_API_KEY_EXPIRY" help:"Default expiry for API keys (e.g. 90d, 1y; empty = no expiry)" group:"auth"`
// Distributed / Horizontal Scaling
Distributed bool `env:"LOCALAI_DISTRIBUTED" default:"false" help:"Enable distributed mode (requires PostgreSQL + NATS)" group:"distributed"`
InstanceID string `env:"LOCALAI_INSTANCE_ID" help:"Unique instance ID for distributed mode (auto-generated UUID if empty)" group:"distributed"`
NatsURL string `env:"LOCALAI_NATS_URL" help:"NATS server URL (e.g., nats://localhost:4222)" group:"distributed"`
StorageURL string `env:"LOCALAI_STORAGE_URL" help:"S3-compatible storage endpoint URL (e.g., http://minio:9000)" group:"distributed"`
StorageBucket string `env:"LOCALAI_STORAGE_BUCKET" default:"localai" help:"S3 bucket name for object storage" group:"distributed"`
StorageRegion string `env:"LOCALAI_STORAGE_REGION" default:"us-east-1" help:"S3 region" group:"distributed"`
StorageAccessKey string `env:"LOCALAI_STORAGE_ACCESS_KEY" help:"S3 access key ID" group:"distributed"`
StorageSecretKey string `env:"LOCALAI_STORAGE_SECRET_KEY" help:"S3 secret access key" group:"distributed"`
RegistrationToken string `env:"LOCALAI_REGISTRATION_TOKEN" help:"Token that backend nodes must provide to register (empty = no auth required)" group:"distributed"`
RegistrationRequireAuth bool `env:"LOCALAI_REGISTRATION_REQUIRE_AUTH" default:"false" help:"Fail startup when distributed mode is enabled but LOCALAI_REGISTRATION_TOKEN is empty (node endpoints and worker file-transfer server would otherwise be unauthenticated)" group:"distributed"`
DistributedRequireAuth bool `env:"LOCALAI_DISTRIBUTED_REQUIRE_AUTH" default:"false" help:"Umbrella switch: require BOTH NATS JWT credentials and a registration token when distributed mode is enabled (implies --nats-require-auth and --registration-require-auth)" group:"distributed"`
AutoApproveNodes bool `env:"LOCALAI_AUTO_APPROVE_NODES" default:"false" help:"Auto-approve new worker nodes (skip admin approval)" group:"distributed"`
DistributedSharedModels bool `env:"LOCALAI_DISTRIBUTED_SHARED_MODELS" default:"false" help:"Assert that every node mounts the SAME models directory at the SAME path (shared volume). When true, the router skips staging model files to workers and loads them directly from the shared path, avoiding re-downloads." group:"distributed"`
DistributedPrefixCache bool `env:"LOCALAI_DISTRIBUTED_PREFIX_CACHE" default:"true" help:"Enable prefix-cache-aware routing in distributed mode (default true). When false, routing falls back to round-robin." group:"distributed"`
DistributedDiskHeadroomCheck bool `env:"LOCALAI_DISTRIBUTED_DISK_HEADROOM_CHECK" default:"true" help:"Reject worker nodes that lack free space to store the model, at scheduling time rather than partway through staging (default true). Free space is measured on the filesystem backing each worker's models directory, and compared against the model's own size plus a small margin. When false, node selection ignores free disk (pre-#11054 behaviour); the check still runs and warns when it would have rejected every node. Can also be toggled at runtime via the distributed_disk_headroom_check setting." group:"distributed"`
DistributedPrefixCacheTTL string `env:"LOCALAI_DISTRIBUTED_PREFIX_CACHE_TTL" help:"Idle-timeout for prefix-cache index entries; also drives the background eviction cadence (every TTL/2). Default 5m." group:"distributed"`
BackendInstallTimeout string `env:"LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT" help:"NATS round-trip timeout for backend.install requests sent to worker nodes (default 15m). Increase for slow links pulling multi-GB images." group:"distributed"`
BackendUpgradeTimeout string `env:"LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT" help:"NATS round-trip timeout for backend.upgrade requests (default 15m)." group:"distributed"`
ModelLoadTimeout string `env:"LOCALAI_NATS_MODEL_LOAD_TIMEOUT" help:"Fixed gRPC deadline for the remote LoadModel call sent to a worker node once its backend is installed and model files are staged. Unset (the default), the deadline is derived from the checkpoint size instead: 5m plus 20s per GiB, capped at 6h, so multi-tens-of-GB diffusion/video checkpoints get the minutes they need without a fixed cliff. Set this only to pin a specific budget; the value is used verbatim, including when it is shorter than the derived one." group:"distributed"`
NatsAccountSeed string `env:"LOCALAI_NATS_ACCOUNT_SEED" help:"NATS account signing seed (SU...) used to mint per-node worker JWTs at registration" group:"distributed"`
NatsServiceJWT string `env:"LOCALAI_NATS_SERVICE_JWT" help:"NATS user JWT for the frontend (and agent workers) to publish control-plane messages" group:"distributed"`
NatsServiceSeed string `env:"LOCALAI_NATS_SERVICE_SEED" help:"NATS user signing seed (SU...) paired with LOCALAI_NATS_SERVICE_JWT" group:"distributed"`
NatsWorkerJWTTTL string `env:"LOCALAI_NATS_WORKER_JWT_TTL" help:"Lifetime of minted per-node NATS JWTs (e.g. 24h, default 24h)" group:"distributed"`
NatsRequireAuth bool `env:"LOCALAI_NATS_REQUIRE_AUTH" default:"false" help:"Require NATS JWT credentials (service JWT + account seed) when distributed mode is enabled" group:"distributed"`
NatsTLSCA string `env:"LOCALAI_NATS_TLS_CA" type:"existingfile" help:"PEM file for NATS server CA (private PKI); use with tls:// in --nats-url" group:"distributed"`
NatsTLSCert string `env:"LOCALAI_NATS_TLS_CERT" type:"existingfile" help:"Client certificate for NATS mTLS" group:"distributed"`
NatsTLSKey string `env:"LOCALAI_NATS_TLS_KEY" type:"existingfile" help:"Client private key for NATS mTLS" group:"distributed"`
ExposeNodeHeader bool `env:"LOCALAI_EXPOSE_NODE_HEADER" default:"false" help:"Set the X-LocalAI-Node response header on inference responses (OpenAI chat/completions/embeddings, Anthropic /v1/messages, Ollama /api/chat,/api/generate,/api/embed) with the ID of the worker that served the request. Disabled by default: the node ID reveals internal topology and should not be exposed on a public endpoint. Best-effort: under heavy concurrency the header may reflect a recent routing decision rather than this exact request's." group:"distributed"`
ModelScheduling string `env:"LOCALAI_MODEL_SCHEDULING" help:"Declarative per-model scheduling config applied at startup (inline JSON list of {model_name,node_selector,min_replicas,max_replicas,replicas:\"all\"}). Authoritative: overwrites matching models on every boot. Distributed mode only." group:"distributed"`
ModelSchedulingConfig string `env:"LOCALAI_MODEL_SCHEDULING_CONFIG" help:"Path to a YAML file with the same per-model scheduling list as LOCALAI_MODEL_SCHEDULING. Distributed mode only." group:"distributed"`
Distributed bool `env:"LOCALAI_DISTRIBUTED" default:"false" help:"Enable distributed mode (requires PostgreSQL + NATS)" group:"distributed"`
InstanceID string `env:"LOCALAI_INSTANCE_ID" help:"Unique instance ID for distributed mode (auto-generated UUID if empty)" group:"distributed"`
NatsURL string `env:"LOCALAI_NATS_URL" help:"NATS server URL (e.g., nats://localhost:4222)" group:"distributed"`
StorageURL string `env:"LOCALAI_STORAGE_URL" help:"S3-compatible storage endpoint URL (e.g., http://minio:9000)" group:"distributed"`
StorageBucket string `env:"LOCALAI_STORAGE_BUCKET" default:"localai" help:"S3 bucket name for object storage" group:"distributed"`
StorageRegion string `env:"LOCALAI_STORAGE_REGION" default:"us-east-1" help:"S3 region" group:"distributed"`
StorageAccessKey string `env:"LOCALAI_STORAGE_ACCESS_KEY" help:"S3 access key ID" group:"distributed"`
StorageSecretKey string `env:"LOCALAI_STORAGE_SECRET_KEY" help:"S3 secret access key" group:"distributed"`
RegistrationToken string `env:"LOCALAI_REGISTRATION_TOKEN" help:"Token that backend nodes must provide to register (empty = no auth required)" group:"distributed"`
RegistrationRequireAuth bool `env:"LOCALAI_REGISTRATION_REQUIRE_AUTH" default:"false" help:"Fail startup when distributed mode is enabled but LOCALAI_REGISTRATION_TOKEN is empty (node endpoints and worker file-transfer server would otherwise be unauthenticated)" group:"distributed"`
DistributedRequireAuth bool `env:"LOCALAI_DISTRIBUTED_REQUIRE_AUTH" default:"false" help:"Umbrella switch: require BOTH NATS JWT credentials and a registration token when distributed mode is enabled (implies --nats-require-auth and --registration-require-auth)" group:"distributed"`
AutoApproveNodes bool `env:"LOCALAI_AUTO_APPROVE_NODES" default:"false" help:"Auto-approve new worker nodes (skip admin approval)" group:"distributed"`
DistributedSharedModels bool `env:"LOCALAI_DISTRIBUTED_SHARED_MODELS" default:"false" help:"Assert that every node mounts the SAME models directory at the SAME path (shared volume). When true, the router skips staging model files to workers and loads them directly from the shared path, avoiding re-downloads." group:"distributed"`
DistributedPrefixCache bool `env:"LOCALAI_DISTRIBUTED_PREFIX_CACHE" default:"true" help:"Enable prefix-cache-aware routing in distributed mode (default true). When false, routing falls back to round-robin." group:"distributed"`
DistributedPrefixCacheTTL string `env:"LOCALAI_DISTRIBUTED_PREFIX_CACHE_TTL" help:"Idle-timeout for prefix-cache index entries; also drives the background eviction cadence (every TTL/2). Default 5m." group:"distributed"`
BackendInstallTimeout string `env:"LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT" help:"NATS round-trip timeout for backend.install requests sent to worker nodes (default 15m). Increase for slow links pulling multi-GB images." group:"distributed"`
BackendUpgradeTimeout string `env:"LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT" help:"NATS round-trip timeout for backend.upgrade requests (default 15m)." group:"distributed"`
ModelLoadTimeout string `env:"LOCALAI_NATS_MODEL_LOAD_TIMEOUT" help:"gRPC deadline for the remote LoadModel call sent to a worker node once its backend is installed and model files are staged (default 5m). Increase for very large checkpoints (multi-tens-of-GB diffusion/video models) whose load and pipeline init exceed 5 minutes. Raising it also widens the cold-load lock ceiling." group:"distributed"`
NatsAccountSeed string `env:"LOCALAI_NATS_ACCOUNT_SEED" help:"NATS account signing seed (SU...) used to mint per-node worker JWTs at registration" group:"distributed"`
NatsServiceJWT string `env:"LOCALAI_NATS_SERVICE_JWT" help:"NATS user JWT for the frontend (and agent workers) to publish control-plane messages" group:"distributed"`
NatsServiceSeed string `env:"LOCALAI_NATS_SERVICE_SEED" help:"NATS user signing seed (SU...) paired with LOCALAI_NATS_SERVICE_JWT" group:"distributed"`
NatsWorkerJWTTTL string `env:"LOCALAI_NATS_WORKER_JWT_TTL" help:"Lifetime of minted per-node NATS JWTs (e.g. 24h, default 24h)" group:"distributed"`
NatsRequireAuth bool `env:"LOCALAI_NATS_REQUIRE_AUTH" default:"false" help:"Require NATS JWT credentials (service JWT + account seed) when distributed mode is enabled" group:"distributed"`
NatsTLSCA string `env:"LOCALAI_NATS_TLS_CA" type:"existingfile" help:"PEM file for NATS server CA (private PKI); use with tls:// in --nats-url" group:"distributed"`
NatsTLSCert string `env:"LOCALAI_NATS_TLS_CERT" type:"existingfile" help:"Client certificate for NATS mTLS" group:"distributed"`
NatsTLSKey string `env:"LOCALAI_NATS_TLS_KEY" type:"existingfile" help:"Client private key for NATS mTLS" group:"distributed"`
ExposeNodeHeader bool `env:"LOCALAI_EXPOSE_NODE_HEADER" default:"false" help:"Set the X-LocalAI-Node response header on inference responses (OpenAI chat/completions/embeddings, Anthropic /v1/messages, Ollama /api/chat,/api/generate,/api/embed) with the ID of the worker that served the request. Disabled by default: the node ID reveals internal topology and should not be exposed on a public endpoint. Best-effort: under heavy concurrency the header may reflect a recent routing decision rather than this exact request's." group:"distributed"`
ModelScheduling string `env:"LOCALAI_MODEL_SCHEDULING" help:"Declarative per-model scheduling config applied at startup (inline JSON list of {model_name,node_selector,min_replicas,max_replicas,replicas:\"all\"}). Authoritative: overwrites matching models on every boot. Distributed mode only." group:"distributed"`
ModelSchedulingConfig string `env:"LOCALAI_MODEL_SCHEDULING_CONFIG" help:"Path to a YAML file with the same per-model scheduling list as LOCALAI_MODEL_SCHEDULING. Distributed mode only." group:"distributed"`
Version bool
@@ -282,8 +279,6 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
config.WithCors(r.CORS),
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
config.WithDisableCSRF(r.DisableCSRF),
config.WithDisableHTTPCompression(r.DisableHTTPCompression),
config.WithHTTPCompressionMinLength(r.HTTPCompressionMinLength),
config.WithThreads(r.Threads),
config.WithUploadLimitMB(r.UploadLimit),
config.WithApiKeys(r.APIKeys),
@@ -410,9 +405,6 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
if !r.DistributedPrefixCache {
opts = append(opts, config.DisablePrefixCache)
}
if !r.DistributedDiskHeadroomCheck {
opts = append(opts, config.DisableDiskHeadroomCheck)
}
if r.DistributedPrefixCacheTTL != "" {
d, err := time.ParseDuration(r.DistributedPrefixCacheTTL)
if err != nil {

View File

@@ -50,14 +50,7 @@ type ApplicationConfig struct {
DynamicConfigsDirPollInterval time.Duration
CORS bool
DisableCSRF bool
// DisableHTTPCompression turns off the gzip response middleware. Gzip is
// on by default because the React UI bundle and the admin JSON APIs are
// text-heavy; turn it off only when a fronting proxy already compresses.
DisableHTTPCompression bool
// HTTPCompressionMinLength is the response-size floor (bytes) below which
// gzip is skipped. 0 keeps middleware.DefaultCompressionMinLength.
HTTPCompressionMinLength int
PreloadJSONModels string
PreloadJSONModels string
PreloadModelsFromPath string
CORSAllowOrigins string
ApiKeys []string
@@ -389,18 +382,6 @@ func WithDisableCSRF(b bool) AppOption {
}
}
func WithDisableHTTPCompression(b bool) AppOption {
return func(o *ApplicationConfig) {
o.DisableHTTPCompression = b
}
}
func WithHTTPCompressionMinLength(n int) AppOption {
return func(o *ApplicationConfig) {
o.HTTPCompressionMinLength = n
}
}
func WithP2PToken(s string) AppOption {
return func(o *ApplicationConfig) {
o.P2PToken = s

View File

@@ -1,51 +0,0 @@
package config_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
)
// The disk-headroom admission check has two operator surfaces that must not
// drift into two sources of truth: an env/CLI flag that sets the boot value,
// and a runtime setting that can override it live. Both write the SAME
// ApplicationConfig member, which the SmartRouter reads on every scheduling
// decision.
var _ = Describe("distributed disk-headroom check setting", func() {
It("is enabled by default", func() {
o := config.NewApplicationConfig()
Expect(o.Distributed.DiskHeadroomDisabled).To(BeFalse(),
"the check must default ON — it prevents a measured production failure")
Expect(*o.ToRuntimeSettings().DistributedDiskHeadroomCheck).To(BeTrue())
})
It("reports the env/CLI value through the runtime settings snapshot", func() {
o := config.NewApplicationConfig(config.DisableDiskHeadroomCheck)
Expect(*o.ToRuntimeSettings().DistributedDiskHeadroomCheck).To(BeFalse())
})
It("lets a runtime override beat the env value, in both directions", func() {
// Env said "off"; the operator turns it back on at runtime.
o := config.NewApplicationConfig(config.DisableDiskHeadroomCheck)
on := true
o.ApplyRuntimeSettings(&config.RuntimeSettings{DistributedDiskHeadroomCheck: &on})
Expect(o.Distributed.DiskHeadroomDisabled).To(BeFalse())
// Env said "on" (the default); the operator turns it off at runtime.
o = config.NewApplicationConfig()
off := false
o.ApplyRuntimeSettings(&config.RuntimeSettings{DistributedDiskHeadroomCheck: &off})
Expect(o.Distributed.DiskHeadroomDisabled).To(BeTrue())
})
It("leaves the value alone when the runtime settings do not mention it", func() {
o := config.NewApplicationConfig(config.DisableDiskHeadroomCheck)
o.ApplyRuntimeSettings(&config.RuntimeSettings{})
Expect(o.Distributed.DiskHeadroomDisabled).To(BeTrue())
})
})

View File

@@ -88,22 +88,6 @@ type DistributedConfig struct {
AgentWorkerConcurrency int `yaml:"agent_worker_concurrency" json:"agent_worker_concurrency" env:"LOCALAI_AGENT_WORKER_CONCURRENCY"`
JobWorkerConcurrency int `yaml:"job_worker_concurrency" json:"job_worker_concurrency" env:"LOCALAI_JOB_WORKER_CONCURRENCY"`
// DiskHeadroomDisabled turns off the scheduler's free-disk admission check,
// restoring the pre-#11054 behaviour where node selection ignores whether a
// node can actually store the model. The check is ON by default because it
// prevents a measured failure (a node with 0 bytes free accepted a 70GB
// model and failed 16 minutes into staging); this is the escape hatch for
// setups where our size estimate is wrong (deduplicating filesystems, a
// worker that fetches its own weights), not the norm.
//
// Disabling does NOT silence the check: it still runs and warns when it
// would have rejected every node, so the operator keeps the diagnosis
// without being blocked. See SmartRouter.scheduleNewModel.
//
// Stored as the negation of the CLI/runtime flag so the zero value is
// "enabled" (mirrors PrefixCacheDisabled).
DiskHeadroomDisabled bool
// PrefixCacheDisabled turns off prefix-cache-aware routing, falling back to
// round-robin (the floor). Prefix-cache routing is ON by default in
// distributed mode; this flag exists so operators can opt out. The CLI
@@ -326,13 +310,6 @@ var EnableDistributedSharedModels = func(o *ApplicationConfig) {
o.Distributed.SharedModels = true
}
// DisableDiskHeadroomCheck turns off the scheduler's free-disk admission
// check (see DistributedConfig.DiskHeadroomDisabled). The check is enabled by
// default in distributed mode.
var DisableDiskHeadroomCheck = func(o *ApplicationConfig) {
o.Distributed.DiskHeadroomDisabled = true
}
// DisablePrefixCache turns off prefix-cache-aware routing (falls back to
// round-robin). Prefix-cache routing is enabled by default in distributed mode.
var DisablePrefixCache = func(o *ApplicationConfig) {
@@ -379,10 +356,6 @@ const (
FlagBackendInstallTimeout = "backend-install-timeout"
FlagBackendUpgradeTimeout = "backend-upgrade-timeout"
FlagModelLoadTimeout = "model-load-timeout"
// FlagDiskHeadroomCheck names the disk-headroom toggle. It is quoted in
// the warning the check emits while disabled, so the operator reading a
// log line knows exactly which knob produced it.
FlagDiskHeadroomCheck = "distributed-disk-headroom-check"
)
// Defaults for distributed timeouts.

View File

@@ -10,15 +10,7 @@ import (
"github.com/mudler/LocalAI/pkg/modelartifacts"
)
// persistArtifactBinding writes the resolved artifact set back into a model's
// config document. It replaces the whole `artifacts:` list, so the caller must
// pass EVERY artifact the model declares — the primary and all companions — not
// just the one that triggered the write. Persisting only the primary silently
// dropped companions from disk, and on the next controller restart the reloaded
// config had no companion at all: withCompanionArtifactOptions then synthesized
// no companion option and a remote backend fell back to fetching the companion
// repo itself, failing the load (the distributed longcat-video base_model bug).
func persistArtifactBinding(fileName, modelName string, artifacts []modelartifacts.Spec) error {
func persistArtifactBinding(fileName, modelName string, result modelartifacts.Result) error {
data, err := os.ReadFile(fileName)
if err != nil {
return err
@@ -32,7 +24,7 @@ func persistArtifactBinding(fileName, modelName string, artifacts []modelartifac
return err
}
artifactValue := &yaml.Node{}
encoded, err := yaml.Marshal(artifacts)
encoded, err := yaml.Marshal([]modelartifacts.Spec{result.Spec})
if err != nil {
return err
}

View File

@@ -25,16 +25,19 @@ var _ = Describe("artifact binding persistence", func() {
sibling_only: true
parameters: {model: sibling.gguf}
`), 0644)).To(Succeed())
primary := modelartifacts.Spec{
Name: "model", Target: "model",
Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo", Revision: "main"},
Resolved: &modelartifacts.Resolved{
Endpoint: "https://huggingface.co",
Revision: "0123456789abcdef0123456789abcdef01234567",
CacheKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
result := modelartifacts.Result{
RelativePath: ".artifacts/huggingface/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/snapshot",
Spec: modelartifacts.Spec{
Name: "model", Target: "model",
Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo", Revision: "main"},
Resolved: &modelartifacts.Resolved{
Endpoint: "https://huggingface.co",
Revision: "0123456789abcdef0123456789abcdef01234567",
CacheKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
},
}
Expect(persistArtifactBinding(fileName, "managed", []modelartifacts.Spec{primary})).To(Succeed())
Expect(persistArtifactBinding(fileName, "managed", result)).To(Succeed())
updated, err := os.ReadFile(fileName)
Expect(err).NotTo(HaveOccurred())
Expect(string(updated)).To(ContainSubstring("name: sibling"))
@@ -44,48 +47,4 @@ var _ = Describe("artifact binding persistence", func() {
Expect(string(updated)).To(ContainSubstring("cache_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
Expect(string(updated)).To(ContainSubstring("revision: 0123456789abcdef0123456789abcdef01234567"))
})
It("writes back every artifact it is given, primary and companion", func() {
// The binding replaces the whole artifacts list, so a companion is only
// retained if it is passed in. Dropping it here is what lost companions
// on a controller restart (the distributed longcat-video base_model bug).
fileName := filepath.Join(GinkgoT().TempDir(), "models.yaml")
Expect(os.WriteFile(fileName, []byte(`
- name: avatar
backend: longcat-video
artifacts:
- name: model
target: model
source: {type: huggingface, repo: owner/avatar}
- name: base_model
target: companion
source: {type: huggingface, repo: owner/base}
parameters: {model: owner/avatar}
`), 0644)).To(Succeed())
primaryKey := "1111111111111111111111111111111111111111111111111111111111111111"
companionKey := "2222222222222222222222222222222222222222222222222222222222222222"
resolved := func(repo, key string) modelartifacts.Spec {
return modelartifacts.Spec{
Name: "x", Target: "companion",
Source: modelartifacts.Source{Type: "huggingface", Repo: repo, Revision: "main"},
Resolved: &modelartifacts.Resolved{
Endpoint: "https://huggingface.co",
Revision: "0123456789abcdef0123456789abcdef01234567",
CacheKey: key,
},
}
}
primary := resolved("owner/avatar", primaryKey)
primary.Name, primary.Target = "model", "model"
companion := resolved("owner/base", companionKey)
companion.Name = "base_model"
Expect(persistArtifactBinding(fileName, "avatar", []modelartifacts.Spec{primary, companion})).To(Succeed())
updated, err := os.ReadFile(fileName)
Expect(err).NotTo(HaveOccurred())
Expect(string(updated)).To(ContainSubstring("name: base_model"))
Expect(string(updated)).To(ContainSubstring(primaryKey))
Expect(string(updated)).To(ContainSubstring(companionKey))
})
})

View File

@@ -435,15 +435,12 @@ func (bcl *ModelConfigLoader) PreloadWithContext(ctx context.Context, modelPath
bcl.Unlock()
continue
}
// Persist the WHOLE resolved artifact set (primary + every companion),
// not just the primary result: writing back only the primary dropped
// companions from disk and lost them on the next restart.
if artifactResult != nil && bindingNeedsPersistence(current, updated.Artifacts) && current.modelConfigFile != "" {
if artifactResult != nil && bindingNeedsPersistence(current, *artifactResult) && current.modelConfigFile != "" {
modelartifacts.ReportProgress(ctx, modelartifacts.ProgressEvent{
Phase: modelartifacts.PhasePersisting,
Artifact: artifactResult.Spec.Name,
})
if err := persistArtifactBinding(current.modelConfigFile, current.Name, updated.Artifacts); err != nil {
if err := persistArtifactBinding(current.modelConfigFile, current.Name, *artifactResult); err != nil {
bcl.Unlock()
return err
}
@@ -552,14 +549,8 @@ func (bcl *ModelConfigLoader) preloadOne(
return updated, artifactResult, nil
}
// bindingNeedsPersistence reports whether the freshly resolved artifact set
// differs from what is currently on the config, and so has to be written back.
// It compares the WHOLE set, not just the primary: a companion that resolved
// for the first time (or changed) must trigger a write even when the primary is
// unchanged, or its resolved state would never reach disk and would be lost on
// the next restart.
func bindingNeedsPersistence(current ModelConfig, resolved []modelartifacts.Spec) bool {
return !reflect.DeepEqual(current.Artifacts, resolved)
func bindingNeedsPersistence(current ModelConfig, result modelartifacts.Result) bool {
return len(current.Artifacts) == 0 || !reflect.DeepEqual(current.Artifacts[0], result.Spec)
}
func (bcl *ModelConfigLoader) displayPreloadedModel(config ModelConfig) {

View File

@@ -111,39 +111,6 @@ parameters: {model: meituan-longcat/LongCat-Video-Avatar-1.5}
Expect(loaded.ModelFileName()).To(ContainSubstring(loaded.Artifacts[0].Resolved.CacheKey))
})
It("keeps every resolved artifact in the persisted file across a reload", func() {
// Regression for the distributed longcat-video companion loss: a
// controller resolves the primary and companion in memory, but if the
// binding it writes back to disk carries only the primary, the companion
// is gone the moment the process restarts and reloads the file. With no
// companion in the config, withCompanionArtifactOptions synthesizes no
// base_model option, so the remote backend falls back to downloading the
// base repo itself and fails ("base_model must point to a LongCat-Video
// checkpoint"). The persisted document, reloaded fresh, must still name
// the companion.
modelsPath := GinkgoT().TempDir()
configPath := filepath.Join(modelsPath, "avatar.yaml")
Expect(os.WriteFile(configPath, []byte(companionConfig), 0644)).To(Succeed())
fake := &companionMaterializer{}
loader := NewModelConfigLoader(modelsPath, WithArtifactMaterializer(fake))
Expect(loader.LoadModelConfigsFromPath(modelsPath)).To(Succeed())
Expect(loader.PreloadWithContext(context.Background(), modelsPath)).To(Succeed())
// A fresh loader models the restart: it only ever sees what was written
// back to disk, never the in-memory state the first loader held.
reloaded := NewModelConfigLoader(modelsPath, WithArtifactMaterializer(&companionMaterializer{}))
Expect(reloaded.LoadModelConfigsFromPath(modelsPath)).To(Succeed())
persisted, found := reloaded.GetModelConfig("avatar")
Expect(found).To(BeTrue())
Expect(persisted.Artifacts).To(HaveLen(2))
Expect(persisted.Artifacts[1].Name).To(Equal("base_model"))
Expect(persisted.Artifacts[1].Target).To(Equal(modelartifacts.TargetCompanion))
Expect(persisted.Artifacts[1].Resolved).ToNot(BeNil())
Expect(persisted.Artifacts[1].Resolved.CacheKey).ToNot(BeEmpty())
})
It("fails the load when an explicitly declared companion cannot be acquired", func() {
// Explicit artifacts are all-or-nothing: a config that names a companion
// is asserting the backend needs it, so silently loading without it

View File

@@ -1,78 +0,0 @@
package config
import "time"
// The remote LoadModel deadline used to be the fixed DefaultModelLoadTimeout.
// That is a model-size cliff, not a timeout: the deadline starts only after the
// backend install and file staging have finished, so it covers the worker's
// checkpoint read and pipeline init alone — work whose duration is proportional
// to the bytes on disk. A 70 GB video checkpoint on a Jetson Thor worker
// therefore failed reproducibly (953.5s wall clock, ~11m of it install and
// staging, then DeadlineExceeded on a load that never had a chance).
//
// Raising the constant does not fix that: it moves the cliff to the next larger
// model, and it makes a genuinely wedged SMALL model hang for the whole inflated
// duration before anyone notices. So the budget is derived from the size instead.
const (
// ModelLoadTimeoutPerGiB is the budget granted per GiB of checkpoint on top
// of DefaultModelLoadTimeout.
//
// It is deliberately generous. This is a timeout: erring long costs only
// failure LATENCY on a load that was going to fail anyway, while erring
// short costs a guaranteed FALSE failure on a load that was healthy. 20s/GiB
// corresponds to reading weights at ~54 MB/s, which is below what any
// supported medium sustains (NVMe is orders of magnitude faster; the slow end
// is an eMMC or SD-backed Jetson, or a checkpoint faulted in over a network
// filesystem) and so leaves headroom for the dequantisation and pipeline init
// that follow the read. Hardware and quantisation both move the real figure
// by an order of magnitude, which is exactly why the constant sits at the
// pessimistic end rather than at a measured average.
//
// Worked examples: 2 GiB -> 5m40s (a wedged small model still fails fast),
// 70 GiB -> 28m20s (the production checkpoint that failed at 5m),
// 600 GiB -> 3h25m (the size the cluster has to support).
ModelLoadTimeoutPerGiB = 20 * time.Second
// MaxModelLoadTimeout caps the derived budget so a nonsense size (a corrupted
// stat, a future 10 TB artifact) cannot hand out an effectively infinite
// deadline. At ModelLoadTimeoutPerGiB the cap binds only above ~1 TiB,
// comfortably past the 600 GB requirement.
MaxModelLoadTimeout = 6 * time.Hour
)
// bytesPerGiB is the divisor for the per-GiB rate above.
const bytesPerGiB int64 = 1 << 30
// ModelLoadTimeoutForSize derives the gRPC deadline for the remote LoadModel
// call from the checkpoint's on-disk size.
//
// A non-positive size means the frontend could not measure the payload — a
// backend given a bare HuggingFace repo id fetches its own weights on the
// worker, so there is nothing local to stat. Rather than guess, that case keeps
// the historical DefaultModelLoadTimeout, which is also the floor of the derived
// range: size only ever adds budget.
//
// An explicit LOCALAI_NATS_MODEL_LOAD_TIMEOUT always wins over this; see
// SmartRouterOptions.ModelLoadTimeout.
func ModelLoadTimeoutForSize(bytes int64) time.Duration {
if bytes <= 0 {
return DefaultModelLoadTimeout
}
// Split into whole GiB plus remainder before scaling: multiplying a 600 GB
// byte count by a 20e9-nanosecond rate overflows int64 long before the cap
// could clamp it.
gib := bytes / bytesPerGiB
remainder := bytes % bytesPerGiB
extra := time.Duration(gib) * ModelLoadTimeoutPerGiB
extra += time.Duration(int64(ModelLoadTimeoutPerGiB) * remainder / bytesPerGiB)
// A large enough gib still overflows the multiply above into a negative
// duration; treat any non-positive extra as "past the cap".
if extra <= 0 {
return MaxModelLoadTimeout
}
return min(DefaultModelLoadTimeout+extra, MaxModelLoadTimeout)
}

View File

@@ -1,53 +0,0 @@
package config_test
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
)
const gib int64 = 1 << 30
var _ = Describe("ModelLoadTimeoutForSize", func() {
// The remote LoadModel deadline used to be a fixed 5m. That is a model-size
// cliff: a 70 GB video checkpoint on a Jetson Thor worker failed
// reproducibly with DeadlineExceeded because its weight load and pipeline
// init alone exceed 5 minutes. Raising the constant only moves the cliff, so
// the budget is derived from the bytes the worker has to read.
It("keeps a small checkpoint close to the historical 5m default", func() {
// A wedged 2 GB model must still fail fast: inflating every load's
// budget is a real regression in failure latency.
Expect(config.ModelLoadTimeoutForSize(2 * gib)).To(BeNumerically("<", 10*time.Minute))
})
It("gives a 70 GB checkpoint materially more budget than a 2 GB one", func() {
small := config.ModelLoadTimeoutForSize(2 * gib)
big := config.ModelLoadTimeoutForSize(70 * gib)
Expect(big).To(BeNumerically(">", small*3))
// The measured production failure had ~5m of load budget and needed
// more; anything under 20m would still be a cliff for this exact model.
Expect(big).To(BeNumerically(">=", 20*time.Minute))
})
It("scales monotonically with size", func() {
Expect(config.ModelLoadTimeoutForSize(600 * gib)).
To(BeNumerically(">", config.ModelLoadTimeoutForSize(70*gib)))
})
It("still gives a 600 GB checkpoint hours, not minutes", func() {
Expect(config.ModelLoadTimeoutForSize(600 * gib)).To(BeNumerically(">=", 3*time.Hour))
})
It("falls back to the plain default when the size is unknown", func() {
Expect(config.ModelLoadTimeoutForSize(0)).To(Equal(config.DefaultModelLoadTimeout))
Expect(config.ModelLoadTimeoutForSize(-1)).To(Equal(config.DefaultModelLoadTimeout))
})
It("never exceeds the absolute maximum, however absurd the size", func() {
Expect(config.ModelLoadTimeoutForSize(100_000 * gib)).To(Equal(config.MaxModelLoadTimeout))
})
})

View File

@@ -80,12 +80,6 @@ type RuntimeSettings struct {
AgentPoolDatabaseURL *string `json:"agent_pool_database_url,omitempty"` // PostgreSQL DSN when vector engine is postgres
AgentPoolAgentHubURL *string `json:"agent_pool_agent_hub_url,omitempty"` // override the agenthub.localai.io endpoint
// Distributed-mode settings. Read LIVE by the SmartRouter on each
// scheduling decision, so toggling takes effect on the next placement
// without a restart. Stored as the negation of
// DistributedConfig.DiskHeadroomDisabled for UI clarity.
DistributedDiskHeadroomCheck *bool `json:"distributed_disk_headroom_check,omitempty"` // Reject nodes without room to store the model (default: true)
// LocalAI Assistant settings — read live by the chat handler at request
// entry, so flipping the toggle takes effect on the next request.
LocalAIAssistantEnabled *bool `json:"localai_assistant_enabled,omitempty"` // negation of DisableLocalAIAssistant for UI clarity

View File

@@ -418,16 +418,6 @@ var runtimeSettingsFields = []fieldSpec{
func(o *ApplicationConfig, v string) { o.AgentPool.AgentHubURL = v },
restartRequired()),
// Distributed disk-headroom admission check: stored as the negation for UI
// clarity (the config member is the "disabled" flag so its zero value is
// the safe, enabled default). No restartRequired(): the SmartRouter reads
// the ApplicationConfig member live on every scheduling decision, which is
// the whole point of exposing it here rather than as env/CLI only.
field("distributed_disk_headroom_check",
func(s *RuntimeSettings) **bool { return &s.DistributedDiskHeadroomCheck },
func(o *ApplicationConfig) bool { return !o.Distributed.DiskHeadroomDisabled },
func(o *ApplicationConfig, v bool) { o.Distributed.DiskHeadroomDisabled = !v }),
// LocalAI Assistant: stored as the negation for UI clarity.
field("localai_assistant_enabled",
func(s *RuntimeSettings) **bool { return &s.LocalAIAssistantEnabled },

View File

@@ -101,7 +101,6 @@ var _ = Describe("runtime settings registry", func() {
src.AgentPool.DatabaseURL = "postgres://x"
src.AgentPool.AgentHubURL = "https://hub"
src.DisableLocalAIAssistant = true
src.Distributed.DiskHeadroomDisabled = true
src.Branding = BrandingConfig{
InstanceName: "n", InstanceTagline: "t",
LogoFile: "l.png", LogoHorizontalFile: "lh.png", FaviconFile: "f.ico",

View File

@@ -58,15 +58,7 @@ func CheckBackendUpgrades(ctx context.Context, galleries []config.Gallery, syste
// row is flagged upgradeable regardless of whether any node matches the gallery
// — next Upgrade All realigns the cluster. NodeDrift lists the outliers.
func CheckUpgradesAgainst(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, installedBackends SystemBackends) (map[string]UpgradeInfo, error) {
// Unfiltered on purpose. AvailableBackends drops every gallery entry the
// *local* host cannot run, and in distributed mode the host running this
// check is a CPU-only controller while the GPU backends live on worker
// nodes. Filtering there made FindGalleryElement return nil for every
// cuda/rocm/l4t entry, so those backends were silently skipped and never
// reported an upgrade. Every name we look up here is already installed
// somewhere in the cluster, so hardware compatibility has been decided at
// install time and re-deciding it against the controller is wrong.
galleryBackends, err := AvailableBackendsUnfiltered(galleries, systemState)
galleryBackends, err := AvailableBackends(galleries, systemState)
if err != nil {
return nil, fmt.Errorf("failed to list available backends: %w", err)
}
@@ -262,10 +254,8 @@ func UpgradeBackend(ctx context.Context, systemState *system.SystemState, modelL
return UpgradeBackend(ctx, systemState, modelLoader, galleries, installed.Metadata.MetaBackendFor, downloadStatus, requireIntegrity)
}
// Find the gallery entry. Unfiltered for the same reason as the check
// above: backendName is already installed here, so the capability filter
// can only reject an entry we must be able to resolve.
galleryBackends, err := AvailableBackendsUnfiltered(galleries, systemState)
// Find the gallery entry
galleryBackends, err := AvailableBackends(galleries, systemState)
if err != nil {
return fmt.Errorf("failed to list available backends: %w", err)
}

View File

@@ -359,47 +359,6 @@ var _ = Describe("Upgrade Detection and Execution", func() {
Expect(upgrades).To(HaveKey("my-backend-development"))
Expect(upgrades).NotTo(HaveKey("my-alias"))
})
// Hardware-specific backends live on GPU worker nodes while the
// controller is typically a CPU-only pod. The gallery candidate set
// must therefore NOT be filtered by the controller's own capability:
// doing so drops every cuda/rocm/l4t entry, FindGalleryElement
// returns nil, and the backend is silently skipped. Observed live:
// 48 installed backends, only 5 (all CPU) ever evaluated, while
// cuda13-nvidia-l4t-arm64-longcat-video-development sat two versions
// behind on a worker.
It("flags a GPU-only backend installed on a worker even though the controller is CPU-only", func() {
cpuOnlyState := system.NewCapabilityState("default",
system.WithBackendPath(backendsPath))
writeGalleryYAML([]GalleryBackend{
{
Metadata: Metadata{Name: "cuda13-nvidia-l4t-arm64-longcat-video-development"},
URI: filepath.Join(tempDir, "gpu-source"),
Version: "2.0.0",
},
})
installed := SystemBackends{
"cuda13-nvidia-l4t-arm64-longcat-video-development": SystemBackend{
Name: "cuda13-nvidia-l4t-arm64-longcat-video-development",
Metadata: &BackendMetadata{
Name: "cuda13-nvidia-l4t-arm64-longcat-video-development",
Version: "1.0.0",
},
Nodes: []NodeBackendRef{
{NodeID: "a", NodeName: "gpu-worker-1", Version: "1.0.0"},
},
},
}
upgrades, err := CheckUpgradesAgainst(context.Background(), galleries, cpuOnlyState, installed)
Expect(err).NotTo(HaveOccurred())
Expect(upgrades).To(HaveKey("cuda13-nvidia-l4t-arm64-longcat-video-development"))
info := upgrades["cuda13-nvidia-l4t-arm64-longcat-video-development"]
Expect(info.InstalledVersion).To(Equal("1.0.0"))
Expect(info.AvailableVersion).To(Equal("2.0.0"))
})
})
Describe("UpgradeBackend", func() {

View File

@@ -49,12 +49,6 @@ var reactUI embed.FS
var quietPaths = []string{"/api/operations", "/api/resources", "/healthz", "/readyz"}
// immutableAssetCacheControl is the Cache-Control served for content-hashed
// build output. The filename changes whenever the content does, so a one-year
// TTL plus `immutable` is safe and removes both the re-download and the
// conditional revalidation round-trip.
const immutableAssetCacheControl = "public, max-age=31536000, immutable"
// applyModelLoadCooldown maps a ModelLoadCooldownError anywhere in err's chain
// to HTTP 503 with a Retry-After header (whole seconds, floor 1), so a client
// polling a model whose load recently failed backs off instead of triggering a
@@ -208,13 +202,6 @@ func API(application *application.Application) (*echo.Echo, error) {
// errors — picks them up.
e.Use(httpMiddleware.SecurityHeaders())
// Gzip responses. Registered before the tracing and handler middlewares so
// the response writer it installs sits underneath them: the trace buffer
// keeps capturing plaintext while the wire carries the compressed bytes.
if !application.ApplicationConfig().DisableHTTPCompression {
e.Use(httpMiddleware.Compression(application.ApplicationConfig().HTTPCompressionMinLength))
}
// Custom logger middleware using xlog
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -525,10 +512,6 @@ func API(application *application.Application) (*echo.Echo, error) {
if err != nil {
return c.String(http.StatusNotFound, "React UI not built")
}
// index.html names the content-hashed bundles, so it must never
// be cached: a stale copy pins the browser to the previous
// deploy's assets.
c.Response().Header().Set("Cache-Control", "no-cache")
// Inject <base href> for reverse-proxy support; baseURL comes
// from attacker-controllable Host / X-Forwarded-Host headers.
baseURL := httpMiddleware.BaseURL(c)
@@ -590,10 +573,8 @@ func API(application *application.Application) (*echo.Echo, error) {
})
// Serve React static assets (JS, CSS, etc.) and i18n locale JSONs
// from the embedded React build. cacheControl is stamped on every
// hit so the browser can reuse the bytes instead of re-fetching
// ~1.8 MB of bundle on each navigation.
serveReactSubdir := func(subdir, cacheControl string) echo.HandlerFunc {
// from the embedded React build.
serveReactSubdir := func(subdir string) echo.HandlerFunc {
return func(c echo.Context) error {
p := subdir + "/" + c.Param("*")
f, err := reactFS.Open(p)
@@ -605,21 +586,14 @@ func API(application *application.Application) (*echo.Echo, error) {
if contentType == "" {
contentType = echo.MIMEOctetStream
}
if cacheControl != "" {
c.Response().Header().Set("Cache-Control", cacheControl)
}
return c.Stream(http.StatusOK, contentType, f)
}
}
return echo.NewHTTPError(http.StatusNotFound)
}
}
// Vite content-hashes everything under /assets (Manage-DrwQK63f.js),
// so a given URL can never change content: cache it for a year and
// skip revalidation entirely. Locale JSONs keep stable names, so
// they only get a short TTL.
e.GET("/assets/*", serveReactSubdir("assets", immutableAssetCacheControl))
e.GET("/locales/*", serveReactSubdir("locales", "public, max-age=300"))
e.GET("/assets/*", serveReactSubdir("assets"))
e.GET("/locales/*", serveReactSubdir("locales"))
}
}
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

View File

@@ -140,12 +140,12 @@ func (mgs *BackendEndpointService) ApplyBackendEndpoint(systemState *system.Syst
if err != nil {
return err
}
mgs.backendApplier.EnqueueBackendOp(galleryop.ManagementOp[gallery.GalleryBackend, any]{
mgs.backendApplier.BackendGalleryChannel <- galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: uuid.String(),
GalleryElementName: input.ID,
Galleries: mgs.galleries,
Force: input.Force,
})
}
return c.JSON(200, schema.BackendResponse{ID: uuid.String(), StatusURL: fmt.Sprintf("%sbackends/jobs/%s", middleware.BaseURL(c), uuid.String())})
}
@@ -221,21 +221,17 @@ func (mgs *BackendEndpointService) DeleteBackendEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
backendName := c.Param("name")
mgs.backendApplier.BackendGalleryChannel <- galleryop.ManagementOp[gallery.GalleryBackend, any]{
Delete: true,
GalleryElementName: backendName,
Galleries: mgs.galleries,
}
uuid, err := uuid.NewUUID()
if err != nil {
return err
}
// The op carries the same ID the caller is handed back: without it the
// deletion ran under an empty ID and StatusURL pointed at a job that
// could never have a status.
mgs.backendApplier.EnqueueBackendOp(galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: uuid.String(),
Delete: true,
GalleryElementName: backendName,
Galleries: mgs.galleries,
})
return c.JSON(200, schema.BackendResponse{ID: uuid.String(), StatusURL: fmt.Sprintf("%sbackends/jobs/%s", middleware.BaseURL(c), uuid.String())})
}
}
@@ -317,12 +313,12 @@ func (mgs *BackendEndpointService) UpgradeBackendEndpoint() echo.HandlerFunc {
return err
}
mgs.backendApplier.EnqueueBackendOp(galleryop.ManagementOp[gallery.GalleryBackend, any]{
mgs.backendApplier.BackendGalleryChannel <- galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: uuid.String(),
GalleryElementName: backendName,
Galleries: mgs.galleries,
Upgrade: true,
})
}
return c.JSON(200, schema.BackendResponse{ID: uuid.String(), StatusURL: fmt.Sprintf("%sbackends/jobs/%s", middleware.BaseURL(c), uuid.String())})
}

View File

@@ -87,14 +87,14 @@ func (mgs *ModelGalleryEndpointService) ApplyModelGalleryEndpoint() echo.Handler
if err != nil {
return err
}
mgs.galleryApplier.EnqueueModelOp(galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
mgs.galleryApplier.ModelGalleryChannel <- galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
Req: input.GalleryModel,
ID: uuid.String(),
GalleryElementName: input.ID,
Variant: input.Variant,
Galleries: mgs.galleries,
BackendGalleries: mgs.backendGalleries,
})
}
return c.JSON(200, schema.GalleryResponse{ID: uuid.String(), StatusURL: fmt.Sprintf("%smodels/jobs/%s", middleware.BaseURL(c), uuid.String())})
}
@@ -110,22 +110,18 @@ func (mgs *ModelGalleryEndpointService) DeleteModelGalleryEndpoint() echo.Handle
return func(c echo.Context) error {
modelName := c.Param("name")
mgs.galleryApplier.ModelGalleryChannel <- galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
Delete: true,
GalleryElementName: modelName,
}
mgs.configLoader.RemoveModelConfig(modelName)
uuid, err := uuid.NewUUID()
if err != nil {
return err
}
// The op carries the same ID the caller is handed back: without it the
// deletion ran under an empty ID and StatusURL pointed at a job that
// could never have a status.
mgs.galleryApplier.EnqueueModelOp(galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
ID: uuid.String(),
Delete: true,
GalleryElementName: modelName,
})
mgs.configLoader.RemoveModelConfig(modelName)
return c.JSON(200, schema.GalleryResponse{ID: uuid.String(), StatusURL: fmt.Sprintf("%smodels/jobs/%s", middleware.BaseURL(c), uuid.String())})
}
}

View File

@@ -108,7 +108,7 @@ func ImportModelURIEndpoint(cl *config.ModelConfigLoader, appConfig *config.Appl
opcache.Set(galleryID, uuid.String())
}
galleryService.EnqueueModelOp(galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
galleryService.ModelGalleryChannel <- galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
Req: gallery.GalleryModel{
Overrides: map[string]any{},
},
@@ -116,7 +116,7 @@ func ImportModelURIEndpoint(cl *config.ModelConfigLoader, appConfig *config.Appl
GalleryElementName: galleryID,
GalleryElement: &modelConfig,
BackendGalleries: appConfig.BackendGalleries,
})
}
resp.ID = uuid.String()
resp.StatusURL = fmt.Sprintf("%smodels/jobs/%s", httpUtils.BaseURL(c), uuid.String())

View File

@@ -84,12 +84,6 @@ type RegisterNodeRequest struct {
AvailableVRAM uint64 `json:"available_vram,omitempty"`
TotalRAM uint64 `json:"total_ram,omitempty"`
AvailableRAM uint64 `json:"available_ram,omitempty"`
// TotalDisk / AvailableDisk describe the filesystem backing the worker's
// MODELS directory (where staged weights land), not the root filesystem.
// Omitted by workers that predate the fields; the scheduler treats
// total_disk == 0 as "unknown" and leaves such a node in rotation.
TotalDisk uint64 `json:"total_disk,omitempty"`
AvailableDisk uint64 `json:"available_disk,omitempty"`
GPUVendor string `json:"gpu_vendor,omitempty"`
// GPUComputeCapability is the worker GPU's compute capability ("major.minor",
// e.g. "12.1" for GB10). Used by the router for per-arch option tuning.
@@ -182,8 +176,6 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
AvailableVRAM: req.AvailableVRAM,
TotalRAM: req.TotalRAM,
AvailableRAM: req.AvailableRAM,
TotalDisk: req.TotalDisk,
AvailableDisk: req.AvailableDisk,
GPUVendor: req.GPUVendor,
GPUComputeCapability: req.GPUComputeCapability,
Capability: req.Capability,
@@ -380,8 +372,7 @@ func HeartbeatEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
_ = c.Bind(&update) // best-effort — empty body is fine
var updatePtr *nodes.HeartbeatUpdate
if update.AvailableVRAM != nil || update.TotalVRAM != nil || update.AvailableRAM != nil ||
update.AvailableDisk != nil || update.TotalDisk != nil || update.GPUVendor != "" {
if update.AvailableVRAM != nil || update.TotalVRAM != nil || update.AvailableRAM != nil || update.GPUVendor != "" {
updatePtr = &update
}
@@ -530,7 +521,9 @@ func InstallBackendOnNodeEndpoint(_ nodes.NodeCommandSender, galleryService *gal
CancelFunc: cancelFunc,
}
galleryService.StoreCancellation(jobID, cancelFunc)
galleryService.EnqueueBackendOp(op)
go func() {
galleryService.BackendGalleryChannel <- op
}()
xlog.Info("Node-scoped backend install dispatched", "node", nodeID, "backend", req.Backend, "uri", req.URI, "jobID", jobID)
return c.JSON(http.StatusAccepted, map[string]string{
@@ -593,7 +586,9 @@ func UpgradeBackendOnNodeEndpoint(galleryService *galleryop.GalleryService, opca
CancelFunc: cancelFunc,
}
galleryService.StoreCancellation(jobID, cancelFunc)
galleryService.EnqueueBackendOp(op)
go func() {
galleryService.BackendGalleryChannel <- op
}()
xlog.Info("Node-scoped backend upgrade dispatched", "node", nodeID, "backend", req.Backend, "jobID", jobID)
return c.JSON(http.StatusAccepted, map[string]string{

View File

@@ -1,106 +1,21 @@
package localai
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/trace"
)
// DefaultTraceListLimit bounds the trace list responses. The ring buffer holds
// up to LOCALAI_TRACING_MAX_ITEMS entries (1024 in a typical deployment) and
// each one can embed a full request/response payload, so returning the whole
// buffer made /api/traces a multi-megabyte response that the admin UI
// re-fetched on every poll. Callers that genuinely want everything can pass
// limit=0.
const DefaultTraceListLimit = 50
// MaxTraceListLimit caps an explicit limit so a client cannot ask for an
// unbounded page by accident.
const MaxTraceListLimit = 1000
// tracePageParams reads the limit/offset/full query parameters. Invalid values
// fall back to the bounded defaults rather than erroring, so existing clients
// keep working.
func tracePageParams(c echo.Context) (offset, limit int, full bool) {
limit = DefaultTraceListLimit
if raw := c.QueryParam("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
limit = n
}
}
if limit > MaxTraceListLimit {
limit = MaxTraceListLimit
}
if raw := c.QueryParam("offset"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
offset = n
}
}
if raw := c.QueryParam("full"); raw != "" {
full, _ = strconv.ParseBool(raw)
}
return offset, limit, full
}
// setTracePageHeaders publishes the paging metadata out-of-band so the
// response body stays a plain JSON array for existing consumers.
func setTracePageHeaders(c echo.Context, total, offset, limit int) {
h := c.Response().Header()
h.Set("X-Total-Count", strconv.Itoa(total))
h.Set("X-Trace-Offset", strconv.Itoa(offset))
h.Set("X-Trace-Limit", strconv.Itoa(limit))
}
func traceNotFound(c echo.Context) error {
return c.JSON(http.StatusNotFound, schema.ErrorResponse{
Error: &schema.APIError{Message: "trace not found", Code: http.StatusNotFound},
})
}
// GetAPITracesEndpoint returns a bounded page of API request/response traces
// GetAPITracesEndpoint returns all API request/response traces
// @Summary List API request/response traces
// @Description Returns a bounded, newest-first page of captured API exchange traces. Request and response bodies plus headers are omitted unless full=true; fetch them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.
// @Description Returns captured API exchange traces (request/response pairs) in reverse chronological order
// @Tags monitoring
// @Produce json
// @Param limit query int false "Maximum entries to return (default 50, max 1000, 0 for all)"
// @Param offset query int false "Number of entries to skip (default 0)"
// @Param full query bool false "Include request/response bodies and headers (default false)"
// @Success 200 {object} map[string]any "Traced API exchanges"
// @Router /api/traces [get]
func GetAPITracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
offset, limit, full := tracePageParams(c)
page, total := middleware.GetTracesPage(offset, limit)
if !full {
for i := range page {
page[i] = middleware.SummarizeExchange(page[i])
}
}
setTracePageHeaders(c, total, offset, limit)
return c.JSON(http.StatusOK, page)
}
}
// GetAPITraceEndpoint returns a single API trace with its full payload
// @Summary Get one API trace
// @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response
// @Tags monitoring
// @Produce json
// @Param id path string true "Trace ID"
// @Success 200 {object} map[string]any "Traced API exchange"
// @Failure 404 {object} schema.ErrorResponse "Trace not found"
// @Router /api/traces/{id} [get]
func GetAPITraceEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
exchange, ok := middleware.GetTrace(c.Param("id"))
if !ok {
return traceNotFound(c)
}
return c.JSON(http.StatusOK, exchange)
return c.JSON(200, middleware.GetTraces())
}
}
@@ -113,50 +28,20 @@ func GetAPITraceEndpoint() echo.HandlerFunc {
func ClearAPITracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
middleware.ClearTraces()
return c.NoContent(http.StatusNoContent)
return c.NoContent(204)
}
}
// GetBackendTracesEndpoint returns a bounded page of backend operation traces
// GetBackendTracesEndpoint returns all backend operation traces
// @Summary List backend operation traces
// @Description Returns a bounded, newest-first page of captured backend traces (LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.
// @Description Returns captured backend traces (LLM calls, embeddings, TTS, etc.) in reverse chronological order
// @Tags monitoring
// @Produce json
// @Param limit query int false "Maximum entries to return (default 50, max 1000, 0 for all)"
// @Param offset query int false "Number of entries to skip (default 0)"
// @Param full query bool false "Include the body and data payloads (default false)"
// @Success 200 {object} map[string]any "Backend operation traces"
// @Router /api/backend-traces [get]
func GetBackendTracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
offset, limit, full := tracePageParams(c)
page, total := trace.GetBackendTracesPage(offset, limit)
if !full {
for i := range page {
page[i] = trace.SummarizeBackendTrace(page[i])
}
}
setTracePageHeaders(c, total, offset, limit)
return c.JSON(http.StatusOK, page)
}
}
// GetBackendTraceEndpoint returns a single backend trace with its full payload
// @Summary Get one backend operation trace
// @Description Returns a single captured backend trace, including the body and data payloads omitted from the list response
// @Tags monitoring
// @Produce json
// @Param id path string true "Trace ID"
// @Success 200 {object} map[string]any "Backend operation trace"
// @Failure 404 {object} schema.ErrorResponse "Trace not found"
// @Router /api/backend-traces/{id} [get]
func GetBackendTraceEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
t, ok := trace.GetBackendTrace(c.Param("id"))
if !ok {
return traceNotFound(c)
}
return c.JSON(http.StatusOK, t)
return c.JSON(200, trace.GetBackendTraces())
}
}
@@ -169,6 +54,6 @@ func GetBackendTraceEndpoint() echo.HandlerFunc {
func ClearBackendTracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
trace.ClearBackendTraces()
return c.NoContent(http.StatusNoContent)
return c.NoContent(204)
}
}

View File

@@ -1,16 +1,11 @@
package localai_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/trace"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -21,10 +16,8 @@ var _ = Describe("Traces Endpoints", func() {
BeforeEach(func() {
app = echo.New()
app.GET("/api/traces", GetAPITracesEndpoint())
app.GET("/api/traces/:id", GetAPITraceEndpoint())
app.POST("/api/traces/clear", ClearAPITracesEndpoint())
app.GET("/api/backend-traces", GetBackendTracesEndpoint())
app.GET("/api/backend-traces/:id", GetBackendTraceEndpoint())
app.POST("/api/backend-traces/clear", ClearBackendTracesEndpoint())
})
@@ -59,131 +52,4 @@ var _ = Describe("Traces Endpoints", func() {
Expect(rec.Code).To(Equal(http.StatusNoContent))
})
It("advertises the paging metadata in response headers", func() {
req := httptest.NewRequest(http.MethodGet, "/api/traces", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Header().Get("X-Trace-Limit")).To(Equal(strconv.Itoa(DefaultTraceListLimit)))
Expect(rec.Header().Get("X-Trace-Offset")).To(Equal("0"))
Expect(rec.Header().Get("X-Total-Count")).ToNot(BeEmpty())
})
It("caps an oversized explicit limit", func() {
req := httptest.NewRequest(http.MethodGet, "/api/traces?limit=999999", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Header().Get("X-Trace-Limit")).To(Equal(strconv.Itoa(MaxTraceListLimit)))
})
It("404s an unknown trace ID", func() {
req := httptest.NewRequest(http.MethodGet, "/api/traces/does-not-exist", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusNotFound))
})
})
// The backend trace buffer is a package-level ring buffer, so these specs run
// in their own Describe with an explicit clear to stay independent.
var _ = Describe("Backend traces payload bounding", func() {
var app *echo.Echo
// A payload of the shape that made the live /api/backend-traces response
// 3.4 MB: every entry carries the full input text.
const heavyText = 8192
BeforeEach(func() {
app = echo.New()
app.GET("/api/backend-traces", GetBackendTracesEndpoint())
app.GET("/api/backend-traces/:id", GetBackendTraceEndpoint())
trace.ClearBackendTraces()
trace.InitBackendTracingIfEnabled(500, 0)
base := time.Now()
for i := range 200 {
trace.RecordBackendTrace(trace.BackendTrace{
// Distinct timestamps keep the newest-first ordering (and
// therefore the offset paging) deterministic.
Timestamp: base.Add(time.Duration(i) * time.Millisecond),
Type: trace.BackendTraceLLM,
ModelName: "test-model",
Summary: "summary " + strconv.Itoa(i),
Body: strings.Repeat("b", heavyText),
Data: map[string]any{"input_text": strings.Repeat("x", heavyText)},
})
}
// Recording is asynchronous through a channel; wait for the buffer.
Eventually(func() int { return len(trace.GetBackendTraces()) }).Should(Equal(200))
})
AfterEach(func() {
trace.ClearBackendTraces()
})
get := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
return rec
}
It("bounds the default list to DefaultTraceListLimit entries", func() {
rec := get("/api/backend-traces")
Expect(rec.Code).To(Equal(http.StatusOK))
var out []map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &out)).To(Succeed())
Expect(out).To(HaveLen(DefaultTraceListLimit))
Expect(rec.Header().Get("X-Total-Count")).To(Equal("200"))
})
It("omits the heavy body and data fields from list entries", func() {
rec := get("/api/backend-traces")
var out []map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &out)).To(Succeed())
Expect(out[0]).ToNot(HaveKey("body"))
Expect(out[0]["data"]).To(BeNil())
Expect(out[0]["summary"]).ToNot(BeEmpty())
Expect(out[0]["id"]).ToNot(BeEmpty())
})
It("shrinks the polled payload by orders of magnitude", func() {
bounded := get("/api/backend-traces").Body.Len()
unbounded := get("/api/backend-traces?limit=0&full=true").Body.Len()
Expect(unbounded).To(BeNumerically(">", 200*heavyText))
Expect(bounded).To(BeNumerically("<", unbounded/100))
})
It("serves the full record from the per-trace endpoint", func() {
var list []map[string]any
Expect(json.Unmarshal(get("/api/backend-traces").Body.Bytes(), &list)).To(Succeed())
id, _ := list[0]["id"].(string)
Expect(id).ToNot(BeEmpty())
rec := get("/api/backend-traces/" + id)
Expect(rec.Code).To(Equal(http.StatusOK))
var one map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &one)).To(Succeed())
Expect(one["body"]).To(HaveLen(heavyText))
data, ok := one["data"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(data["input_text"]).To(HaveLen(heavyText))
})
It("pages through the buffer with offset", func() {
var first, second []map[string]any
Expect(json.Unmarshal(get("/api/backend-traces?limit=10").Body.Bytes(), &first)).To(Succeed())
Expect(json.Unmarshal(get("/api/backend-traces?limit=10&offset=10").Body.Bytes(), &second)).To(Succeed())
Expect(first).To(HaveLen(10))
Expect(second).To(HaveLen(10))
Expect(second[0]["id"]).ToNot(Equal(first[0]["id"]))
})
})

View File

@@ -1,42 +0,0 @@
package mcp
import (
"context"
"os/exec"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("connectMCP", func() {
// A stdio server that starts but never completes the MCP initialize
// handshake models the real-world hang from mudler/LocalAI#10880: an
// unreachable/misbehaving MCP server would otherwise block the caller (and,
// because the session-cache mutex is held across connection setup, every
// other MCP request for the model) until the 360s httpClient timeout, which
// surfaces in the UI as the server widget "spinning forever".
It("returns promptly with an error when the handshake never completes", func() {
// `sleep` stays alive but never reads its stdin nor emits an MCP
// initialize response, so client.Connect blocks on the handshake.
// (`cat` would echo the request back and the SDK would treat it as a
// bogus response, returning immediately instead of hanging.)
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // reap the abandoned goroutine/subprocess after the test
transport := &mcp.CommandTransport{Command: exec.CommandContext(ctx, "sleep", "60")}
start := time.Now()
session, err := connectMCP(ctx, transport, 200*time.Millisecond)
elapsed := time.Since(start)
Expect(err).To(HaveOccurred())
Expect(session).To(BeNil())
Expect(err.Error()).To(ContainSubstring("timed out"))
// It must return around the timeout, not hang until the 360s httpClient
// timeout. Allow generous slack for slow CI.
Expect(elapsed).To(BeNumerically("<", 5*time.Second))
})
})

View File

@@ -137,44 +137,6 @@ func MCPServersFromMetadata(metadata map[string]string) []string {
return servers
}
// connectMCP performs the MCP initialize handshake with a bounded timeout.
//
// Without this bound, an unreachable remote server (capped only by the 360s
// httpClient timeout) or a stdio server whose handshake never completes blocks
// the caller indefinitely. Because the session cache mutex is held across
// connection setup, one stalled server also wedges every other MCP request for
// the same model, which surfaces in the UI as the server widget "spinning
// forever" (mudler/LocalAI#10880) - most visibly for cloud-proxy models, whose
// chat path never warms the session cache in the background.
//
// The session, once established, stays bound to the shared ctx (it is cancelled
// later via the cached cancel func on eviction/shutdown), so we cannot pass a
// WithTimeout context to Connect: firing the timeout would tear a healthy
// session down. Instead we run Connect on the shared ctx in a goroutine and stop
// waiting after the timeout. We deliberately do NOT cancel here - the ctx is
// shared with sibling servers that may already have connected. A genuinely
// stalled goroutine holds only that shared ctx and is reaped when the model's
// sessions are cancelled on eviction/shutdown.
func connectMCP(ctx context.Context, transport mcp.Transport, timeout time.Duration) (*mcp.ClientSession, error) {
type result struct {
session *mcp.ClientSession
err error
}
// Buffered so the goroutine can always send and exit, even after we stop
// waiting on the timeout branch.
done := make(chan result, 1)
go func() {
s, err := client.Connect(ctx, transport, nil)
done <- result{session: s, err: err}
}()
select {
case r := <-done:
return r.session, r.err
case <-time.After(timeout):
return nil, fmt.Errorf("timed out after %s establishing MCP session (server unreachable?)", timeout)
}
}
func SessionsFromMCPConfig(
name string,
remote config.MCPGenericConfig[config.MCPRemoteServers],
@@ -225,7 +187,7 @@ func SessionsFromMCPConfig(
)
transport := &mcp.StreamableClientTransport{Endpoint: server.URL, HTTPClient: httpClient}
mcpSession, err := connectMCP(ctx, transport, config.DefaultMCPDiscoveryTimeout)
mcpSession, err := client.Connect(ctx, transport, nil)
if err != nil {
xlog.Error("Failed to connect to MCP server", "error", err, "url", server.URL)
continue
@@ -243,7 +205,7 @@ func SessionsFromMCPConfig(
command.Env = append(command.Env, key+"="+value)
}
transport := &mcp.CommandTransport{Command: command}
mcpSession, err := connectMCP(ctx, transport, config.DefaultMCPDiscoveryTimeout)
mcpSession, err := client.Connect(ctx, transport, nil)
if err != nil {
xlog.Error("Failed to start MCP server", "error", err, "command", command)
continue
@@ -307,7 +269,7 @@ func NamedSessionsFromMCPConfig(
)
transport := &mcp.StreamableClientTransport{Endpoint: server.URL, HTTPClient: httpClient}
mcpSession, err := connectMCP(ctx, transport, config.DefaultMCPDiscoveryTimeout)
mcpSession, err := client.Connect(ctx, transport, nil)
if err != nil {
xlog.Error("Failed to connect to MCP server", "error", err, "name", serverName, "url", server.URL)
continue
@@ -328,7 +290,7 @@ func NamedSessionsFromMCPConfig(
command.Env = append(command.Env, key+"="+value)
}
transport := &mcp.CommandTransport{Command: command}
mcpSession, err := connectMCP(ctx, transport, config.DefaultMCPDiscoveryTimeout)
mcpSession, err := client.Connect(ctx, transport, nil)
if err != nil {
xlog.Error("Failed to start MCP server", "error", err, "name", serverName, "command", command)
continue

View File

@@ -3,7 +3,6 @@ package ollama
import (
"encoding/json"
"fmt"
"math"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
@@ -58,25 +57,7 @@ func applyOllamaOptions(opts *schema.OllamaOptions, cfg *config.ModelConfig) {
cfg.StopWords = append(cfg.StopWords, opts.Stop...)
}
if opts.NumCtx > 0 {
numCtx := opts.NumCtx
// The model configuration / hardware-aware defaults have already
// populated cfg.ContextSize by the time we get here. Treat any
// existing positive value as the server-side ceiling: an
// unauthenticated client must never be able to *raise* the context
// window, since that drives KV-cache allocation and an oversized
// value (e.g. 2,000,000,000) can trigger a catastrophic OOM. A
// smaller num_ctx is still honored. See issue #11022.
if cfg.ContextSize != nil && *cfg.ContextSize > 0 && numCtx > *cfg.ContextSize {
numCtx = *cfg.ContextSize
}
// Regardless of the ceiling, keep the value int32-safe: ContextSize is
// cast to int32 before it reaches the backend (core/backend/options.go),
// so a value above math.MaxInt32 would silently wrap into a negative
// context size when no smaller ceiling exists.
if numCtx > math.MaxInt32 {
numCtx = math.MaxInt32
}
cfg.ContextSize = &numCtx
cfg.ContextSize = &opts.NumCtx
}
}
@@ -99,4 +80,4 @@ func ollamaMessagesToOpenAI(messages []schema.OllamaMessage) []schema.Message {
result = append(result, openAIMsg)
}
return result
}
}

View File

@@ -1,65 +0,0 @@
package ollama
import (
"math"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// These specs pin the num_ctx handling for issue #11022. /api/chat and
// /api/generate share applyOllamaOptions, so exercising the helper covers both
// endpoints. A client-supplied options.num_ctx must not be able to *raise* the
// model/hardware-derived context ceiling already in cfg.ContextSize (that value
// drives KV-cache allocation, so an oversized request is a DoS), and it must
// stay int32-safe because ContextSize is cast to int32 before it reaches the
// backend, where an out-of-range value would silently wrap negative.
var _ = Describe("applyOllamaOptions num_ctx clamping (issue #11022)", func() {
It("caps num_ctx at math.MaxInt32 so the later int32 cast cannot wrap negative", func() {
cfg := &config.ModelConfig{}
applyOllamaOptions(&schema.OllamaOptions{NumCtx: math.MaxInt32 + 1}, cfg)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(math.MaxInt32))
Expect(int32(*cfg.ContextSize)).To(BeNumerically(">", 0),
"capped context size must stay positive after the int32 cast")
})
It("does not let an oversized num_ctx raise an existing context ceiling", func() {
for _, ceiling := range []int{4096, 8192} {
existing := ceiling
cfg := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &existing}}
applyOllamaOptions(&schema.OllamaOptions{NumCtx: 2000000000}, cfg)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(ceiling),
"an in-range but oversized num_ctx must be clamped to the server ceiling, not replace it")
}
})
It("still honors a num_ctx smaller than the existing ceiling", func() {
existing := 8192
cfg := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &existing}}
applyOllamaOptions(&schema.OllamaOptions{NumCtx: 2048}, cfg)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(2048))
})
It("passes an in-range num_ctx through unchanged", func() {
cfg := &config.ModelConfig{}
applyOllamaOptions(&schema.OllamaOptions{NumCtx: 4096}, cfg)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(4096))
})
It("leaves ContextSize untouched when num_ctx is not set", func() {
cfg := &config.ModelConfig{}
applyOllamaOptions(&schema.OllamaOptions{}, cfg)
Expect(cfg.ContextSize).To(BeNil())
})
})

View File

@@ -2,8 +2,8 @@ package openai
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/labstack/echo/v4"
@@ -19,18 +19,6 @@ import (
"github.com/mudler/xlog"
)
// validateStreamingPromptStrings enforces that a streaming completion request
// resolves to exactly one prompt string. Malformed inputs — an omitted prompt,
// an empty array, or an array whose elements do not reduce to a single string —
// return a 400 error instead of panicking on PromptStrings[0] or opening a
// half-written event stream (which Echo surfaces as a 500). See issue #11021.
func validateStreamingPromptStrings(cfg *config.ModelConfig) error {
if len(cfg.PromptStrings) != 1 {
return echo.NewHTTPError(http.StatusBadRequest, "streaming completions require exactly one prompt string")
}
return nil
}
// CompletionEndpoint is the OpenAI Completion API endpoint https://platform.openai.com/docs/api-reference/completions
// @Summary Generate completions for a given prompt and model.
// @Tags inference
@@ -114,18 +102,14 @@ func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eva
if input.Stream {
xlog.Debug("Stream request received")
// Validate before writing any SSE headers so a malformed request
// yields a 400 JSON error instead of a half-opened event stream
// (which Echo would otherwise surface as a 500). See issue #11021.
if err := validateStreamingPromptStrings(config); err != nil {
return err
}
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
if len(config.PromptStrings) > 1 {
return errors.New("cannot handle more than 1 `PromptStrings` when Streaming")
}
// Response/output PII redaction is out of scope for now —
// redaction runs request-side via the NER middleware only.
predInput := config.PromptStrings[0]

View File

@@ -1,40 +0,0 @@
package openai
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// These tests pin the streaming completion request validation added for issue
// #11021: a streaming request whose prompt does not resolve to exactly one
// string must be rejected with an HTTP 400 before any SSE headers are written,
// rather than panicking on PromptStrings[0] or returning a 500 on a
// half-opened event stream.
var _ = Describe("streaming completion prompt validation (issue #11021)", func() {
DescribeTable("rejects malformed prompts with HTTP 400",
func(prompts []string) {
cfg := &config.ModelConfig{}
cfg.PromptStrings = prompts
err := validateStreamingPromptStrings(cfg)
Expect(err).To(HaveOccurred())
he, ok := err.(*echo.HTTPError)
Expect(ok).To(BeTrue(), "expected an *echo.HTTPError, got %T", err)
Expect(he.Code).To(Equal(http.StatusBadRequest))
},
Entry("omitted prompt (nil slice)", []string(nil)),
Entry("empty array", []string{}),
Entry("multiple prompt strings", []string{"a", "b", "c"}),
)
It("accepts exactly one prompt string", func() {
cfg := &config.ModelConfig{}
cfg.PromptStrings = []string{"hello"}
Expect(validateStreamingPromptStrings(cfg)).To(Succeed())
})
})

View File

@@ -1,113 +0,0 @@
package middleware
import (
"path/filepath"
"slices"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// DefaultCompressionMinLength is the response size (in bytes) below which
// gzip is skipped. Anything smaller tends to grow once the ~20 byte gzip
// envelope and the CPU cost on both ends are accounted for.
const DefaultCompressionMinLength = 1024
// streamingPathPrefixes lists routes that either stream incrementally
// (SSE / chunked token deltas) or hand the connection off entirely
// (WebSocket, WebRTC signalling). Buffering those behind a gzip writer
// defeats incremental flushing: the client sits on an empty buffer until
// enough bytes accumulate to fill a deflate block, which reads as a hung
// stream. They are excluded by path because whether a completion request
// streams is decided by the request BODY (`"stream": true`), which the
// compression middleware runs too early to see.
// The unversioned aliases (/chat/completions, /audio/transcriptions, ...) are
// registered alongside the /v1 forms, so both spellings are listed.
var streamingPathPrefixes = []string{
"/v1/chat/completions",
"/chat/completions",
"/v1/completions",
"/completions",
"/v1/engines/",
"/v1/responses",
"/v1/messages",
"/v1/realtime",
"/v1/audio/speech",
"/audio/speech",
"/v1/audio/transcriptions",
"/audio/transcriptions",
"/api/chat",
"/api/generate",
"/api/agent/jobs",
"/api/backend-logs",
"/api/node-backend-logs",
"/ws/",
}
// streamingPathSubstrings catches SSE bridges that carry a variable
// segment before the streaming suffix, e.g. /api/agents/:name/sse.
var streamingPathSubstrings = []string{
"/sse",
"/progress",
"/stream",
"/events",
}
// precompressedExtensions are formats that already carry their own
// compression. Running deflate over them costs CPU on both ends and makes the
// response marginally LARGER (measured: woff2 font files grow by ~60 bytes),
// so they are served as-is.
var precompressedExtensions = []string{
".woff", ".woff2", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
".ico", ".mp3", ".mp4", ".webm", ".ogg", ".zip", ".gz", ".br", ".zst",
}
// skipCompression reports whether a request must bypass gzip.
func skipCompression(c echo.Context) bool {
req := c.Request()
// An explicit SSE Accept header is the strongest signal available
// before the handler runs.
if strings.Contains(req.Header.Get("Accept"), "text/event-stream") {
return true
}
// WebSocket upgrades never carry a compressible body.
if strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
return true
}
path := req.URL.Path
for _, p := range streamingPathPrefixes {
if strings.HasPrefix(path, p) {
return true
}
}
for _, s := range streamingPathSubstrings {
if strings.Contains(path, s) {
return true
}
}
ext := strings.ToLower(filepath.Ext(path))
if ext != "" && slices.Contains(precompressedExtensions, ext) {
return true
}
return false
}
// Compression returns gzip middleware tuned for LocalAI's traffic mix. The
// React UI ships ~1.8 MB of JS/CSS that compresses to roughly a fifth of
// that, and the admin JSON endpoints are similarly text-heavy, so the win
// is large; streaming routes are excluded via skipCompression.
//
// minLength <= 0 falls back to DefaultCompressionMinLength.
func Compression(minLength int) echo.MiddlewareFunc {
if minLength <= 0 {
minLength = DefaultCompressionMinLength
}
return middleware.GzipWithConfig(middleware.GzipConfig{
Skipper: skipCompression,
MinLength: minLength,
})
}

View File

@@ -1,116 +0,0 @@
package middleware_test
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/middleware"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Compression middleware", func() {
var e *echo.Echo
// A payload comfortably above the minimum-length threshold and highly
// repetitive, so a real gzip pass shrinks it dramatically.
body := strings.Repeat("localai compresses this json payload. ", 400)
BeforeEach(func() {
e = echo.New()
e.Use(middleware.Compression(middleware.DefaultCompressionMinLength))
handler := func(c echo.Context) error {
return c.String(http.StatusOK, body)
}
e.GET("/assets/bundle.js", handler)
e.GET("/api/traces", handler)
e.GET("/v1/chat/completions", handler)
e.GET("/api/agents/demo/sse", handler)
e.GET("/api/tiny", func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
e.GET("/assets/font.woff2", handler)
})
get := func(path string, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Accept-Encoding", "gzip")
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
It("gzips a compressible static asset response", func() {
rec := get("/assets/bundle.js", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Header().Get("Content-Encoding")).To(Equal("gzip"))
Expect(rec.Body.Len()).To(BeNumerically("<", len(body)/2))
zr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes()))
Expect(err).ToNot(HaveOccurred())
decoded, err := io.ReadAll(zr)
Expect(err).ToNot(HaveOccurred())
Expect(string(decoded)).To(Equal(body))
})
It("gzips JSON API responses", func() {
rec := get("/api/traces", nil)
Expect(rec.Header().Get("Content-Encoding")).To(Equal("gzip"))
Expect(rec.Body.Len()).To(BeNumerically("<", len(body)))
})
It("does not compress streaming completion endpoints", func() {
rec := get("/v1/chat/completions", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
It("does not compress SSE bridges", func() {
rec := get("/api/agents/demo/sse", nil)
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
It("does not compress a request that asks for an event stream", func() {
rec := get("/api/traces", map[string]string{"Accept": "text/event-stream"})
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
It("does not compress responses below the minimum length", func() {
rec := get("/api/tiny", nil)
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal("ok"))
})
It("does not re-compress formats that are already compressed", func() {
rec := get("/assets/font.woff2", nil)
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
It("leaves the body untouched when the client does not accept gzip", func() {
req := httptest.NewRequest(http.MethodGet, "/assets/bundle.js", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
})

View File

@@ -8,9 +8,7 @@ import (
"net"
"net/http"
"slices"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
@@ -38,10 +36,6 @@ type APIExchangeResponse struct {
}
type APIExchange struct {
// ID identifies this exchange for the lifetime of the process. The list
// endpoint returns trimmed entries; clients fetch the full payload back
// by ID from /api/traces/:id.
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Duration time.Duration `json:"duration"`
Request APIExchangeRequest `json:"request"`
@@ -61,11 +55,6 @@ var traceBuffer *circularbuffer.Queue[APIExchange]
var mu sync.Mutex
var logChan = make(chan APIExchange, 100)
var tracingMaxItems int
var traceIDSeq atomic.Uint64
func nextTraceID() string {
return strconv.FormatUint(traceIDSeq.Add(1), 10)
}
var doInitializeTracing = sync.OnceFunc(func() {
maxItems := tracingMaxItems
@@ -236,7 +225,6 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
responseBody := make([]byte, resBody.Len())
copy(responseBody, resBody.Bytes())
exchange := APIExchange{
ID: nextTraceID(),
Timestamp: startTime,
Duration: time.Since(startTime),
ClientIP: c.RealIP(),
@@ -294,58 +282,6 @@ func GetTraces() []APIExchange {
return traces
}
// GetTracesPage returns the newest-first window [offset, offset+limit) of the
// trace buffer together with the total number of buffered exchanges. A limit
// <= 0 means "no bound" and returns everything from offset onwards.
func GetTracesPage(offset, limit int) ([]APIExchange, int) {
all := GetTraces()
return window(all, offset, limit), len(all)
}
// GetTrace returns the buffered exchange with the given ID.
func GetTrace(id string) (APIExchange, bool) {
for _, t := range GetTraces() {
if t.ID == id {
return t, true
}
}
return APIExchange{}, false
}
// SummarizeExchange strips the heavy parts of an exchange: request/response
// bodies and header maps. What remains is enough to render the trace list
// (method, path, status, timing, sizes, caller), and the byte counters are
// preserved so the UI can still say how big the dropped payload was. Callers
// fetch the full record by ID when a row is expanded.
//
// This is what keeps the polling cost bounded: bodies are what made
// /api/traces a multi-megabyte response on every refresh.
func SummarizeExchange(e APIExchange) APIExchange {
e.Request.Body = nil
e.Request.Headers = nil
e.Response.Body = nil
e.Response.Headers = nil
return e
}
// window slices s to the requested page, clamping out-of-range bounds to an
// empty result rather than panicking.
func window[T any](s []T, offset, limit int) []T {
if offset < 0 {
offset = 0
}
if offset >= len(s) {
return []T{}
}
s = s[offset:]
if limit > 0 && limit < len(s) {
s = s[:limit]
}
out := make([]T, len(s))
copy(out, s)
return out
}
// ClearTraces clears the in-memory logs
func ClearTraces() {
mu.Lock()

View File

@@ -1,145 +0,0 @@
package middleware
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// /api/traces used to serialize the entire ring buffer, bodies included: a
// 1024-entry buffer of chat completions measured 21 MB on a live deployment,
// re-fetched by the admin UI every few seconds. These specs pin the two
// properties that make the poll cheap again: the list is bounded, and list
// entries carry no payload bodies.
func seedTraceBuffer(n, bodyBytes int) {
body := []byte(strings.Repeat("x", bodyBytes))
mu.Lock()
traceBuffer = circularbuffer.New[APIExchange](n)
base := time.Now()
for i := range n {
reqBody := make([]byte, len(body))
copy(reqBody, body)
resBody := make([]byte, len(body))
copy(resBody, body)
reqHeaders := http.Header{"Content-Type": {"application/json"}}
resHeaders := http.Header{"Content-Type": {"application/json"}}
traceBuffer.Enqueue(APIExchange{
ID: strconv.Itoa(i),
Timestamp: base.Add(time.Duration(i) * time.Millisecond),
Request: APIExchangeRequest{
Method: "POST", Path: "/v1/chat/completions",
Headers: &reqHeaders, Body: &reqBody, BodyBytes: len(reqBody),
},
Response: APIExchangeResponse{
Status: 200, Headers: &resHeaders, Body: &resBody, BodyBytes: len(resBody),
},
})
}
mu.Unlock()
}
var _ = Describe("Trace pagination", func() {
AfterEach(func() {
mu.Lock()
traceBuffer = nil
mu.Unlock()
})
It("returns only the requested window and reports the true total", func() {
seedTraceBuffer(200, 64)
page, total := GetTracesPage(0, 50)
Expect(total).To(Equal(200))
Expect(page).To(HaveLen(50))
})
It("walks the buffer with offset", func() {
seedTraceBuffer(20, 8)
first, _ := GetTracesPage(0, 5)
second, _ := GetTracesPage(5, 5)
Expect(first).To(HaveLen(5))
Expect(second).To(HaveLen(5))
Expect(second[0].ID).ToNot(Equal(first[0].ID))
})
It("clamps an offset past the end to an empty page instead of panicking", func() {
seedTraceBuffer(3, 8)
page, total := GetTracesPage(500, 10)
Expect(total).To(Equal(3))
Expect(page).To(BeEmpty())
})
It("returns everything when the limit is zero", func() {
seedTraceBuffer(17, 8)
page, total := GetTracesPage(0, 0)
Expect(total).To(Equal(17))
Expect(page).To(HaveLen(17))
})
It("looks a trace up by ID with its body intact", func() {
seedTraceBuffer(10, 32)
found, ok := GetTrace("4")
Expect(ok).To(BeTrue())
Expect(found.ID).To(Equal("4"))
Expect(found.Response.Body).ToNot(BeNil())
Expect(*found.Response.Body).To(HaveLen(32))
})
It("reports a miss for an unknown ID", func() {
seedTraceBuffer(2, 8)
_, ok := GetTrace("nope")
Expect(ok).To(BeFalse())
})
It("shrinks the serialized payload by dropping bodies and headers", func() {
seedTraceBuffer(100, 4096)
full, _ := GetTracesPage(0, 0)
bounded, _ := GetTracesPage(0, 50)
for i := range bounded {
bounded[i] = SummarizeExchange(bounded[i])
}
fullJSON, err := json.Marshal(full)
Expect(err).ToNot(HaveOccurred())
boundedJSON, err := json.Marshal(bounded)
Expect(err).ToNot(HaveOccurred())
Expect(len(boundedJSON)).To(BeNumerically("<", len(fullJSON)/50),
"a bounded, summarized page must be orders of magnitude smaller than the full dump")
})
It("keeps the size counters after summarizing so the UI can still report payload sizes", func() {
seedTraceBuffer(1, 1024)
page, _ := GetTracesPage(0, 1)
summary := SummarizeExchange(page[0])
Expect(summary.Request.Body).To(BeNil())
Expect(summary.Request.Headers).To(BeNil())
Expect(summary.Response.Body).To(BeNil())
Expect(summary.Response.Headers).To(BeNil())
Expect(summary.Request.BodyBytes).To(Equal(1024))
Expect(summary.Response.BodyBytes).To(Equal(1024))
Expect(summary.Request.Path).To(Equal("/v1/chat/completions"))
Expect(summary.Response.Status).To(Equal(200))
})
})

View File

@@ -51,10 +51,10 @@ function transcriptionTrace(audioWavBase64) {
}
async function openBackendTraceRow(page, traces) {
await page.route('**/api/traces?*', (route) => {
await page.route('**/api/traces', (route) => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify([]) })
})
await page.route('**/api/backend-traces?*', (route) => {
await page.route('**/api/backend-traces', (route) => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify(traces) })
})
await page.goto('/app/traces')

View File

@@ -3,7 +3,7 @@ import { test, expect } from './coverage-fixtures.js'
test.describe('Traces - Error Display', () => {
test.beforeEach(async ({ page }) => {
// Mock API traces with sample data so the table renders
await page.route('**/api/traces?*', (route) => {
await page.route('**/api/traces', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -16,7 +16,7 @@ test.describe('Traces - Error Display', () => {
})
})
// Mock backend traces with sample data
await page.route('**/api/backend-traces?*', (route) => {
await page.route('**/api/backend-traces', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -59,10 +59,10 @@ test.describe('Traces - Error Display', () => {
// uncovered, dragging UI line coverage below the regression gate.
test.describe('Traces - vector_store backend trace detail', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/traces?*', (route) => {
await page.route('**/api/traces', (route) => {
route.fulfill({ contentType: 'application/json', body: '[]' })
})
await page.route('**/api/backend-traces?*', (route) => {
await page.route('**/api/backend-traces', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([

View File

@@ -6,7 +6,7 @@ import { test, expect } from './coverage-fixtures.js'
// can tell who/what issued each request.
test.describe('Traces - API request metadata', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/traces?*', (route) => {
await page.route('**/api/traces', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -21,7 +21,7 @@ test.describe('Traces - API request metadata', () => {
]),
})
})
await page.route('**/api/backend-traces?*', (route) => {
await page.route('**/api/backend-traces', (route) => {
route.fulfill({ contentType: 'application/json', body: '[]' })
})
await page.goto('/app/traces')

View File

@@ -1,82 +0,0 @@
import { test, expect } from './coverage-fixtures.js'
// The trace list endpoints return a bounded page with the heavy request /
// response bodies stripped; the full record is fetched per trace when a row
// is expanded. Without this the admin UI polled a multi-megabyte JSON blob
// every few seconds. These specs pin both halves of the contract from the
// browser's side: the page asks for a bounded list, and expanding a row
// issues the per-trace detail request whose payload is what gets rendered.
const LIST_BODY = [
{
id: '7',
request: { method: 'POST', path: '/v1/chat/completions', body: null },
response: { status: 200, body: null, body_bytes: 65536 },
},
]
const DETAIL_BODY = {
id: '7',
request: {
method: 'POST',
path: '/v1/chat/completions',
// "hello from the request body" base64-encoded, matching the wire shape.
body: Buffer.from('hello from the request body').toString('base64'),
},
response: {
status: 200,
body: Buffer.from('hello from the response body').toString('base64'),
body_bytes: 65536,
},
client_ip: '203.0.113.9',
}
test.describe('Traces - bounded list and on-demand detail', () => {
let listUrls = []
test.beforeEach(async ({ page }) => {
listUrls = []
await page.route('**/api/traces?*', (route) => {
listUrls.push(route.request().url())
route.fulfill({
contentType: 'application/json',
headers: { 'X-Total-Count': '842' },
body: JSON.stringify(LIST_BODY),
})
})
await page.route('**/api/traces/7', (route) => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify(DETAIL_BODY) })
})
await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
headers: { 'X-Total-Count': '0' },
body: '[]',
})
})
await page.goto('/app/traces')
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
})
test('requests a bounded page rather than the whole buffer', async () => {
expect(listUrls.length).toBeGreaterThan(0)
expect(listUrls[0]).toContain('limit=')
expect(listUrls[0]).not.toContain('limit=0')
})
test('reports the server-side total, not the page length', async ({ page }) => {
// The tab counter reflects X-Total-Count (842 buffered) even though only
// one entry was returned in the page.
await expect(page.locator('button', { hasText: 'API Traces' })).toContainText('842')
})
test('fetches the full record when a row is expanded', async ({ page }) => {
await page.locator('tr', { hasText: '/v1/chat/completions' }).first().click()
// The bodies live only in the detail response, so seeing them proves the
// per-trace fetch happened and its payload is what gets rendered.
await expect(page.locator('text=hello from the request body')).toBeVisible()
await expect(page.locator('text=hello from the response body')).toBeVisible()
await expect(page.locator('text=203.0.113.9').first()).toBeVisible()
})
})

View File

@@ -22,7 +22,7 @@
"@lezer/highlight": "^1.2.1",
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"dompurify": "^3.4.12",
"dompurify": "^3.4.11",
"highlight.js": "^11.11.1",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
@@ -1675,20 +1675,19 @@
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
@@ -1698,19 +1697,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -2386,9 +2372,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -2876,9 +2862,9 @@
"dev": true
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -7067,34 +7053,16 @@
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dependencies": {
"content-type": "^2.0.0",
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.6"
}
},
"node_modules/typedarray-to-buffer": {

View File

@@ -30,7 +30,7 @@
"@lezer/highlight": "^1.2.1",
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"dompurify": "^3.4.12",
"dompurify": "^3.4.11",
"highlight.js": "^11.11.1",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",

View File

@@ -23,7 +23,6 @@
"agents": "Agentenaufgaben",
"agentpool": "Agenten-Pool",
"assistant": "LocalAI Assistant",
"distributed": "Verteilt",
"responses": "Antworten"
}
},

View File

@@ -23,7 +23,6 @@
"agents": "Agent Jobs",
"agentpool": "Agent Pool",
"assistant": "LocalAI Assistant",
"distributed": "Distributed",
"responses": "Responses"
}
},

View File

@@ -23,7 +23,6 @@
"agents": "Trabajos de agentes",
"agentpool": "Pool de agentes",
"assistant": "LocalAI Assistant",
"distributed": "Distribuido",
"responses": "Respuestas"
}
},

View File

@@ -23,7 +23,6 @@
"agents": "Agent Job",
"agentpool": "Agent Pool",
"assistant": "Asisten LocalAI",
"distributed": "Terdistribusi",
"responses": "Respons"
}
},

View File

@@ -23,7 +23,6 @@
"agents": "Job degli agenti",
"agentpool": "Pool agenti",
"assistant": "LocalAI Assistant",
"distributed": "Distribuito",
"responses": "Risposte"
}
},

View File

@@ -23,7 +23,6 @@
"agents": "에이전트 작업",
"agentpool": "에이전트 풀",
"assistant": "LocalAI 어시스턴트",
"distributed": "분산",
"responses": "응답"
}
},

Some files were not shown because too many files have changed in this diff Show More