Compare commits

..

231 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
dependabot[bot]
2a8eb5a04b chore(deps): bump the npm_and_yarn group across 1 directory with 5 updates (#11011)
Bumps the npm_and_yarn group with 5 updates in the /core/http/react-ui directory:

| Package | From | To |
| --- | --- | --- |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.0` | `3.4.11` |
| [hono](https://github.com/honojs/hono) | `4.12.18` | `4.12.31` |
| [qs](https://github.com/ljharb/qs) | `6.15.0` | `6.15.3` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.13.1` | `7.18.1` |
| [undici](https://github.com/nodejs/undici) | `7.25.0` | `7.28.0` |



Updates `dompurify` from 3.4.0 to 3.4.11
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.0...3.4.11)

Updates `hono` from 4.12.18 to 4.12.31
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.18...v4.12.31)

Updates `qs` from 6.15.0 to 6.15.3
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.0...v6.15.3)

Updates `react-router` from 7.13.1 to 7.18.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router@7.18.1/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.18.1/packages/react-router)

Updates `undici` from 7.25.0 to 7.28.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.25.0...v7.28.0)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.11
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: hono
  dependency-version: 4.12.31
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: qs
  dependency-version: 6.15.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: react-router
  dependency-version: 7.18.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: undici
  dependency-version: 7.28.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 09:40:42 +02:00
mudler's LocalAI [bot]
9c8f510021 chore(model gallery): 🤖 add 1 new models via gallery agent (#11013)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-21 09:40:24 +02:00
localai-org-maint-bot
1e0baec2a7 fix(ci): repair nightly backend dep bumps for renamed localai-org repos (#11012)
The "Bump Backend dependencies" workflow has failed every night for over
ten days. Four upstreams — ced.cpp, moss-transcribe.cpp, voice-detect.cpp
and rf-detr.cpp — moved from the mudler org to localai-org, so the GitHub
API answers 301 for the old slugs. ced.cpp additionally renamed its
default branch to main.

bump_deps.sh fetched without -L or -f and never checked the response, so
the redirect's JSON body was passed straight to sed, which died with
"unterminated `s' command". The loud failure was luck: an error body
without slashes would have been substituted into the Makefile as the new
pin, silently corrupting the version and shipping it in a bump PR.

Point the matrix at the new slugs and branch, and harden the script so a
bad response can never reach sed: follow redirects, fail on HTTP errors,
and require a bare 40-hex SHA before rewriting anything. Also refresh the
now-stale repository URLs in the backend Makefiles, test scripts,
backend/index.yaml and the docs.

Verified all 25 matrix entries resolve to a commit SHA and that the four
previously-failing jobs run end to end against the real API.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-21 09:40:10 +02:00
mudler's LocalAI [bot]
7bda73fd66 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to f39cc4a3af988091f662313b336dddf8c83a3fb5 (#11002)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-21 09:39:35 +02:00
mudler's LocalAI [bot]
a2c87947a9 chore(model-gallery): ⬆️ update checksum (#11005)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-21 08:48:14 +02:00
mudler's LocalAI [bot]
7c984f5c81 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260720105820 (#11003)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-21 08:48:02 +02:00
mudler's LocalAI [bot]
f8755997cc feat(swagger): update swagger (#11001)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-21 08:47:02 +02:00
mudler's LocalAI [bot]
d0401f9bb4 chore: bump go-processmanager to pick up the concurrent-Run fix (#11004)
Picks up mudler/go-processmanager#7, which closes the check-then-act
window in Run's "already started" guard. Concurrent Run calls on one
handle could each observe a nil p.proc, each start a process and each
launch a monitor goroutine; every monitor does `defer close(p.done)`
against a channel created once in New, so the second monitor to see its
process exit panicked on close of a closed channel. The same window
raced on p.proc itself.

LocalAI does not hit this today: the only New/Run pair
(pkg/model/process.go:178-191) runs a freshly created handle, and
core/services/worker/supervisor.go only ever calls Stop on handles it
holds. The bump is defensive, and keeps the dependency from drifting
further behind a fix in the process lifecycle we rely on.

No exported signature changes upstream; ErrProcessAlreadyRun is
additive and keeps the historical "command already started" prefix, so
any caller matching on that text is unaffected. Nothing in this repo
matches it.

Verified: go build ./core/... ./pkg/... clean; go vet clean;
go test -race ./pkg/model/ ./core/services/worker/ both ok.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Bash] [Edit]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 23:32:29 +02:00
mudler's LocalAI [bot]
0cdd781c2d ci(gallery): propose variant groupings for review instead of letting them decay (#10992)
ci(gallery): propose variant groupings for review on a schedule

A gallery entry may declare `variants:`, references to other entries that
are alternative builds of the same weights, and auto-selection then installs
the best build for the host. Those families exist only because humans curated
them in two manual sweeps.

The gallery agent dedupes on the HuggingFace repo URL and picks one
quantization per model, so it never adds a second build of a repo it already
has, and consequently never creates a family and never joins one. A model
published across two repos lands as two unrelated standalone entries. The
grouping decays as the gallery grows and nothing notices.

Add a scheduled job, in the same shape as the checksum checker: compute
offline, edit the index textually, open a pull request against ci-forks. It
proposes and never decides. Grouping is a judgement call that has gone wrong
in both directions, so the value is catching drift and surfacing candidates
with their evidence.

Three grouping signals, taken from the manual sweeps: same name once
quantization markers are stripped, the `:` config-suffix convention, and the
same primary weight filename once quantization markers are stripped. The
third requires the same upstream repository. Excluding auxiliary files is not
enough on its own: bert-embeddings, an ultravox audio model and a roleplay
finetune all declare a primary file called llama-3.2-1b-instruct-q4_k_m.gguf,
and grouping on that is the same error that linked four wan-2.1 entries and
Z-Image-Turbo to qwen3-4b.

Add gallery/variant-exclusions.yaml, a checked-in rejection ledger. A job
that re-proposes declined candidates every night becomes noise and gets
ignored. Declining a proposal is one flow-mapping line a reviewer adds inside
the proposal pull request itself. Seeded with the six -abliterated pairs whose
base is also in the gallery, the mistral-small multimodal pair, the whisper-1
alias, the kokoros language set, and the recurring finetune tokens. qat and
apex are deliberately not on it: they are quantization techniques.

Proposals refuse to nest, to let two parents claim one target, to target an
entry that installs nothing, and to touch a merge anchor, since a variants key
added to an anchor is inherited by every merging child. The anchor refusal
names every entry that would inherit, which is the worklist a human needs.

Run against the pre-sweep gallery, the job rediscovers 12 of the 19 groupings
the second manual sweep made, with no false positives. The rest it reports as
refusals or ledger declines rather than missing silently.

Assisted-by: Claude:claude-opus-4-8

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 23:08:00 +02:00
mudler's LocalAI [bot]
f01038f479 fix(modelartifacts): stage each writer's artifact in its own partial tree (#10995)
Every writer used to stage into the same `.artifacts/.partial/<cacheKey>`.
That was safe only because the artifact lock held: two writers that both
believed they had it opened the same blob with O_APPEND and interleaved
their bytes into one file, while the resume probe read the other writer's
in-flight size. SHA verification caught the damage only after both had
burned the entire download.

#10986 restored the lock's precondition on CIFS but left the dependency in
place. Suffix the staging tree with a writer identity drawn once per
process run, so concurrent writers cannot corrupt each other whatever the
lock does. The lock stops being a correctness dependency and becomes a
pure efficiency optimisation: a lock failure now costs a duplicated
download, not a corrupted one.

Commit stays an atomic rename. The loser of a commit race reconciles onto
the winner's tree instead of surfacing a bare ENOTEMPTY for work that
actually succeeded, since the artifact is content-addressed and both trees
hold the same verified bytes.

Writer-unique staging means a crashed writer's tree is no longer
overwritten by its successor, so two things are added to keep it from
becoming a disk leak and a resume regression:

- A sweep reclaims trees whose contents have been untouched for 24h,
  matching the window the startup reaper already uses for stray *.partial
  files. It reads the newest mtime anywhere inside the tree, because
  writing a blob never touches an ancestor, and refuses any name this
  package did not write. A live download writes continuously, and the
  downloader's stall watchdog aborts a silent one long before it could
  look abandoned.

- Adoption lets a restarted process claim a dead predecessor's tree for
  the same artifact and resume from its bytes, which a tens-of-gigabytes
  repo depends on. The claim is an atomic rename, so racing adopters
  cannot both win. It runs only under the artifact lock - which is
  released exactly when the owning process dies - and only on a tree idle
  for 5 minutes as a second line of defence for when the lock does not
  exclude.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 23:07:43 +02:00
mudler's LocalAI [bot]
0eb8a1188d fix(worker): give the worker a real health endpoint and a mode-aware HEALTHCHECK (#10999)
fix(worker): give the worker a real health endpoint (#10987)

The image bakes in a single HEALTHCHECK that curls
http://localhost:8080/readyz, but the same image also runs `local-ai
worker`, which serves HTTP on the gRPC base port minus one and never
binds 8080. Every worker container was therefore permanently
`unhealthy` (43 consecutive failures observed on a production node),
which is worse than having no healthcheck: a genuinely broken worker and
a perfectly good one both report `unhealthy`, so the signal carries no
information and orchestration that keys on it misbehaves.

The worker already served /readyz on that port via the file-transfer
server, but as a constant 200 — it only proved the listener was bound,
which is precisely the failure mode at issue. Readiness now tracks the
live NATS connection: all of a worker's actual work (backend lifecycle
events, inference dispatch, file staging) arrives over NATS, so a worker
whose link is dead is up and useless. Registration is already implied,
since the server only starts after registration succeeds.

This reports something the controller cannot already see. The node
registry's status/last_heartbeat is fed by an HTTP heartbeat to the
frontend, a different network path from NATS — a worker can keep
heartbeating while its NATS connection is dead and still look healthy in
the registry. /healthz stays a constant 200: liveness must not follow
readiness, or a NATS blip becomes a cluster-wide restart storm.

The HEALTHCHECK is now a script that derives its endpoint from the mode
the container is actually running plus the env vars that configure the
bind address, so a frontend moved off 8080 with LOCALAI_ADDRESS (broken
the same way) and a worker on a non-default base port are both probed
correctly. Modes with no HTTP surface (agent-worker, one-shot commands)
report healthy rather than false-unhealthy. HEALTHCHECK_ENDPOINT remains
as an explicit override, so the workaround shipped in
docker-compose.distributed.yaml keeps working; both overrides in that
file are now unnecessary and have been removed.

Also fixes the latent --start-period gap. Since #10949 a frontend's
startup preload materializes HuggingFace artifacts before the HTTP
server binds (31 GB observed on a live cluster), so a healthy replica
can legitimately fail probes for a long time. --start-period is Docker's
knob for exactly this: failures inside it leave the container `starting`
instead of burning retries, and it ends early on the first success, so a
generous 60m costs a fast-starting container nothing. --timeout drops
from 10m to 10s — it is a per-probe deadline, and a localhost curl that
has not answered in 10s is itself the fault being detected.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 23:07:27 +02:00
mudler's LocalAI [bot]
d7e04dcc32 fix(openresponses): make responses visible and cancellable across replicas (#11000)
In distributed mode the Open Responses store is process-local: a
sync.OnceValue over a map behind an RWMutex. With several frontend
replicas behind a round-robin load balancer, every request that lands on
a replica other than the creator misses.

Measured on a live 2-replica cluster (#10993): the same response id
returns 200 on the creating replica and 404 on its peer, and a cancel on
the peer returns 404 without ever invoking CancelFunc, so generation runs
to completion on the other replica while the caller is told the response
does not exist. previous_response_id chaining fails through the same
lookup.

Split the state by what can actually cross a process boundary:

- Replicated: response metadata (request, response resource, owner,
  expiry, stream/background flags) via syncstate.SyncedMap, the same
  component finetune, quantization and agent tasks already use. A local
  miss in Get/FindItem now falls back to it and returns a read-only
  remote view, so polling and chaining resolve on any replica.

- Delegated: cancellation. context.CancelFunc is a function pointer and
  exists only in the creating process, so a cancel that lands elsewhere
  is broadcast on responses.<id>.cancel and applied by whichever replica
  holds the function. The broadcast is fire-and-forget rather than
  request/reply: if the owner crashed or was scaled down nobody answers,
  and the handler must not block on a reply that will never come. The
  replicated status moves to cancelled either way, which is truthful,
  since a dead owner's generation died with its process.

- Refused: streaming resume. The resume buffer is a byte log plus a live
  notification channel and cannot be replicated without shipping every
  token over the bus. A resume that reaches the wrong replica now returns
  HTTP 409 naming the owning replica via the new ErrResponseNotLocal,
  instead of an empty event list that looks like a finished stream. It is
  deliberately distinct from ErrOffsetLost, which means the owner's
  buffer evicted the requested events.

Standalone deployments never call EnableDistributed and keep exactly the
previous process-local behaviour.

Fixes #10993


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 23:06:33 +02:00
mudler's LocalAI [bot]
a4bab71f27 gallery: remove duplicated entries and lint against them recurring (#10996)
gallery: remove duplicated entries and lint against them coming back

gallery/index.yaml declared eight names twice: deepseek-r1-distill-llama-8b,
llama3.2-3b-enigma, qwen3-asr-0.6b, qwen3-asr-1.7b, qwopus-glm-18b-merged,
voice-en-us-kathleen-low, whisper-large-q5_0 and whisper-small-q5_1.

FindGalleryElement resolves a reference by returning the first match, so in
every pair the second copy was unreachable: it could not be installed, could
not be selected as a variant target, and could not be corrected, because any
edit to it went to a copy nobody reads. A reference to such a name is also
ambiguous to anything reasoning over the catalog, which is why the variant
proposal job refuses to propose against them.

Each pair was compared both as parsed entries and as raw text, and all eight
were byte-identical apart from position. None of the sixteen blocks defines a
YAML anchor or pulls one in with a merge key, so nothing was reachable only
through a deleted block, and no entry named a removed copy as a variant target.
Removing the second copy of each therefore changes no behaviour: the parsed set
loses exactly eight entries and every surviving entry is field-for-field
unchanged.

The removal is textual, by line range, so the diff is pure deletions rather than
a reflow of forty thousand lines.

checkNoDuplicateEntryNames is the rule that keeps them out, added beside the
existing gallery invariants and reporting in the same style.

checkSingleVariantClaim closes the adjacent gap in the same place. VariantParents
resolves a build claimed by two parents by taking the first in gallery order and
calls that deterministic "for a gallery the linter would reject", but nothing
rejected it: the invariant was held by curation alone. Now it is a rule, and the
comment describes something real. No target is doubly claimed today, so the rule
is green on arrival.

Assisted-by: Claude:claude-opus-4-8

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 22:58:48 +02:00
mudler's LocalAI [bot]
2f33ad6669 fix(modelartifacts): treat CIFS EACCES as lock contention, not failure (#10986)
flock(2) on CIFS/SMB returns EACCES when another client holds the lock:
the kernel maps STATUS_LOCK_NOT_GRANTED and STATUS_FILE_LOCK_CONFLICT to
-EACCES and never produces EWOULDBLOCK on that path. gofrs/flock only
recognises EWOULDBLOCK as contention, so TryLockContext returned a bare
"permission denied" and Ensure aborted. Both replicas then fell back to
legacy loading, which makes the worker download the whole repo in-band
inside LoadModel and blow the remote-load deadline.

Replace TryLockContext with an explicit wait loop over a new Locker
interface, classifying EWOULDBLOCK/EAGAIN/EACCES/EBUSY as contention.
EACCES is ambiguous at the syscall boundary but not here: the lock file
is already open O_CREATE|O_RDWR, so a real permission problem would have
failed the open with an *fs.PathError, and flock(2) documents no EACCES
on Linux at all. The wait is bounded (DefaultLockWait, overridable via
WithLockWait), so even a misclassification degrades to a delay. On
timeout the committed result is re-checked before reporting the new
ErrLockContended, so a peer that finished the work still wins.

Locker also exists so the contention path is testable without a network
filesystem: nothing in CI can make flock(2) return EACCES on demand.

Raise the fallback to error for a managedArtifactBackends backend, via a
shared config.LogArtifactFallback used by both call sites. For those
backends the legacy path is not graceful degradation, and the operator
otherwise sees only a timeout with no causal link. The fallback stays
non-fatal.

Drop the os.Chmod(layout.Lock, 0o600) after acquisition: flock.New
already creates the file 0600, and the chmod was gratuitous risk on a
nounix mount that ignores modes.

Fixes #10981


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 22:12:39 +02:00
mudler's LocalAI [bot]
1cd7d63c7b fix(distributed): reject wrong-model requests on the remaining modalities (#10990)
#10970 gave the four PredictOptions RPCs a model-identity check so a
backend reached through a stale distributed route rejects the request
instead of answering from whatever model it holds (#10952). Every other
modality shares that exposure: the route is cached by host:port, a worker
can recycle a stopped backend's port for another model's backend, and a
liveness-only probe cannot tell a stale row from a valid one.

Extends the same mechanism to the 21 remaining request messages that reach
a backend through the router, using the pattern #10970 established rather
than a parallel one:

- proto: ModelIdentity on each modality request message.
- controller: populated from ModelConfig.Model at the call site that also
  builds ModelOptions, so load-time and request-time values are equal by
  construction.
- backends: one generic guard in pkg/grpc/server.go (27 Go backends), the
  method set in backend/python/common (36 Python backends), llama-cpp
  (AudioTranscription/Stream, Rerank, Score) and privacy-filter
  (TokenClassify).
- reconcile already drops the stale row on IsModelMismatch; no change.

TTSRequest and SoundGenerationRequest get a SEPARATE ModelIdentity field
rather than reusing their existing `model`: FileStagingClient rewrites
`model` to a worker-local path, so comparing it would reject valid
requests in exactly the configuration this guards.

AudioEncode/AudioDecode are deliberately left unguarded: the opus codec
backend is loaded from a literal rather than a ModelConfig, so no value
carries the equality guarantee the comparison depends on. The four
bidirectional stream RPCs are out of scope; they bypass reconcile.

Empty means skip on both sides, so an old controller, an old backend, and
the bare request structs in tests/e2e-backends all keep working.


Assisted-by: Claude Code:claude-opus-4-8 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 21:58:19 +02:00
mudler's LocalAI [bot]
a784cf669f fix(ci): rebuild Go backends on linked pkg/ changes and on matrix entry edits (#10988)
PR #10975 taught the backend matrix filter about shared build inputs, but
left two paths that still rebuild nothing.

Go backends link code from the main tree. `go list -deps ./backend/go/...`
resolves to exactly six pkg subtrees (audio, grpc incl. base/grpcerrors/proto,
httpclient, sound, store, utils), identical for GOOS/GOARCH in
{linux,darwin} x {amd64,arm64}. Editing any of them changes the shipped
binary, but they sit outside every backend directory so the prefix match
never saw them. Enumerating those six rather than taking all of pkg/ is the
point: all of pkg/ changes in ~8.6% of commits, these six in 2.0% — the same
order as the already accepted scripts/build/ rule (1.9%). Blast radius
199/417 Linux, 26/56 Darwin; the ~21 core-server-only pkg subtrees still
rebuild nothing, and neither do _test.go files.

.github/backend-matrix.yml was excluded wholesale because matching its path
would rebuild all 417 entries on every new-backend PR. That hid a real hole:
editing an existing entry's base-image, build-type or cuda version changes
the image it produces while touching no file the filter can see. Since the
change is within a structured file, compare it against the base revision and
rebuild only the entries whose fields actually differ — 1 entry for a
base-image edit, 0 for a comment or whitespace edit, and all 417 only when
the previous revision cannot be resolved. This also closes a third hole: a
new matrix entry for an existing backend (a new CUDA variant, say) touches
nothing under that backend's directory and previously rebuilt nothing.

changed-backends.js fetches the base revision via the contents API, and only
when the changed-file list actually names the matrix file, so the common path
costs no extra request.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 21:46:37 +02:00
mudler's LocalAI [bot]
65bdbc4ee3 fix(http): make /readyz reflect startup readiness, plus gitignore and coverage-ratchet fixes (#10989)
* fix(http): make /readyz reflect startup readiness instead of always 200

/readyz was registered as a static handler returning 200 unconditionally,
so it carried no information: it was green whenever it could be reached at
all. Readiness could not distinguish "serving" from "still starting", and
any future change that started the HTTP listener earlier would silently
turn the probe into a lie.

Track startup completion on the Application (atomic flag, flipped at the
very end of New() on the success path only) and have the readiness handler
consult it per request, returning 503 with a small JSON body while startup
is in progress. A nil readiness source fails open so embedders keep the
historical behaviour.

/healthz is deliberately left readiness-independent. Liveness and readiness
answer different questions, and failing liveness during a long preload makes
an orchestrator restart the pod mid-download so the preload never finishes.

This matters because since #10949 the startup preload materializes
HuggingFace artifacts for managed backends: tens of GB for a large model
(31 GB observed on a live cluster). Both probes stay in quietPaths and stay
exempt from auth.

Note the listener is still started only after New() returns, so today the
not-ready state is not observable over HTTP. Moving the listener earlier is
a separate, deliberate decision and is not made here.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* chore(gitignore): anchor the mock-backend pattern so its source dir is traversable

The bare `mock-backend` pattern matched the *directory*
tests/e2e/mock-backend/, not just the binary built into it. Git will not
descend into an ignored directory even for tracked files, so
`git add tests/e2e/mock-backend/main.go` required -f. This was hit while
working on #10970.

Anchor it to the artifact's full path. The built binary stays ignored (it is
also covered by tests/e2e/mock-backend/.gitignore) while the source directory
becomes traversable again.

Verified with `git check-ignore -v`: a new source file under
tests/e2e/mock-backend/ is no longer ignored, and the binary produced by
`make build-mock-backend` still is.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* chore(coverage): raise the coverage ratchet from 48.5% to 54.2%

The committed baseline had drifted well below reality: it still read 48.5%
while a full instrumented run measures 54.2%. A stale-low baseline makes the
gate meaningless — coverage could regress by more than 5 percentage points
and still pass.

Raising a ratchet is a deliberate act, not something to fold into an
unrelated fix, so it gets its own commit. The headroom was earned by tests
landed in #10946, #10947, #10948, #10949, #10956, #10967, #10968, #10970 and
#10975.

Measured with `make test-coverage` on this branch (the same instrumented run
`make test-coverage-baseline` uses: ginkgo over ./pkg and ./core plus the
in-process tests/e2e suite, --covermode=atomic, --coverpkg over core/... and
pkg/..., generated protobuf excluded). The run completed with exit 0 and zero
spec failures; the total was then written with the exact command the
test-coverage-baseline target uses:

  go tool cover -func=coverage/coverage.out \
    | awk '/^total:/{gsub(/%/,"",$NF); print $NF}' > coverage-baseline.txt

Verified afterwards with scripts/coverage-check.sh, which reports OK.

Note the measured figure includes the readiness specs added earlier on this
branch, so it is a demonstrated floor rather than an estimate.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 21:45:27 +02:00
mudler's LocalAI [bot]
0d9d07d3a5 fix(downloader): distinguish read from write failures and retry transient ones (#10985)
Two independent defects in the download path, both surfaced by the same
incident (#10982).

A failed `io.Copy` was always reported as "failed to write file", because
`io.Copy` folds read and write errors into a single return value. A
peer-cancelled HTTP/2 stream therefore presented as a filesystem failure and
sent an investigation after mount permissions while the disk was healthy. The
source is now wrapped in a recorder so the error names the side that actually
broke, and a write failure names the `.partial` it was writing rather than the
final blob path.

The plan runner returned on the first task error with no retry, so one
transient stream cancel discarded every file already downloaded in a
multi-file materialization. The `.partial` resume machinery already existed
but was unreachable because nothing made a second attempt. Transient failures
(dropped transport, mid-stream read failure, stall, 5xx, 429) are now retried
with bounded exponential backoff and resume from the partial; permanent ones
(4xx, checksum mismatch, local write failure, caller cancellation) fail
immediately.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 19:56:09 +02:00
mudler's LocalAI [bot]
f381844403 gallery: group QAT, APEX and cross-backend builds under their base entry (#10983)
feat(gallery): group 21 more model families under variants

Second variant-grouping sweep. QAT and APEX builds are now treated as
quantization techniques rather than distinct weights, per maintainer
ruling, so they group with their base entry instead of standing alone.

Adds 15 new families: quantization and serving-config pairs for
llama-3.2-1b/3b-instruct, dolphin-2.9-llama3-8b, phi-2-chat, ideogram-4,
meta-llama-3.1-8b-instruct, omnivoice-cpp and qwen3-tts-cpp; the gemma-3
4b/12b/27b QAT families; and three cross-backend pairs (silero-vad plus
its sherpa-onnx build, and the vibevoice TTS and ASR builds shared
between the vibevoice-cpp and crispasr backends). The cross-backend
pairs are the first entries that meaningfully exercise engine-preference
ranking during auto-selection.

Restructures four gemma-4 families (31b-it, 26b-a4b-it, e2b-it, e4b-it).
Those bare entries were skipped by the first sweep, which left a QAT
build as parent by default. The bare entry is what installs when nothing
else fits, so it reclaims the parent slot and the former parent becomes
a plain target. Every pre-existing relationship is preserved; nothing is
dropped and nothing nests. gemma-4-12b-it has no bare entry, so it is
left as is.

qwen3-tts-cpp is a YAML anchor with nine merging children, so the five
children that did not already override variants get an explicit empty
list to stop them inheriting the parent's.

Abliterated builds stay excluded: abliteration edits the weights to
remove refusal behaviour, which makes them a different model rather than
another build of the same one.

Assisted-by: Claude:claude-opus-4-8

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 19:34:03 +02:00
mudler's LocalAI [bot]
83a0f16a21 feat(gallery): let one gallery entry offer several builds of the same model (#10943)
* feat(system): expose raw detected capability for model meta resolution

Model meta gallery entries express hardware fallback through candidate
ordering rather than a capability map, so they need the undecorated
detected capability string without Capability's default/cpu fallback
chain.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(system): drop duplicate capability accessor, cover DetectedCapability

ReportedCapability was added with a body identical to the existing
DetectedCapability. Keep one accessor and move the specs onto it, since
DetectedCapability had no direct coverage of its no-fallback behavior.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): parse IEC binary size suffixes (KiB..PiB)

ParseSizeString accepted only SI suffixes, so a "20GiB" floor was rejected
outright. Model and VRAM sizes are conventionally quoted in IEC units, and
silently reading GiB as GB would understate a floor by about 7%.

Purely additive: these inputs previously returned an unknown-suffix error.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): add Candidate type for meta model entries

Candidate is one option in a meta entry's ordered variant list. It names a
concrete gallery entry and declares when that entry suits the host.

EffectiveMinVRAM resolves the VRAM floor, letting an authored min_vram win
over a nightly-inferred one. An unparseable floor errors instead of being
treated as absent: swallowing a typo would turn a constrained candidate into
an unconstrained one and select a too-large variant rather than fail loudly.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): add hardware-aware model variant resolver

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): allow gallery model entries to declare variant candidates

A gallery entry with a non-empty candidates list is a meta entry: it names
an ordered list of concrete entries and resolves to the first one the host
can satisfy, instead of describing model files directly.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): resolve meta model entries to hardware-appropriate variants at install

Meta gallery entries carry an ordered candidate list; at install time the
first candidate the host satisfies is resolved and its payload installed
under the meta's name, so the model keeps a stable name regardless of which
variant backs it. The resolution is recorded in the installed gallery
config so a reinstall honors a prior pin and operators can see the backing
variant.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): key meta pin recall on the installed name and detach resolved entries

Six review findings on the meta-entry install path.

Pin recall was keyed on the gallery entry name while applyModel writes the
record under the install name (req.Name when supplied), so a meta installed
under a custom name with a pin lost that pin on reinstall and was silently
re-resolved onto a different variant, possibly swapping its backend. Compute
the install name with applyModel's own precedence before the recall.

ResolveMetaModel returned a shallow struct copy, so the resolved entry's
Overrides aliased the gallery entry's map and the install path's in-place
mergo merge wrote the caller's request into the shared catalog. Detach
Overrides, ConfigFile, AdditionalFiles, URLs and Tags. Not exploitable today
only because this path re-unmarshals the gallery per call, which is a
property nobody should have to rely on.

Also: overlay the meta's name onto the persisted config for meta installs so
the gallery file no longer records the variant's name; move the pinned-VRAM
warning below the variant validation so a pin naming a nonexistent entry does
not warn about VRAM before failing for an unrelated reason; and stop seeding
config.URLs in the config_file branch, which duplicated every declared URL.

Add seven network-free specs driving InstallModelFromGallery with a meta
entry: variant payload wins over the meta's legacy url fallback, the
resolution record round-trips to disk, a pin is recorded and honored on
reinstall including under a custom install name, and the resolved entry does
not alias the gallery's maps.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): deep-copy meta overrides and make two specs functional

ResolveMetaModel detached the resolved entry's Overrides and ConfigFile with
maps.Clone, which only copies the top level. Gallery overrides are nested in
practice (parameters.model is near-universal) and the install path merges the
caller's request with mergo.WithOverride, which recurses into nested maps and
overwrites them in place, so the gallery entry's own inner maps were still
reachable and still got rewritten by the last caller to install.

Copy both maps all the way down instead, recursing through the container shapes
a YAML decoder produces. ConfigFile is not mutated on the install path today,
but it carries the same kind of nested payload and leaving it shallowly cloned
would invite the bug back.

Also fix two specs that passed whether or not their target fix was present:

- "does not write the caller's overrides back into the gallery entry" re-read
  the catalog from disk, which re-unmarshals fresh structs and so cannot
  observe in-memory aliasing. It now asserts against the in-memory gallery
  entry and drives the real mergo merge.
- "round-trips the resolution record to disk under the meta's name" asserted a
  name that is already correct in the config_file branch. It now drives the url
  branch via a file:// fixture, where the meta-name overlay actually applies.

Both were verified red by reverting their fix.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(gallery): lint meta model entry invariants in index.yaml

Adds Ginkgo specs that parse the shipped gallery/index.yaml and enforce
the invariants that keep meta entries safe: a legacy url fallback equal
to the final candidate's url, references only to existing non-meta
entries, a min_vram floor on every candidate but the last-resort one,
a capability drawn only from the vocabulary the system can report, and
descending VRAM floors within a capability group.

The capability check is the only compensating control for a typo there.
Candidate matching is a case-sensitive exact comparison against
SystemState.DetectedCapability(), so an unknown value never matches and
falls through silently instead of erroring. The vocabulary therefore
mirrors the raw return set of getSystemCapabilities(), which notably
excludes "cpu": that is a fallback key inside Capability(capMap) on the
meta backend path, never a reported capability. A CPU-only host reports
"default".

These pass vacuously until the pilot meta entry lands; the guard is
intentionally in place before the thing it guards.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(gallery): close coverage gaps in the meta entry lint

The ordering invariant grouped candidates by capability and asserted floors
descend within a group. A candidate with an EMPTY capability matches every
host, so it does not belong in its own group: it dominates every later
candidate whose floor is at or above its own, across capability groups.
Track a running minimum floor over the unconditional candidates instead,
which subsumes the old same-group check for the empty capability.

Every spec skipped non-meta entries, so with zero meta entries in the index
all five bodies were no-ops. Aligning GalleryModel.IsMeta() with
GalleryBackend.IsMeta(), whose semantics are deliberately opposite, would
have made all of them pass while checking nothing. Extract each invariant
into a helper over a slice of entries returning the violations it finds, and
cover those helpers with synthetic fixtures so the logic stays tested at zero
meta entries. The index-driven specs are now a thin application of already
proven logic.

Also assert the index parses non-empty, report every violation in one run
rather than aborting on the first, and parse the index once for the suite.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* ci(gallery): add nightly denormalization of meta model candidates

Fills the read-only backend, quantization and inferred_min_vram fields on
meta gallery candidates and opens a PR, modeled on the existing
checksum_checker job. Computing these needs network access, so it happens
nightly rather than at install time.

An authored min_vram is never modified: a human who measured a real load
knows more than a pre-download estimate does.

The index is rewritten via yaml.Node rather than a document round-trip. A
full round-trip reflows all ~26k lines of gallery/index.yaml, which would
bury the computed values and make the nightly PR unreviewable. The rewrite
touches only the three derived keys, so authored styling survives and a run
that computes nothing leaves the file untouched.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ci): keep the gallery denormalize diff reviewable and self-healing

The nightly denormalization job edits YAML nodes instead of round-tripping
structs so its PR stays small enough for a human to review, but the write
path undid that: yaml.Marshal re-encoded the node tree at yaml.v3's default
4-space indent and dropped the leading document marker, reflowing roughly
6000 lines around the handful of real changes. Encode through
yaml.NewEncoder at the index's authored 2-space indent and restore the
header. A write that changes three fields now changes three lines.

Stale inferred_min_vram values were also never cleared. Both skip paths
(an authored min_vram is present, or the candidate is the last resort)
returned before touching the field, so a candidate that gained a floor or
became the last resort after a reorder kept an inferred value that
EffectiveMinVRAM reported as a real constraint, failing the meta lint with
no way for the job to self-heal. Clear the field before both skips.

The workflow discarded a whole night's work on any single failure: the
program exits 1 when a candidate cannot be estimated, which aborted the job
before the PR step, so one unreachable candidate blocked every other
refresh indefinitely. Capture the status, open the PR with what was
computed, mark the PR body as partial, and fail the run afterwards so the
problem still surfaces.

Also preserve the index's existing file mode instead of forcing 0644, and
drop the redundant //go:build ignore tag, since Go already skips dot
directories and the sibling modelslist.go carries no tag.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): add nanbeige4.1-3b meta entry with hardware-resolved variants

Adds the first real meta entry to the gallery index. It resolves to the
Q8_0 build on hosts with at least 6GiB of VRAM and to the Q4_K_M build
everywhere else, installing either payload under the stable name
nanbeige4.1-3b.

The entry carries a url equal to its final candidate's url. LocalAI
releases that predate candidates support parse the index non-strictly
and drop the key silently, so without that url they would list the entry
and install nothing. A regression spec parses the index the way those
releases do and asserts every meta entry stays installable for them.

Also teaches core/schema/gallery-model.schema.json about candidates. The
schema sets additionalProperties: false at the top level, so an author
following CONTRIBUTING.md and adding the yaml-language-server comment
would otherwise get a validation error on this entry.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): make candidate entries complete, installable entries

Reworks hardware-resolved gallery variants after a design pivot. There is no
longer a separate "meta" entry kind. A gallery entry is a normal, complete
entry that may additionally carry candidates:, a list of hardware-gated
upgrades over itself, and the entry is itself the last-resort candidate.

The previous design relied on a bare url: as the fallback for LocalAI releases
that predate candidates support. That fallback is empty in practice: none of
the 80 gallery/*.yaml files carry a top-level files:, and 1216 of 1281 index
entries carry their payload in the index entry itself, so a url alone yields a
config template with nothing to download. Since every released LocalAI reads
gallery/index.yaml live from master, merging a payload-less entry would have
shown every existing user a model that installs to a broken state. Making the
entry its own base candidate removes the problem at the root: old clients drop
the candidates key and install the entry exactly as they do today.

Resolution order is now explicit pin, then capability plus VRAM over the
declared upgrades, then the entry itself. The entry ALWAYS installs: when its
own min_vram or capability is unmet the installer warns and installs it
anyway, because there is nothing below it and refusing would make the gallery
behave worse the newer the client is. A pin naming the entry's own name is
valid and is how an operator declines an upgrade.

IsMeta() becomes HasCandidates(), ResolveMetaModel becomes ResolveVariant, and
the persisted meta_name record key becomes entry_name. GalleryBackend.IsMeta()
is a separate concept and is untouched.

The lint drops the three rules the pivot makes wrong (url equality with the
final candidate, no inline payload, unconstrained final candidate) and gains
one: the entry's own floor must sit strictly below every candidate's, since a
base that outranks a candidate makes that candidate unreachable.

The pilot entry is now the existing nanbeige4.1-3b-q4, which gains a 2GiB
floor of its own and a single 6GiB upgrade to nanbeige4.1-3b-q8, replacing the
separate nanbeige4.1-3b entry added in d0d441bb4.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): select model variants by hardware fit, not authored order

Gallery entries could already carry a list of alternatives, but selection was
an authored, ordered, first-match policy: every candidate declared a
`capability` string and the VRAM floors had to descend in a hand-tuned order.
That pushed hardware knowledge onto whoever edits the gallery and made ordering
load-bearing, so a reordered list silently changed what users installed.

None of it was necessary. SystemState.IsBackendCompatible already derives
hardware support from a backend name alone: it knows MLX and metal are
Darwin-only, CUDA is NVIDIA-only, ROCm AMD-only, SYCL Intel-only. Selection can
read that instead of asking authors to restate it.

Authoring is now just a list of names:

    - name: qwen3.6-27b
      min_memory: 4GiB
      variants:
        - model: qwen3.6-27b-mlx-8bit
        - model: qwen3.6-27b-gguf-q8
          min_memory: 28GiB

and all the intelligence moved into the selector. Given a host it drops the
variants whose backend cannot run here, drops those whose known memory
requirement exceeds what the host has, and takes the LARGEST of what is left,
because a bigger footprint is a higher quality quantization of the same model.
A variant of unknown size is kept, since nothing proves it does not fit, but it
ranks last so a proven fit always beats a guess. An explicit pin still wins
outright, and if nothing survives the entry installs its own payload: the base
always installs, this never refuses.

Available memory is VRAM when a GPU was detected and system RAM otherwise, read
through xsysinfo so a cgroup limit is honored and a container gets its own
limit rather than the node's RAM.

Capability disappears entirely, from the types, the schema and the lint. VRAM
and RAM collapse into one `min_memory`, because a model's footprint is roughly
the same wherever it lives and one figure is compared against whichever applies.
The lint rules about ordering, the capability vocabulary and floor
relationships are deleted with the hazards they described; what remains is that
every variant names an entry that exists and does not itself declare variants,
plus that any memory figure actually parses.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* gallery: size model variants with a live probe, drop the nightly denormalizer

Selection needs each variant's size to decide whether it fits and to rank
largest-first. That figure was written into the index by a nightly job, which
made the gallery carry a derived value that could drift from the entry it was
derived from. Derive it at install time instead.

pkg/vram already sizes a model without downloading it, and the gallery UI
already uses it: a remote GGUF header range-fetch, then an HTTP HEAD for the
content length, then any declared size:. It caches its results, so reuse it
rather than writing a second probing path.

A probe failure must never fail an install, so an unprobeable variant is
treated as unknown: it survives the memory filter, because nothing proves it
does not fit, and it ranks last, so a known-good fit always beats a guess. If
every probe fails, selection still terminates on the base entry.

The probe is injected through ResolveEnv rather than called directly, for the
same reason the backend compatibility check is: specs pin an exact size, or an
exact failure, without reaching the network.

With that in place three things are dead weight and go:

- The nightly job and the fields it populated. Variant.Backend was redundant
  because the backend is resolved live from the referenced entry during
  selection, and Quantization was display-only that nothing read.
- min_memory on the base entry. The base always installs and its floor could
  only warn, so it could not change any outcome.
- The lint rules and schema entries for both.

min_memory on individual variants stays, as the override for when the probed
size is wrong. An authored figure now suppresses the probe entirely rather
than merely outranking it, so it costs no round trip.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): expose model variants for selection over API, CLI and MCP

A gallery entry may carry `variants:`, alternative builds of the same model.
Selection already worked at install time, but nothing could see what an entry
offered or ask for a specific build, so the feature was undrivable.

Listing: `GET /api/models` now reports `variants` and `auto_variant` for the
entries that declare variants. Each variant carries its resolved backend, its
measured size and whether it fits this host. `auto_variant` is what installing
without a choice would pick right now.

The new gallery.DescribeVariants runs the same variantOptions + SelectVariant
pass the installer runs, so the reported default cannot drift from what
installing actually does, and HostResolveEnv is extracted so both derive the
host and share pkg/vram's probe cache from one place.

Performance: an entry that declares no variants returns early without touching
the probe, so the ~1280 ordinary entries cost exactly what they cost before.

Selection: `variant` is accepted on POST /models/apply, as a query param on
POST /api/models/install/:id, on the gallery apply file/string request, as
`local-ai models install --variant`, and as a parameter on the install_model
MCP tool (both the httpapi and inproc clients). Empty means auto-select.

An unknown variant name now fails the install naming what was requested. This
closes a real hole: an entry declaring no variants short-circuits before
selection runs, so a requested variant was previously dropped silently and the
install reported success.

startup.InstallModels ends in a variadic model list, so install options could
not be appended to it; InstallModelsWithOptions is added alongside and
InstallModels delegates to it. No caller signature changed.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): drop the redundant variant min_memory field

Variant.MinMemory was an authored override for when the live probe misreads
a variant's footprint. It duplicated an existing field: probeEntryMemory
already passes the entry's declared size: into EstimateModelMultiContext,
whose cascade prefers that declared size over its own guesswork. Correcting
size: on the referenced entry fixes the figure for every consumer rather
than only for variant selection, so min_memory shadowed the right answer.

A variant is now nothing but a name. Its effective size is exactly the probe
result, and an unknown stays unknown: it survives the filter and ranks last.

EffectiveMemory loses its error return along with the field. The authored
string was the only thing that could fail to parse, so the error had no
remaining source and was propagating dead nil-checks through SelectVariant,
DescribeVariants and the pin warning.

Selection behaviour is unchanged. The specs covering probe-derived sizing,
ranking, filtering, the unknown-size path, pin recall, entry/variant
metadata split and deep-copy isolation all survive; the three install specs
that needed a definite size now declare it through the referenced entry's
own size:, which exercises the documented escape hatch directly.

gallery/index.yaml is untouched: no entry ever carried the key.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): rank the entry's own build against its variants

Variant selection pulled the declaring entry's own payload, the base, out
of the candidate set and consulted it only once every declared variant had
been rejected. Two real failures followed.

A variant whose size the probe cannot determine deliberately survives the
memory filter, because nothing proves it does not fit. As the only survivor
it then won outright on any host, however small: a 2GiB machine installed an
unmeasured variant in preference to the 4GiB build the entry itself ships,
with no warning. 241 of the 1280 current index entries carry no files and no
size, which is exactly that shape.

"Largest wins" also broke whenever the base was the largest. An author
writing a Q8 entry that offers a Q4 downgrade for small hosts, a natural
shape that nothing in the lint, schema or docs discourages, had the Q4
installed on every large host instead.

Make the base an ordinary participant. It is still exempt from both filters,
so selection always terminates on something installable, but it is now
ranked against the variants: a proven fit first and largest, then the base,
then any variant whose size nothing could measure. Both failures disappear
together. The base is probed for its size accordingly, which it was not
before, because an unsized base would lose every contest to an unmeasurable
variant.

FellBackToBase is kept but narrowed to "no declared variant survived",
rather than "the base was chosen", since the base now also wins on merit and
that is not worth warning about.

A recalled variant pin also became a permanent install failure. A pin the
caller supplies on this request must stay fatal, but one recalled from
._gallery_<name>.yaml can be invalidated by any later gallery edit, and
failing on it turned one rename into a model that could never be reinstalled
or upgraded again short of deleting a dotfile the user has never heard of.
A stale recalled pin is now dropped with a warning naming it, and selection
runs as if it had never been recorded.

Also drop the last textual reference to two abandoned designs from the
DetectedCapability comment, correct the documented variants JSON example,
which showed a memory_bytes of 0 that omitempty makes impossible, and remove
an em dash from the install skill.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): budget variant memory from RAM when a GPU reports no VRAM

Variant selection read its memory budget from VRAM whenever a GPU
capability was detected, and from system RAM only when none was. Apple
Silicon satisfies the first branch and fails the premise: arm64 macs report
the metal capability unconditionally, without probing anything, while
TotalAvailableVRAM has no discrete VRAM pool to find and returns zero. The
budget therefore came out as zero on every Mac.

Zero drops every variant carrying a known size, so the base build was
installed on all of them however much memory the machine had. The feature
was inert on the platform, and silently: falling back to the base is a
legitimate outcome, so nothing looked wrong.

Take VRAM only when it is actually a number, and fall back to RAM
otherwise. On a unified-memory host RAM is not an approximation of the
budget, it is the budget, since the GPU shares it. A discrete GPU whose
VRAM could not be read also lands on RAM, which overstates what the card
holds but understates nothing the host has; the previous zero understated
both.

An unreadable RAM figure still yields zero and still installs the base, so
a genuinely unknown host is not talked into a larger download.

This is what turned tests-apple red: "installs a fitting variant's payload
under the entry's own name" asserts on selection, and the runner resolved
to the base because its budget was zero. The added specs pin the branch
directly rather than relying on a macOS runner to notice again.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add a model variant picker to the models gallery

PR #10943 shipped the server side: a gallery entry may declare `variants:`,
`GET /api/models` attaches `variants` and `auto_variant` to declaring
entries, and `POST /api/models/install/:id` accepts a `variant` query
parameter. Nothing in the UI consumed any of it, so the feature was not
reachable from the browser. This wires it up.

modelsApi.install takes an optional second argument and appends an encoded
`?variant=` only when one is given, so every existing call site keeps
sending exactly the request it sent before.

On the models table, an entry that declares variants gets a split button.
The primary Install still installs the auto-selected build, because auto is
the default and the point of the feature; the chevron opens a menu for a
deliberate override. It follows the Backends.jsx precedent: one shared
Popover re-anchored per row, rendering .action-menu items, which brings
Escape, outside-click and focus return along with it. An entry that
declares no variants renders exactly as it did before.

A variant that does not fit is dimmed but stays selectable, since the server
honors an explicit choice with a warning rather than refusing it.

memory_bytes is omitempty on the wire, so an absent key means the size is
unknown and never zero. A single helper guards both the menu and the detail
row, because formatBytes would otherwise render a falsy value as "0 B",
which reads as "needs nothing".

The expanded detail row gains a Variants section listing each build's
backend, size, whether it fits, which is the entry's own build, and which
one auto-selection would pick, built from the existing DetailRow helper and
.badge classes.

Eight Playwright specs cover the picker, including that plain Install sends
no variant parameter and that choosing one sends it. One pre-existing
assertion was scoped with .first(): the Variants section legitimately adds
more llama-cpp badges to the detail row, which tripped strict mode.

UI line coverage 49.42% -> 49.36% against a 40.0 baseline and 0.8pp
tolerance; branch coverage rose 72.04% -> 72.66%.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): describe model variants from a companion endpoint

Variant description probes each referenced entry's weight files over the
network: an HTTP HEAD plus a ranged GET, serial, five seconds per probe
with no aggregate deadline. Running it inline in GET /api/models made one
listing cost (entries x variants) round trips. The Manage page fetches
with items=9999, so at 200 declaring entries that is ~1000 serial probes,
minutes of a blocked handler and gigabytes of range traffic for a single
page load. Only one entry declares variants today, but the feature exists
so that many will.

Follow the precedent already set for VRAM estimates. The listing now
reports only has_variants, a length check on loaded metadata that touches
nothing, and GET /api/models/variants/:id returns the description for one
entry, mirroring estimate/:id in route shape, auth and error handling.
DescribeVariants itself is unchanged; only its caller moved.

The picker fetches lazily at the two points where a user asks to see
variants, opening the split-button menu and expanding the detail row, and
caches per entry for the page session. An entry declaring no variants
issues no request at all.

A spec counts real HTTP hits on the weight files, so it goes red if
description becomes reachable from the listing path again through any
caller.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): filter the model gallery to entries that declare variants

The gallery is heading towards showing parent entries and hiding the
individual builds they reference, so a user sees one row per model
rather than six quantizations of it.

Adoption is a single entry today, so defaulting to that would leave a
one-row gallery. This ships the migration-phase inverse instead: the
default is untouched, and a toggle narrows the list to only the entries
that declare variants. It previews the end state and changes nothing
until someone asks for it.

The filter is server-side, next to term/tag/backend/capability and above
the pagination arithmetic. The listing paginates at 9 items, so
narrowing on the client would leave totalPages and availableModels
describing the unfiltered set and hand the user empty pages. It selects
on HasVariants(), which reads already-loaded metadata, so it issues no
variant probes.

The parameter is named has_variants after the listing field it selects
on, and is compared against "true" like the other boolean query params
(all_users, save_checkpoint), so has_variants=false reads as absent.
With it omitted the response is byte-for-byte what it was before.

The control is the shared Toggle component, matching the fitsFilter
toggle already on this page: same wrapper class, same icon and label
shape, same localStorage persistence. Unlike fitsFilter it resets to
page 1 on change, which a server-side filter has to do.

Stacking the toggle with a tag or backend filter easily yields nothing
while one entry declares variants, so the empty state now names the
variants filter as the cause rather than leaving a user to conclude the
gallery is broken.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): render gallery model descriptions as Markdown

Gallery descriptions are Markdown, but the React UI dumped them raw, so a
model whose description opens with an ATX heading showed a literal
"# Qwen3.6-27B [](https://chat.qwen.ai)" in the list.

Full-description areas now render through renderMarkdown (marked +
DOMPurify), matching how Backends.jsx and the Manage detail panels already
handle the same content:

  - Models.jsx expanded detail row
  - VoiceLibrary.jsx voice detail header

The truncated one-line previews must not render block Markdown: a leading
"#" would become an <h1> and wreck the row height and rhythm. They get a new
stripMarkdown() helper instead, which reduces Markdown to a single line of
readable plain text. It is used for the cell text and for the title tooltip,
since a tooltip full of "[](url)" is no better than a cell full of it:

  - Models.jsx gallery table description cell
  - Manage.jsx model and backend resource-row descriptions

stripMarkdown walks marked's lexer output rather than running regexes over
the source, so what it strips is by construction what renderMarkdown would
have rendered, and it needs no new dependency. Output lands in JSX text
nodes, so React escapes it; no new dangerouslySetInnerHTML beyond the two
full-description sites, both of which run DOMPurify.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): strip Markdown from the backends table description cell

Commit b35d630cf fixed this for gallery models but left the Backends admin
page with the same asymmetry: its detail panel renders the description
through renderMarkdown, while the collapsed table row dumped the raw gallery
string into both the cell body and the title tooltip.

That is user-visible. 40 of the 949 entries in backend/index.yaml carry
Markdown - insightface uses inline code backticks, others use lists and
links - and backend descriptions also contain embedded newlines, so the
one-line cell showed literal syntax.

The cell now runs stripMarkdown over the description once and uses the
result for the text and the title, matching Models.jsx and the
ResourceRowDesc component in Manage.jsx. The '-' placeholder is preserved,
and now also fires when a description reduces to nothing after stripping.
The detail panel is untouched and no new dangerouslySetInnerHTML is
introduced: stripMarkdown output lands in a JSX text node, so React escapes
it.

Three Playwright specs cover it: a description with a heading, inline code
and a link renders as clean text with no literal syntax and no block
element in the cell, the title tooltip carries the same stripped text, and
a backend without a description still shows the placeholder.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* ui(models): polish the variant detail view and scope rendered Markdown

The gallery detail pane rendered every field through the same two-column
label/value row, including the description. Multi-paragraph prose in a value
cell ran eight rows tall at the top of the pane on a ~1200px measure, breaking
the grid's rhythm exactly where the eye enters. Move it into its own full-width
block above the table, capped at a 68ch measure, keeping the label.

Rendered Markdown had no scoped typography anywhere in the app, so a
description opening with `#` inherited the browser default 2em inside a 13px
surface while a `##` further down was indistinguishable from body text. Add a
reusable .markdown-body block mapping h1-h6, paragraphs, lists, links, code,
blockquotes, images and tables onto the existing type scale, and apply it to
every renderMarkdown() consumer: the models detail, the backends detail, both
Manage details and the voice library detail.

Rebalance the variants list so the name leads. Backend and size drop from
badge/secondary weight to muted metadata; the FITS badge goes entirely, since
it was true of nearly every row and so said nothing, while the variant that
does not fit keeps a warning badge and a dimmed name. AUTO-SELECTED stays
marked because it answers what a plain Install produces. Rows share the
parent's grid tracks via subgrid so name, backend, size and status line up
down the list instead of raggedly following name length.

Finally, make each variant row actionable. It looked like a list of choices
but was inert text, with per-variant install hidden behind the split-button
chevron elsewhere; each row is now a button onto the existing
handleInstall(modelId, variant) path, with hover, keyboard focus and a
disabled state while an install is in flight.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): collapse the listing to one row per model

The listing supported has_variants=true, which narrowed to entries that
DECLARE variants. With adoption at three entries that showed three rows,
which is useless; it was always a placeholder.

Replace it with the view that is actually useful: the deduplicated
gallery. Show every entry installable in its own right and nothing twice,
which means the parents plus every entry nobody references, and hide only
the builds another entry already offers as a variant, since those are
reachable through their parent.

The parameter is renamed to collapse_variants accordingly: the filter is
no longer a predicate on a row's own metadata but a view over the whole
gallery. Default stays off, so the response with the parameter absent is
unchanged.

VariantReferencedIDs never reports an entry that declares variants of its
own, so parents are always visible. That guarantees every hidden entry
has a visible entry offering it, and no chain can strand a row. Variant
resolution already refuses to install such a reference, but the listing
has to stay coherent in the presence of a gallery that has one rather
than silently swallowing entries. Self-references and dangling references
hide nothing.

The referenced set is computed over the whole gallery rather than over
what the other filters left, so an entry is hidden because a parent
offers it and never because of what the user searched for. The pass is
over metadata already in memory: it resolves nothing over the network and
triggers no variant description or size probe, so the listing's zero-probe
contract still holds.

The UI toggle keeps its behaviour (persistence, page reset, clear
filters) and becomes "One row per model", which says what the user gets.
Its localStorage key moves too, since the stored value meant a different
filter.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): show the collapsed model listing by default

The gallery listing is what a user reaches for to answer "what can I
install". Answering that with several rows for the same model, one per
build, makes the reader do the deduplication the collapsed view already
does, so the collapsed view is the one to land on.

The UI now asks for collapse_variants=true unless the toggle says
otherwise. The server default is deliberately untouched: a request with
the parameter absent still returns the full listing, because other API
clients depend on that response and collapsing it under them would be a
breaking change. Opting out omits the parameter rather than sending
false, so it asks for exactly the listing everyone else gets.

The stored preference changes vocabulary from '1'/'0' to 'on'/'off'. The
previous build wrote it from an effect that runs on mount, so a stored
'0' recorded that the page had been opened rather than that anyone chose
the expanded view, and honouring it would pin every earlier visitor to a
default they never picked. Only the new vocabulary counts as a choice;
a legacy '1' meant the collapsed view and is what the new default gives
anyway, so no earlier deliberate choice is lost.

Collapsing being the default also changes what the empty state may say
about it. An opted-into filter can be named as the cause of an empty
result; a default cannot, so the filters keep the top line and the
collapsed view drops to a hint below it, shown only once filters are
narrowing the set. For the same reason "Clear filters" now restores the
collapsed default instead of switching it off, and the toggle alone no
longer counts as a filter worth offering to clear.

The label stays "One row per model": it describes the view the user is
looking at rather than an action, so it reads the same whether it is
opted into or out of.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* gallery: group alternative builds of the same weights under variants

Sweep the gallery for entries that are alternative builds of the same
weights (different quantization, precision, or runtime format) and declare
them as variants of a single parent row, so the listing offers one row per
model instead of one row per quantization and the installer picks the
largest build that this host can actually run.

41 families over 95 entries, turning 54 entries into variants.

The parent is the bare-named entry wherever one exists, so nothing changes
about what any existing entry installs. Ranking already selects the largest
fitting build regardless of which entry is nominally the parent, so the
parent only decides the pathological case where nothing fits. For the ten
families that have no bare-named entry, the smallest build is the parent,
since that is the one that has to install when nothing fits.

Grouping was verified against the actual model filenames rather than the
entry names alone. Different parameter sizes, languages, finetunes, and
products that merely share a name prefix are left as separate rows: the
qwen3.6 APEX and pi-tune finetunes, the DFlash and MTP speculative-decoding
pairings, English-only versus multilingual Whisper, the QAT versus non-QAT
Gemma 4 weights, and the abliterated FLUX build are all distinct models.

Six parents define YAML anchors that other entries pull in with a merge key,
which would have handed their variants to every merging child. For the two
depth-anything anchors that would have made fourteen unrelated entries
advertise the base model's builds as their own. All 26 merging children
therefore carry an explicit empty variants list, which overrides the merged
key and is equivalent to the key being absent.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): rank model variants by host backend preference

Variant auto-selection filtered candidates by whether their backend can
run on the host, then ranked the survivors by size alone. The backend
never influenced the choice beyond that gate, so a Mac offered both an
MLX build and a llama.cpp build kept neither filtered and installed
whichever was larger, leaving the native accelerated runtime unused. The
same held for CUDA against CPU on NVIDIA and ROCm against Vulkan on AMD.

Rank by the host's backend preference between the fit tier and size: fit
stays a filter, preference decides among the builds the host can equally
hold, and size still separates builds on equally preferred runtimes.

The preference data stays in one declarative table in pkg/system, now
read by a prefix lookup instead of a switch, so adding a capability or
reordering one host's runtimes is a one-line edit and the gallery's
ranking code carries no per-backend branching. MLX joins the metal rule
ahead of metal itself, which is inert for the existing alias-resolution
consumer because no alias group holds a candidate named for mlx.

An unrecognised backend, an unrecognised capability and an absent
preference list all collapse to the previous size-only ordering rather
than erroring or dropping candidates.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): rank variants by engine name, not backend build tag

Variant auto-selection ranked candidates with
SystemState.BackendPreferenceTokens, but that function and the variant
ranker speak different vocabularies.

BackendPreferenceTokens returns BUILD TAGS ("cuda", "rocm", "sycl",
"vulkan", "metal", "cpu"). It exists to match installed backend build
directory names like "llama-cpp-cuda-12" during alias resolution in
ListSystemBackends. Variant ranking instead matches a gallery entry's
`backend:` value, which is an ENGINE NAME: "llama-cpp", "vllm",
"vllm-omni", "sglang", "mlx" and the rest. No engine name in
gallery/index.yaml contains "cuda", "rocm", "sycl" or "vulkan".

preferenceRank matches by substring, so on an NVIDIA host the tokens
[cuda, vulkan, cpu] matched neither "llama-cpp" nor "vllm", every
candidate scored identically and size alone decided. The NVIDIA, AMD,
Intel, darwin-x86 and vulkan rules were all inert. Only metal appeared
to work, and only because the token "mlx" happens to equal an engine
name. The mismatch does not error, it silently deletes the feature.

Separate the two vocabularies. backendBuildTagPreferenceRules keeps the
build tags and its original output for every capability, including
metal, whose "mlx" token is removed again; its alias-resolution consumer
is byte-identical to before. engineNamePreferenceRules is new, holds
engine names, and is read by the new EnginePreferenceTokens, which
HostResolveEnv wires into the renamed ResolveEnv.EnginePreference. Both
tables sit adjacent under one block comment naming each vocabulary and
each consumer, and share one lookup helper so their semantics cannot
drift.

On NVIDIA the order is vLLM, then SGLang, then llama-cpp: vLLM is the
throughput engine and a model published with a vLLM build is published
that way because that build is the one worth running. AMD and Intel get
the same order, since rocm and intel builds of both serving engines
ship. Metal prefers mlx over llama-cpp. Vulkan prefers llama-cpp, the
only LLM engine with a Vulkan build. darwin-x86 and unknown
capabilities are deliberately absent rather than guessed at, degrading
to the size-only ordering that predates preference.

preferenceRank stays generic and names no engine and no capability, so
adding a runtime remains a one-line table edit.

Specs pin the NVIDIA and metal rules through the live table and the real
HostResolveEnv wiring, so emptying the engine table or wiring the build
tag source back in both go red. A regression table asserts
BackendPreferenceTokens' original output per capability, and mirrored
locks assert neither table carries the other's vocabulary.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: record that variant selection ranks by engine before size

A gallery entry can now declare variants, and selection ranks the builds a
host can run by engine preference before size. Nothing told a contributor
adding a backend that engineNamePreferenceRules exists, so a new engine would
silently rank below every known one and lose to whatever build happened to be
larger on hosts where it should have won.

Document the step where a backend is added, warn against the sibling
backendBuildTagPreferenceRules table (build tags, not engine names: the wrong
table matches nothing, scores every candidate equally and disables the
preference without erroring), and index it from AGENTS.md.

Fix the authoring and user docs, which still claimed the largest surviving
build wins. An author grouping builds under one entry has to be able to
predict what a user gets, and size alone no longer decides it.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(cli,mcp): describe variant auto-selection as preference before size

The CLI flag help and the install_model tool schema both still said
auto-selection takes the largest build that runs. Ranking now puts engine
preference ahead of size, so on NVIDIA a vLLM build wins over a larger
llama.cpp one. An assistant reading the old schema would tell users the
wrong thing.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): prefer llama.cpp over GPU serving engines on hosts with no GPU

engineNamePreferenceRules had no row for the "default" capability, which
getSystemCapabilities() returns both when no GPU is detected and when a GPU
is present but under the 4 GiB VRAM floor. A missing row yields an empty
preference list, which preferenceRank reads as "score everything equally",
collapsing variant selection to size alone.

That would be harmless if the hardware filter dropped GPU serving engines on
such a host, but it does not. IsBackendCompatible derives support from the
engine NAME, and "vllm" and "sglang" contain none of the darwin, cuda, rocm
or sycl tokens it keys on, so they fall through to its closing "return true".
A vLLM variant therefore survives on a CPU-only box and wins whenever its
build is the larger of the two on offer: the machine installs vLLM in
preference to llama.cpp.

darwin-x86 had the identical hole. It was documented as a deliberate omission
because nothing accelerates on an Intel Mac, which is true about acceleration
and wrong about consequence: with every engine tied, download size decides.

Add rows for both putting llama-cpp first. The GPU engines are enumerated
behind it rather than left unmatched: an unmatched engine already ranks below
every listed one, so llama.cpp would win either way, but unmatched engines
also tie with each other and let size decide among them. Naming them fixes
that order. MLX is left off the darwin-x86 row on purpose so it ranks last,
since IsBackendCompatible admits darwin-tokened engines on that capability
even though MLX needs Apple silicon.

Preference orders survivors and never filters, so a model published only as a
vLLM build is still installed on a host with no GPU; there is a spec for it.

Surveyed every other value getSystemCapabilities() can return. nvidia, amd,
intel and vulkan have rows; the l4t and cuda-refined values reach the nvidia
row by prefix; "apple" and "" cannot reach the vendor fallthrough because the
darwin and no-GPU branches return earlier. default and darwin-x86 were the
only live holes.

BackendPreferenceTokens and its build-tag table are untouched, and
preferenceRank stays generic, naming no engine and no capability.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* gallery: prefer speculative-decoding builds when they fit

Rank serving features between engine preference and size, so a host that can
hold a DFlash or MTP build of a model's weights installs it instead of the
plain build. Both answer faster for the same output, so whenever one survives
the filters there is no reason to take the plain build.

Precedence is now fit, then engine, then serving feature, then size. Engine
outranks the feature deliberately: a serving feature makes the right engine
faster, it does not make a wrong engine right, so a plain vLLM build still
beats a DFlash llama.cpp build on NVIDIA. Fit outranks both, and a drafter
pairing is strictly larger than the plain build, so the existing size filter
drops it on a host too small for it before this axis is consulted.

The order lives in a third preference table in pkg/system, alongside the build
tag and engine name tables. It is the odd one of the three: not keyed by
capability, because no hardware prefers a plain build over an equivalent
faster one, and matched against whole segments of a gallery ENTRY NAME rather
than as a substring of a backend value. Nothing on a gallery entry declares a
serving feature, and tags are not a usable substitute: gemma-4-e2b-it:sglang-mtp
carries an mtp tag while ornith-1.0-9b-mtp and qwen3.6-27b-nvfp4-mtp carry
none. Entry names are author-supplied free text, unlike the closed engine
vocabulary, so a short marker can turn up inside an unrelated word and whole
segment matching is what keeps smtp-assistant from ranking as an MTP build.
The block comment over the tables now documents all three together and states
what each is matched against; the ranking code names no feature, so adding one
stays a one-line edit to the table.

29c49203b rejected these entries as serving configurations rather than
alternative builds of the same weights. The definition is now "alternative ways
to serve the same model", which includes them, so regroup 14 entries under 12
parents. Judged by the files each entry points at: the qwen3.6, qwen3.5, qwen3
and deepseek pairings are the base GGUF plus a drafter, the gemma-4 QAT MTP
entries are the same QAT weights at a different quantization plus an MTP
drafter, and the two sglang MTP entries describe themselves as the same model
served with speculative decoding. Left separate: qwen3.6-27b-mtp-pi-tune, a
finetune with its own weights, and every entry whose base model LocalAI does
not ship as its own row, which is the whole Qwopus line plus gemmable-4-12b-mtp,
mimo-7b-mtp:sglang and qwen3.5-4b-dflash.

None of the twelve parents defines a YAML anchor, so no variants key can leak
through a merge key and no empty override was needed this time. The index was
edited by line insertion only.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test: check env restore errors in capability and variant specs

errcheck flagged ten unchecked os.Setenv and os.Unsetenv returns in the
specs added while the pre-commit hook was being skipped. Restoring an env
var is exactly the place a silent failure leaks state into the next spec,
so assert on it rather than suppressing the linter.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): make the mtp tag authoritative for serving-feature ranking

Variant auto-selection ranks survivors by fit, then engine, then serving
feature, then size. The serving-feature lookup read only whole alphanumeric
segments of a variant's entry name, because tags were inconsistent: every
dflash entry carried a dflash tag, but only 7 of 20 MTP entries carried an
mtp tag.

Tag the 13 untagged MTP entries, then teach the lookup to read tags as well
as names. A tag is now the authoritative signal and is compared whole and
case-insensitively, which is safe precisely because a tag is a deliberate
declaration rather than free text: there is no word-inside-a-word failure
mode, so the segment splitting the name half needs is unnecessary there.

The name check stays as a fallback rather than being replaced. Switching to
tags only would have regressed the six already-grouped entries on the day it
shipped, and would depend on tagging discipline that does not exist yet.

The lookup still names no feature, so adding one remains a one-line edit to
servingFeaturePreferenceTokens.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): make a declared tag the sole serving-feature signal

Variant auto-selection ranks survivors by fit, then engine, then serving
feature, then size. The serving-feature lookup recognised a speculative build
by either a declared tag or a whole segment of its entry name. Drop the name
half: a tag is now the only signal.

A name is author-supplied free text and a naming convention is not a contract,
so reading a marker out of one infers a capability nobody declared. The gallery
already had the failure in it: the four NVFP4 entries name MTP-bearing weights
while setting no option that enables speculative decoding, and being live
variants they were winning the feature axis without answering any faster.

overrides.options was considered as the replacement and rejected. It carries
spec_type:draft-mtp / spec_type:draft-dflash, which is what actually turns the
feature on, but that spelling is llama.cpp's config vocabulary: ds4 spells the
same feature mtp_path and sglang spells it speculative_algorithm in a
referenced config. Keying a cross-backend ranking decision on one backend's
option syntax would rank the other backends' builds as plain. Options are the
curation-time check instead, and never reach the selection logic.

With no fallback left, tag correctness is load bearing, so audit every entry
against the rule "tagged when the entry configures that feature, in whatever
vocabulary its backend uses". Three entries configure MTP untagged and gain the
tag (hy3, glm-5.2, qwythos-9b-claude-mythos-5-1m, all spec_type:draft-mtp with
no marker in their names). Four carry the tag while configuring nothing and
lose it: qwen3.6-27b-nvfp4-mtp, qwen3.6-35b-a3b-nvfp4-mtp,
qwopus3.6-27b-coder-mtp-nvfp4 and qwopus3.6-27b-v2-mtp-nvfp4, whose only option
is use_jinja:true. The dflash side was checked independently rather than assumed
consistent: all five dflash entries declare spec_type:draft-dflash and all five
are tagged, so it needed no edits.

Four entries keep a tag that a literal spec_type-only reading would strip,
because they configure MTP through a different backend: deepseek-v4-flash-q2-mtp
via ds4's mtp_path/mtp_draft, and the three sglang entries via
speculative_algorithm in their referenced configs. Stripping those would
contradict the reason spec_type was rejected as the signal and would demote four
genuinely faster builds to plain.

The index was edited by line insertion and deletion only, never round-tripped
through a serializer. A resolved-tag diff across all 1272 named entries, taken
after merge keys are applied, shows exactly these 7 changing and no entry
gaining or losing a tag through an anchor.

The two specs that pinned the name fallback are inverted rather than deleted,
since a name silently promoting a build is the regression worth guarding. The
whole-token guard survives on the tag path, where smtp must still not match mtp.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): make deepseek-v4-flash variant targets installable

Clicking install on deepseek-v4-flash failed with "invalid gallery model".
The parent entry is fine, but all four entries it was grouped with declared
neither url: nor config_file:, and applyModel needs one of the two to have
anything to build a config from. They carry urls: (plural), the informational
HuggingFace link list, which is a different field. None of the four was ever
independently installable, so grouping them routed a previously-working
install into a broken entry.

Give each the url: the parent already resolves through. virtual.yaml is a
no-op base, and applyModel passes overrides to InstallModel separately from
the fetched config, so backend: ds4, the parameters and the ssd/mtp options
all still land exactly as authored. This is the same pattern the parent and
many other GGUF entries in the index already use.

Add the lint rule that should have caught this. checkVariantReferences only
proved a target exists and is not itself a parent, which is structural
validity: an entry can exist, declare no variants, and still be
uninstallable. checkVariantTargetsInstallable mirrors applyModel's
precondition instead, and names the parent, the target and the missing
fields, because whoever hits it is reading a gallery entry and has no reason
to know applyModel exists.

The two index-driven resolution specs live in their own Ordered container:
an Ordered container stops at its first failure, so sharing one with the lint
rules let a lint breach skip them silently.

Nine further entries gallery-wide have the same defect and are unrelated to
variants, so they are broken installs that predate this branch. They are left
alone here rather than buried in a regression fix, and widening the rule to
cover every entry is deferred with them so the gate can ratchet up in one
step instead of needing a skip list.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): install entries with no url or config_file on an empty base

applyModel had three branches: fetch a base config from url:, build one from
an inline config_file:, or fail with "invalid gallery model". An entry
declaring neither is now installed on an empty base config, with overrides:
and files: supplying everything.

This is what the ~345 entries pointing at gallery/virtual.yaml were already
getting. That stub is five lines carrying name, description and license.
description and license are overwritten from the gallery entry immediately
after the fetch, and the name never reaches disk because InstallModel prefers
the install name. Crucially applyModel passes model.Overrides to InstallModel
as a separate argument rather than merging it into the fetched config, so
nothing an author writes depends on that base existing. The fetch bought a
round trip to GitHub and nothing else.

That makes f4ef80173 the wrong fix, so it is unwound. The four url: lines it
added to the deepseek-v4-flash variants are reverted: they are a pointless
network fetch now, and the family installs without them.

Relaxing the branch would hide a real authoring mistake, so a payload rule
replaces the base-config rule. An entry with no url, no config_file, no
overrides and no files installs nothing and would leave an empty model
directory while reporting success, so it is refused by name. The caller's
request counts toward the payload, because its overrides and files are merged
into the install exactly as the entry's own are. urls: (plural) is the
informational link list and does not count, which is what the four entries
that shipped broken had and why they were still uninstallable.

checkVariantTargetsInstallable asserted every variant target declares a url:
or a config_file:, which is no longer true and would now reject correct
authoring. checkEntriesInstallSomething pins what survives instead, and covers
every entry rather than only variant targets: the hazard is a half-written
stanza and a parent can be one as easily as a target. The old rule was scoped
to targets precisely because nine unrelated entries would have failed a
gallery-wide version; those nine are valid now, so the deferred ratchet
happens here in one step. 1280 entries, zero violations.

Those nine (aurore-reveil_koto-small-7b-it, lfm2-1.2b, the six liquidai_lfm2
entries and deepseek-v4-pro-q2-ssd) become installable for free. Each carries
overrides: and files:, and one of them is driven through the real install path
in a spec.

The no-fetch spec is paired rather than bare: an assertion that nothing was
fetched proves nothing unless something could have been, so a control runs the
same fixture with a url: pointing at a base config that is not there and
asserts the install fails. Only then does the identical fixture without the
url passing mean the read was skipped.

Follow-up, deliberately not here: the ~345 entries still naming virtual.yaml
can drop their url:. That is 345 index edits with their own risk, and mixing
them in would bury this change.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* ui(models): let search bypass the variant collapse, drop the toggle

The models page collapsed the gallery to one row per model by default and
offered a toggle to see every individual build. Because the collapse composed
with the search term, a build another entry offers as a variant could not be
found by typing its name, so the toggle was the only way to reach those builds
in the UI. A user who typed a name they knew existed got "no models found",
which reads as "that model does not exist".

Collapse is for browsing; search is for finding. An explicit search term now
bypasses the collapse in the listing handler, so a name lookup returns matching
entries whether or not a parent offers them. The term is trimmed once at the
top of the handler, so whitespace is neither a search nor a bypass; previously
an untrimmed blank term also narrowed the listing to whatever contained a
space. Tag and backend deliberately do not bypass: they refine a listing the
user is still reading rather than name an entry already known to exist.

That makes the toggle redundant, so it goes, along with its i18n strings in all
six locales, its localStorage persistence, its participation in "Clear filters"
and the empty-state hint telling users to turn it off. The hint was doubly
stale: it pointed at a control that no longer exists, and it was untrue exactly
when a user has a search term, since searching now sees every build. The page
always requests the collapsed listing.

The stored preference key is left inert rather than cleaned up: nothing reads
it, so a user who had the toggle off simply gets the collapsed view.

collapse_variants stays on the API, off by default, because other clients want
either view and the UI dropping its control is no reason to remove a working
parameter.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): give the models gallery filter form a deliberate structure

The filter area had accreted controls into one undifferentiated flow. The
"Fits in GPU" toggle and the backend select were direct children of
.filter-bar, the same wrapping container as the 18 taxonomy chips, so their
position was decided by how many chips happened to wrap at the current width
rather than by any layout intent. At narrow widths they were pushed past the
right edge of that container's horizontal scroll and became unreachable
entirely.

Restructure into three bands inside the house .filter-bar-group wrapper that
components/FilterBar.jsx already uses on Backends and the System tabs:

  1. query scope: search plus the backend select
  2. taxonomy: the chip row, alone, free to wrap
  3. refinements: fits-in-GPU and context size, under a hairline rule

The backend select leads the chips rather than trailing them because picking a
backend disables the use cases that backend cannot serve, so it gates the row
below it. Fits-in-GPU and context size share a band because they are one
control group: the context size is the length the VRAM estimate is computed at,
and that estimate is what the fits filter tests against.

Chips had no visible keyboard focus indicator. The global focus ring is wrapped
in :where(), so it carries the specificity of a bare :focus-visible, ties with
.filter-btn and loses on source order, leaving focused chips showing their
resting drop shadow. Restate the ring where it outranks both resting and hover.

Also: aria-pressed on the chips, a real label association and aria-valuetext on
the context slider (it steps over an index, so it announced "2"), disabled chip
styling moved off inline styles, a prefers-reduced-motion block for the chip
transition, and the hard-coded English "Context:" moved into all seven locales.

No behaviour change: same filters, same state, same requests. Page reset on
change, localStorage persistence and "Clear filters" verified unchanged.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): let the models recommendations panel fade into the background

The "Recommended for your hardware" strip rendered at full height on every
visit regardless of how many models were already installed, costing 186px at
1600px wide (287px at 1100px, where its cards wrapped to two rows) and pushing
the first gallery row to y=554 / y=703.

Make its prominence track how much the user still needs it. The panel now
defaults to a one-line summary once anything is installed, and both the
collapse choice and the existing dismissal persist:

  collapsed = explicit user choice, if one exists
            : installedCount > 0

The preference is three-valued on purpose. A boolean cannot tell "the user
expanded it" apart from "the user has never chosen", and those need opposite
handling when the installed count later crosses zero: someone who deliberately
opened the panel on an empty instance should not have it collapse out from
under them when their first model finishes installing.

Collapsed keeps the card, icon, title and a suggestion count, so the panel is
recovered by clicking what you are already looking at rather than by hunting.
Expanded is unchanged, because for a user with nothing installed it was never
the problem. Collapsed reclaims 145px at 1600 and 420, and 246px at 1100.

Models.jsx gains a statsLoaded flag: stats initializes to installed:0, so
reading it before the fetch resolves would render expanded and collapse a frame
later, which is exactly the layout shove this removes.

The dismissal key moves to the page's localai-models-* convention; the old
localai_rec_models_dismissed is still read, never written, so an existing
dismissal is honoured rather than resurrected by the rename.

Accessibility: the disclosure is a real button whose accessible name is the
visible title alone, with state on aria-expanded and aria-controls resolving in
both states, because the grid is hidden via the hidden attribute rather than
unmounted. That also keeps the four install buttons out of the tab order while
collapsed. The app's global focus ring applies; no per-component outline is
added, per the warning in App.css. Reveal animates opacity and transform only,
never height, and both it and the chevron rotation are disabled under
prefers-reduced-motion.

Only en had a recommended block, so the other six locales were falling back to
English for the whole panel. Translated the complete block rather than adding
one orphaned key to files that would still render the title in English.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(downloader): recover from a leftover .partial on non-HTTP URIs

An interrupted download leaves a `<file>.partial` behind. The partial
handling in DownloadFileWithContext gated resume on `err == nil &&
uri.LooksLikeHTTPURL()`, so for any URI that is not literally http(s)
the branch fell through to `else if !errors.Is(err, os.ErrNotExist)`,
which with a nil err is true. The download then failed with an error
wrapping nil:

  failed to check file ".../Ternary-Bonsai-27B-Q2_g64.gguf" existence: <nil>

Every gallery file URI uses `huggingface://`, so a single interrupted
download made that model permanently uninstallable until someone
deleted the partial by hand. The `<nil>` in the message compounded it
by pointing debugging at a filesystem failure that never happened.

Restructure the handling as an explicit switch over the four real
states: partial exists and is resumable, partial exists and is not
resumable (discard and restart, as already done for an HTTP server
without range support), no partial, and a genuine stat failure. The
error branch is now only reachable with a non-nil error, names the
path that was actually stat'd, and wraps with %w.

Discarding is required for correctness and not merely convenience: the
writer opens the partial with O_APPEND, so an un-resumed download would
concatenate a fresh body onto stale bytes.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): tell the models gallery's variant rows apart, and let browsing see every build

Both variant surfaces rendered name, backend and size. For two builds of one
model that is close to no information: a variant exists precisely because the
same weights are offered another way, so the backend usually matches and the
sizes usually land within a few hundred megabytes. Comparing
ternary-bonsai-27b-pq2 against ternary-bonsai-27b-q2-g64 meant reading two names
that differ by a suffix nobody has defined anywhere in the UI.

Report the quantization and the serving features on VariantView, and derive both
server-side from the referenced entry rather than parsing names in the browser,
so every client reads the same format out of the same file the installer will
hand the backend.

Quantization comes from overrides.parameters.model first, falling back to the
file list. That order is load bearing: entries routinely ship a vision tower
alongside the language model at a different quantization, so reading the file
list first reports the mmproj's format. Matching walks `-` and `.` delimited
segments right to left; `_` deliberately does not split, because it separates the
parts INSIDE a quant token and splitting on it reports Q4 for a Q4_K_M build. A
second, looser pass takes a segment's `_`-delimited tail, which catches the
gemma-4-E2B_q4_0-it.gguf style; it runs second so a precise match can never lose
to a fuzzy one further right in the name. An entry naming no format reports
nothing, which is the honest answer for a backend served from a directory of
weights.

Features are the same tag-against-vocabulary match servingFeatureRank already
ranks on, over the same host preference list. A build can therefore never be
shown as faster than one selection did not actually reward, nor rewarded without
being shown; a spec pins that agreement rather than trusting it.

The compact dropdown gets the quantization on its meta line and the bare feature
token. The detail row, which has the room, gets the quantization as its own
monospaced column so precision lines up down the list, and the feature spelled
out, because DFLASH names nothing to a user who has not met it. The referenced
entry's description stays out of both: the detail row already renders the
parent's prose above the table, and a second block per variant would push a
three-variant list past a screen to restate what the columns now say precisely.

The collapse toggle comes back. 462583f38 dropped it once search bypassed the
collapse, on the reasoning that nothing was unreachable any more. That holds for
finding a build whose name you know and does not hold for browsing: no sequence
of actions enumerated the 68 builds the default view hides. Collapse is for
browsing and search is for finding, and the toggle was the browsing half.

It goes in the refinements band 0d4823362 established, not back among the
taxonomy chips where its position depended on how many chips happened to wrap. It
leads that band because it decides how many rows the other two refine over, and
because unlike fits-in-GPU it is unconditional: a host with no GPU still browses.

The search bypass is untouched and re-checked by a spec in the toggle's default
state, since restoring the control must not restore the dead end it replaced. The
empty-state hint returns but only without a search term, because a term bypasses
the collapse and the hint would otherwise point at a control that cannot change
the result. The stored preference reads 'on'/'off' only: an older build wrote
'1'/'0' from an effect that ran on mount, so those record that the page was
opened, not that anyone chose a view.

Also fixes a latent flake it exposed. The collapse_variants spec compared whole
response bodies byte for byte, and the listing envelope carries live host
telemetry that drifts between two calls milliseconds apart, so it was asserting
on the machine's memory pressure. It now compares everything the parameter
governs -- the entries, their serialization and the paging -- and is green 25/25
where it was failing about one run in three.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): let the models gallery show a variant's full details

The variant list in an entry's expanded detail row says how the builds
differ: name, backend, quantization, size, and the auto-selected, base
and serving-feature markers. It cannot say what any one of them is. A
variant's own description, tags, license, source links and file list are
unreachable anywhere in the UI, because while the collapse is on a
variant has no gallery row of its own.

Give each variant row an info control that reveals its entry, rendered by
the same ModelDetail a top-level row gets, so a field added to the detail
view appears here too. variantData is withheld from the nested render: a
variant may declare variants of its own, and recursing would nest a
picker inside a picker two levels deep already.

An inline disclosure rather than a modal. The control sits inside a table
row that is already expanded, inside a variant list within that; a dialog
opened from there stacks a dismissal on a dismissal for a handful of
extra fields about the entry the user is already reading, and breaks the
page's own expand idiom. The third level is carried by an inset and a
left rule instead of another card.

The entry is fetched by exact name from the listing, once, on first use.
The listing already returns every field the detail view renders, and a
search term bypasses the variant collapse server-side, so no new endpoint
is needed and neither the listing nor DescribeVariants gains any work.
Expanding a row costs nothing; a variant nobody opens costs nothing. A
name the listing no longer returns is stated, not blanked: an empty panel
reads as a rendering fault rather than as a lookup that came back empty.

The control is a sibling of the install button, not a descendant, so
asking about a build can never install it.

The variant list keeps its content-sized columns via a trailing filler
track instead of max-content sizing, so the rows are unchanged while the
panel spanning them gets the pane width its file table needs.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): let search respect the collapse instead of switching it off

The models listing collapsed to one row per model, and an explicit search term
turned that off wholesale. Searching while collapsed therefore answered with the
individual builds a parent already offers, which are exactly the rows the view
the user asked for has no place for: typing "mtp" returned
qwen3.6-27b-nvfp4-mtp, a row that is invisible the moment the box is cleared.
The bypass was the right shape of fix for the wrong half of the problem. What a
search must not do is answer "no models found" for a build the gallery does
hold; that does not require abandoning the grouping the user asked for.

So the term is now matched against every entry either way, hidden builds
included, and the collapse decides how a match is reported rather than which
matches exist. Collapsing stops being a filter that drops rows and becomes a
substitution: a match on a build another entry offers is reported as that entry,
the one installable in its own right. Nothing becomes unfindable and nothing
comes back that the requested view cannot show.

Substitution happens after search, tag and backend, so every filter is judged
against the build that really carries the name, tag or backend rather than
against a parent that merely offers it; the other order would let backend=vllm
match a parent whose own backend is something else. The price is that the
surfaced row shows the parent's own metadata while the match was on a variant,
which is what grouping means, and the alternative is claiming the gallery holds
no such build. It happens before the count and the page math, so both describe
the rows actually handed out rather than the matches that produced them.

A parent already in the result keeps its own position and absorbs its matching
variants there, which is what leaves the browsing listing ordered exactly as it
was; a parent surfaced only by a variant takes the position of the first variant
that surfaced it. Either way it appears once, however many of its builds matched
and whether or not it matched itself. Search preserves gallery order rather than
scoring, so a surfaced parent has a real position rather than an invented one.

VariantParents never reports an entry that declares variants of its own, so a
parent is never itself hidden and one hop always lands on a visible row. The
handler follows exactly one anyway: refusing the second is what makes a gallery
the linter would have rejected terminate rather than loop.

The empty-state hint pointing at the toggle goes with it for every server-side
filter. Substitution means a match is always reported as some row, so the
collapse can no longer be why a term, a chip or a backend came back empty, and
naming it there sends the user to a control that cannot change the result. It
survives for the fits filter alone, which runs in the browser after the
substitution and judges the surfaced entry's own size: there the build that fits
really can be filtered out along with a parent that does not.

Searching a build's exact name while collapsed now answers with its parent, so
the result no longer contains the string the user typed. That is intended, and
the row is the one they can act on, but it is a real rough edge: nothing on the
row explains the connection. Closing it properly means reporting which variant
matched so the UI can say so, which the listing does not do today.

ResetGalleryModelCache is added for tests. The model cache is a package global
keyed by nothing, so a background refresh one spec triggers can land in the
middle of the next and answer it with the previous spec's gallery; the extra
specs here made that fail about one run in five. It waits for the in-flight
refresh to publish before clearing, since clearing alone only narrows the
window.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 18:43:02 +02:00
mudler's LocalAI [bot]
6e52d0c2ef fix(ci): rebuild backends when shared build inputs change (#10975)
The backend matrix path filter only matched files under a backend's own
directory, so a change to shared build infrastructure rebuilt nothing at
all: an empty matrix, every job green, and the change reaching no image.

PR #10946 fixed scripts/build/package-gpu-libs.sh shipping a partial
4-of-8 cuDNN library set, which mixed versions with the venv's pip cuDNN
and produced CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH at inference time.
It merged 1h48m after the weekly full-matrix cron had already run, so no
backend image ever received the fix and nothing signalled that it had
been un-shipped.

Add a SHARED_BUILD_INPUTS table mapping each shared path to the narrowest
set of matrix entries it can honestly invalidate, plus a generic rule for
backend/Dockerfile.<x> (which each entry already names). A full matrix is
417 Linux + 56 Darwin builds, so package-gpu-libs.sh now rebuilds the 176
Python entries rather than everything. Unclassified files under
scripts/build/ fall back to a full rebuild deliberately: over-building is
recoverable, silently shipping nothing is not.

Extract the filtering logic to scripts/lib/backend-filter.mjs so it can be
unit-tested without bun, js-yaml or a GitHub API round-trip, and run those
tests from the existing lint workflow via `make test-ci-scripts`.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 13:48:12 +02:00
mudler's LocalAI [bot]
465d488c90 fix(distributed): reject wrong-model requests at the backend (#10970)
fix(distributed): reject wrong-model requests at the backend (#10952)

In distributed mode the controller caches a NodeModel row naming a backend's
host:port. A worker can recycle a stopped backend's gRPC port for a different
model's backend, and probeHealth verifies liveness rather than identity, so the
probe succeeds against whatever now occupies the port and the request is
dispatched to the wrong backend. The caller gets a silent wrong-model answer.

Nothing in the request could catch this: PredictOptions had no model field, so
model identity crossed the wire only in ModelOptions.Model at LoadModel time,
and the cached-hit path issues no LoadModel. Every backend's "model not loaded"
guard checks a nil handle, which a process holding a different model passes, so
the stale row was never dropped either.

Add PredictOptions.ModelIdentity and enforce it at the point of use:

  - The controller populates it in gRPCPredictOpts from ModelConfig.Model, the
    same expression ModelOptions feeds to model.WithModel and therefore the
    same value the backend received as ModelOptions.Model. Both are read from
    one config value in one function, so they are equal by construction and the
    comparison cannot false-reject.
  - Backends compare it against what they loaded and return NOT_FOUND with a
    fixed sentinel. Enforced in pkg/grpc/server.go (27 Go backends), an
    interceptor in backend/python/common (all 36 Python backends, no
    per-backend change), and the llama-cpp / ik-llama-cpp / ds4 C++ servers.
    That is every backend with real exposure: kokoros answers all four RPCs
    with unimplemented and privacy-filter implements none of them.
  - The router's reconcile drops the stale replica row on a mismatch, so the
    next request reloads somewhere correct.

Empty means "skip the check" on both sides: a controller that predates the
field sends nothing, a backend loaded by such a controller has nothing to
compare, and the C++ server synthesizes PredictOptions internally for ASR. That
keeps upgrades working in both directions.

Scoped to the four PredictOptions RPCs. TTSRequest.model and
SoundGenerationRequest.model are deliberately NOT validated: FileStagingClient
already rewrites them to worker-local absolute paths, so in distributed mode
they already differ from the load-time value and comparing them would reject
valid requests.

IsModelMismatch requires both the NOT_FOUND code and the sentinel, unlike the
neighbouring helpers which accept either. insightface's Embedding returns
NOT_FOUND "no face detected" on a PredictOptions RPC, and a code-only check
would drop a healthy replica row on every faceless image.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 13:05:47 +02:00
mudler's LocalAI [bot]
1618c2e445 chore(model gallery): 🤖 add 1 new models via gallery agent (#10971)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-20 08:34:55 +02:00
dependabot[bot]
9043cbc786 chore(deps): bump torch CPU wheels to 2.12.1 (#10969)
* chore(deps): bump the pip group across 6 directories with 1 update

Bumps the pip group with 1 update in the /backend/python/ace-step directory: torch.
Bumps the pip group with 1 update in the /backend/python/llama-cpp-quantization directory: torch.
Bumps the pip group with 1 update in the /backend/python/longcat-video directory: torch.
Bumps the pip group with 1 update in the /backend/python/sglang directory: torch.
Bumps the pip group with 1 update in the /backend/python/trl directory: torch.
Bumps the pip group with 1 update in the /backend/python/vllm-omni directory: torch.


Updates `torch` from 2.10.0+rocm7.0 to 2.12.1+cpu

Updates `torch` from 2.10.0 to 2.12.1+cpu

Updates `torch` from 2.12.1 to 2.12.1+cu130

Updates `torch` from 2.9.0 to 2.12.1+cpu

Updates `torch` from 2.10.0 to 2.12.1+cpu

Updates `torch` from 2.7.0 to 2.12.1+cu130

---
updated-dependencies:
- dependency-name: torch
  dependency-version: 2.12.1+cpu
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: torch
  dependency-version: 2.12.1+cpu
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: torch
  dependency-version: 2.12.1+cu130
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: torch
  dependency-version: 2.12.1+cpu
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: torch
  dependency-version: 2.12.1+cpu
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: torch
  dependency-version: 2.12.1+cu130
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(deps): preserve platform-specific torch requirements

Keep the 2.12.1 CPU bump only where uv resolves it cleanly, and restore ROCm, CUDA, MPS, and unrelated transformers constraints that Dependabot rewrote to incompatible wheel variants.

Assisted-by: Codex:gpt-5 [uv]

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-20 08:34:33 +02:00
localai-org-maint-bot
0406741a8c fix(vibevoice): install diffusers from PyPI instead of git main (#10972)
Every vibevoice requirements file pulled diffusers straight from
git+https://github.com/huggingface/diffusers. That branch now reports
itself as 0.40.0.dev0 and requires huggingface-hub>=1.23.0,<2.0, while
transformers>=4.51.3,<5.0.0 (which upstream VibeVoice mandates) still
caps huggingface-hub at <1.0. Because a git URL offers the resolver
exactly one candidate version, uv has nothing to backtrack to and the
install fails outright:

  Because only diffusers==0.40.0.dev0 is available and diffusers==0.40.0.dev0
  depends on huggingface-hub>=1.23.0,<2.0 [...] we can conclude that your
  requirements are unsatisfiable.

This broke the vibevoice build on every variant - cpu (amd64/arm64),
cuda 12/13, l4t 12/13, intel and rocm, plus the darwin metal job - and
has been failing the weekly full-matrix rebuild for three weeks. It is
not caught by master pushes because backend builds are path-filtered
there, so it only surfaces on the Sunday cron and on release tags.

Use the PyPI package instead. That is what upstream VibeVoice declares
in its own pyproject.toml, and what every other LocalAI backend already
does - vibevoice was the only one tracking the git branch. With a real
release series available the resolver settles on diffusers 0.39.0 with
huggingface-hub 0.36.2 and transformers 4.57.6, and it can keep
backtracking on its own if upstream shifts again.

Verified with uv pip compile against cpu, cublas12, cublas13, hipblas,
intel, mps and l4t13: all resolve to that same coherent set. l4t12 only
resolves on aarch64, since its Jetson index ships no x86_64 torch wheel.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Bash] [uv]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 08:27:25 +02:00
Nandana Dileep
b5e4413eab feat: add MiniMax-M3 model support (#10837)
Adds inference parameter defaults for the minimax-m3 model family and
includes a vendored patch of upstream llama.cpp PR #24523 to recognize
the minimax-m3 architecture. Once the upstream PR merges, the patch can
be removed and LLAMA_VERSION bumped normally.

Changes:
- backend/cpp/llama-cpp/patches/0001-add-minimax-m3-support.patch:
  vendored patch from ggml-org/llama.cpp#24523 (Preliminary MiniMax-M3
  support). Applied by prepare.sh during the build; keeps the pinned
  LLAMA_VERSION pointing at the latest upstream tag.
- core/config/inference_defaults.json: add minimax-m3 family entry
  (temperature=1.0, top_p=0.95, top_k=40, min_p=0.01,
  repeat_penalty=1.0, matching the existing minimax defaults) and
  register it in the patterns list before the shorter minimax-m2.7
  entry for correct longest-match-first ordering.

Upstream: depends on ggml-org/llama.cpp#24523
Closes: https://github.com/mudler/LocalAI/issues/10820

Signed-off-by: Nandana Dileep <110280757+nandanadileep@users.noreply.github.com>
2026-07-20 08:26:51 +02:00
mudler's LocalAI [bot]
e55cc3e2a7 fix(worker): bound the gRPC port allocator and stop leaking dead backends' ports (#10968)
The worker's gRPC port allocator grew monotonically with no upper bound:
nextPort started at the base port and incremented whenever freePorts was
empty, and nothing checked 65535. Past that it handed out integers that
cannot be bound, surfacing as an opaque "backend won't start".

#10961 estimated this needed ~15,000 concurrent-peak allocations, i.e.
effectively unreachable. It is not, because of a second defect: the
"process died unexpectedly" branch in startBackend deleted the process
map entry without releasing its port at all. That port was leaked, never
quarantined and never reused. A crash-looping backend leaks one port per
restart, so a backend dying every 30s walks 50051 to 65535 in about five
days. The leak, not concurrent peak, is the realistic route to exhaustion.

Fixing the leak alone would have been wrong. Releasing that port makes it
re-bindable, and the death path is the one teardown path with no
request/reply to carry StoppedProcessKeys back to the controller (#10952's
eager row removal), so a stale NodeModel row could then resolve to a live
listener belonging to a different backend. probeHealth verifies liveness,
not identity, so the request is silently misrouted. The 15s port
quarantine does not cover this: the only reaper is the per-model health
check at ~45s, and it can be disabled outright. The residual was masked
only because the port was never rebound.

So both are fixed together:

- The allocator takes an explicit [basePort, LOCALAI_GRPC_MAX_PORT] range
  and returns ErrNoFreePort naming the range, the live backend count, the
  quarantined count, and the knob to raise. Exhaustion is now diagnosable
  instead of surfacing as an unbindable port.

- Released ports carry per-key affinity: a port is offered back to the
  process key that last held it before any other key. Process keys
  (modelID#replica) and NodeModel rows (nodeID, modelName, replicaIndex)
  are isomorphic, so a port that can only be re-bound by its previous
  owner can only ever be named by that owner's row, which that key's
  re-registration overwrites. Misrouting to a different model becomes
  impossible by construction rather than by racing the quarantine timer.

Affinity is a preference, not a reservation: under range pressure an owned
port is stolen with a warning, because a guaranteed outage is worse than a
rare misroute window on a port long out of quarantine. Claiming a port
evicts its previous owner's entry, keeping ownership injective over ports
so the affinity map can never exceed the range width regardless of how
many distinct model keys the worker sees.

Ownership also expires. It is only load-bearing while a controller row
could still name the port, which the per-model reaper bounds at roughly
45s, so it lapses after five minutes and the port becomes ordinary free
space again. Holding it indefinitely would have made every distinct model
the worker ever served consume a port permanently: every release path is
keyed, so nothing would ever be unowned, the allocator would climb to the
end of its range on distinct-key count rather than concurrency, stealing
would become routine, and the steal warning would tell operators to widen
a range that was not the constraint. With expiry, reaching the steal
branch means the worker is genuinely out of concurrent capacity, so that
advice is correct when it appears.

Closes #10961
Closes #10952


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-19 23:56:37 +00:00
mudler's LocalAI [bot]
9d82c37f98 fix(distributed): backend discovery hid worker-installed backends behind the controller's filesystem (#10967)
fix(distributed): backend discovery hid worker-installed backends

Backend discovery endpoints filter on installed-state, which on a
distributed controller derives from the controller's own filesystem. A
backend lives on the worker node that runs it, so every backend an admin
installed on a GPU worker read as "not installed" and vanished from the
listing. #10947 fixed the sibling capability filter on the same endpoints,
so a fine-tuning-capable GPU worker now made the backend listable while
the installed-state filter still dropped it: the dropdown stayed empty.

The controller cannot derive this locally, but it already aggregates the
per-node view that GET /backends renders, so discovery reuses the active
BackendManager rather than growing a second path. Three surfaces shared the
root cause and route through the same helper now:

  - GET /backends/available (Installed is now cluster-wide)
  - GET /api/fine-tuning/backends
  - GET /api/quantization/backends

The response stays a boolean rather than an installed-on-N-of-M count:
per-node install state is already served by GET /backends nodes[], and
per-node control by POST /api/nodes/:id/backends/install, so a summary is
all these dropdowns need.

A nil provider (single-node) leaves the local filesystem as the only source
and reproduces today's listing exactly, and a registry error degrades to
that same listing instead of blanking the catalog.


Assisted-by: Claude:claude-opus-4-8 golangci-lint

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 01:09:37 +02:00
mudler's LocalAI [bot]
f735cb24c0 fix(worker): reap deleted backends and stop models that live on a worker (#10956)
* fix(worker): reap deleted backends and stop models that live on a worker

Three related backend-lifecycle defects, all reachable from the same
production incident on a Jetson/Thor worker: a deleted backend's gRPC
process survived ~40 minutes with its directory removed from disk, a later
model load was routed to that orphan and failed with a certifi path pointing
into the deleted directory, and the admin could not stop the model because
the frontend reported it as not loaded.

1. backend.delete orphaned the process it claimed to delete
------------------------------------------------------------
s.processes is keyed by `modelID#replicaIndex` (buildProcessKey), so the
backend name never appeared in a key and was recorded nowhere on the
process. backend.delete resolved its target via isRunning/stopBackend, whose
prefix path only matches a bare *modelID* - a delete keyed on a backend name
resolved to zero keys, the stop silently no-op'd, and the files were removed
out from under a live process.

The install fast path then handed that orphan back out: it returns any live
process for the (model, replica) slot without checking which backend started
it, so a reinstalled variant inherited the deleted backend's port.

- Record backendName on backendProcess, threaded installBackend ->
  startBackend.
- Add resolveProcessKeysForBackend, matching the recorded name and resolving
  alias <-> concrete via ListSystemBackends *before* DeleteBackendFromSystem
  erases the metadata that carries the alias. Alias resolution failure
  degrades to name-only matching so a delete never fails on it.
- backend.stop goes through resolveStopTargets, which accepts a backend
  name, a model name, or an exact modelID#replica key. Its payload field is
  named "backend" but is published with all three meanings: the admin UI
  sends a backend name, UnloadRemoteModel sends a model name, and the
  router's abandoned-load reap (#10948) sends an exact replica key.
  Narrowing it to backend names alone would strand the latter two.
  backend.delete stays strict - its identifier is unambiguously a backend.
- Gate the install fast path on processMatchesBackend so a slot held by a
  different backend is restarted rather than reused. Processes with no
  recorded name (pre-upgrade) are accepted, so rollout does not restart
  every running backend.
- stopBackendExact reports a real stop failure - the process still being
  alive afterwards, which is precisely what finishBackendStop already
  detects to keep the entry and its port reserved - and backend.delete no
  longer replies success when it knew about a process and could not kill it.
  "No process was running" stays a success but is logged, so the orphan case
  is visible rather than silent.

2. /backend/shutdown reported a running model as missing
---------------------------------------------------------
ModelLoader.deleteProcess short-circuits on a miss in this replica's
in-memory store. In distributed mode the authoritative record of "is this
model loaded" is the shared node registry: a frontend replica that never
served the model itself (load balancer picked a peer, or the replica
restarted) has no local entry. The remote unload path that pkg/model
documents ("when ShutdownModel is called for a model with no local process,
UnloadRemoteModel is called") sat behind that short-circuit, unreachable in
exactly the case it exists for. #10865 reworked this function but kept the
short-circuit at the top, so the gap survived that refactor.

- deleteProcess consults the remote unloader on a local-store miss, via a
  shared unloadRemote helper so this branch and the existing
  no-local-process branch both prefer #10865's RemoteModelContextUnloader,
  preserving force propagation across the distributed boundary.
- UnloadRemoteModelContext reports ErrRemoteModelNotLoaded when no node has
  the model; it previously returned nil, making a no-op stop
  indistinguishable from a real one. The converse case (nodes have it, none
  could be stopped) already errors since #10865 joined the per-node
  failures, so that half of the original fix was dropped as redundant.
- Only when the model is absent locally AND cluster-wide does the endpoint
  report not-found, now 404 naming both scopes rather than a bare 500.
- modelNotFoundErr becomes the exported ErrModelNotFound so the HTTP layer
  can map it without string matching; watchdog's identity comparison becomes
  errors.Is.

3. Coverage for the bounded Free() that #10865 shipped untested
----------------------------------------------------------------
The original branch also bounded the pre-stop Free(), but #10865 landed that
fix first (workerBackendFreeTimeout, applied in both stopBackendExact and
handleModelUnload). That production change is therefore DROPPED here as
superseded - master's version is strictly better, since it also releases the
supervisor mutex across the call and keeps the port reserved until
termination completes.

What #10865 did not ship is a test, and the bound is load-bearing: the
router-side reap in #10948 sends backend.stop for an abandoned load, and
against a wedged backend an unbounded Free would swallow that stop before it
reached the process. Nothing failed if the bound regressed.

The spec stands up a real gRPC backend server whose Free handler never
returns - what a Python backend looks like when its single worker thread
(PYTHON_GRPC_MAX_WORKERS=1 on 37 backends) is occupied by a stuck LoadModel.
A stub socket is not sufficient and was tried first: without a completed
HTTP/2 handshake, gRPC's own ~20s connect timeout ends the call, so that
version passed against the very bug it targets. With the connection READY,
only the caller's deadline can end it, so the spec hangs to its 60s limit if
the timeout is removed and passes with it.

Its fixture process is deliberately never started. go-processmanager v0.1.1
writes Process.pid from readPID() without synchronization, so a live process
races its own monitor goroutine under -race - reproducible with a bare
Run()+Stop() and unrelated to this spec. Since
scripts/model-lifecycle-conformance.sh runs this package with -race and is
fail-closed, starting one would turn that gate red on an upstream defect. An
unstarted process still proves the point: the stop is reached and the slot
released, which is exactly what an unbounded Free prevents.

Verified: make lint (new-from-merge-base origin/master) reports 0 issues;
scripts/model-lifecycle-conformance.sh passes all three stages including the
FizzBee liveness check (1458 states, IsLive: true).

Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(distributed): keep remote unload idempotent, ask presence separately

2035a4d25 made UnloadRemoteModel return ErrRemoteModelNotLoaded when no node
holds the model, so ShutdownModel could answer 404 instead of a misleading
500. That narrowed a shared adapter contract to serve one caller and broke
the documented idempotent-unload guarantee, which CI caught on PR #10956:

  [FAIL] Node Backend Lifecycle (NATS-driven) > NATS backend.stop events
         should be no-op for models not on any node [Distributed]
         Expected success, but got: model not loaded on any node

The spec name states the contract outright. The matching unit assertion was
updated in that commit; this e2e one was missed because it lives under
tests/e2e/ with no build tags and does not run in package-scoped test runs.

Caller audit - who breaks when an idempotent unload becomes an error:

- pkg/model/watchdog.go:902 (LRU memory reclaimer) is the serious one. It
  untracks a model ONLY when shutdown returns nil or ErrModelNotFound. A new
  error type means the model is never untracked, so the reclaimer keeps
  re-selecting the same entry and never reclaims - a live wedge whenever a
  local store entry outlives the remote model.
- core/services/galleryop/managers_local.go:43 (DeleteModel) would warn on
  every deletion of an already-unloaded model.
- core/services/modeladmin/{state,config,remote_sync}.go stop instances
  best-effort against models that are frequently not loaded.
- deleteProcess itself: the no-local-process branch returns the unload result
  directly, so a stale local entry for a model no longer on any node turned a
  previously-successful cleanup into a failure.

Only ShutdownModel wants the distinction, and only on the local-store-miss
path. So the distinction moves to the caller instead of the contract:

- UnloadRemoteModel/UnloadRemoteModelContext return nil again when no node
  has the model, and ErrRemoteModelNotLoaded is removed.
- New optional RemoteModelPresenceChecker (HasRemoteModel) answers the
  question directly. deleteProcess consults it BEFORE unloading, because an
  idempotent unload cannot report afterwards whether anything was stopped.
  Absent locally AND cluster-wide is the only case that reports 404.
- A failed registry lookup is surfaced rather than reported as absence: an
  unreachable registry is not evidence a model is gone, and answering a
  confident 404 off a failed lookup is how an operator gets told a running
  model does not exist.
- Unloaders that predate the extension keep working - deleteProcess attempts
  the unload rather than refusing it - and compile-time assertions in the
  nodes package now pin all three optional interfaces, since both are
  consumed by runtime type assertion where drift degrades behavior silently
  instead of failing the build.

The contract is now pinned at both levels that disagreed, each spec pointing
at the other: "with no nodes returns nil" in unloader_test.go and "should be
no-op for models not on any node" in node_lifecycle_test.go.

Verified: full distributed e2e suite 233 passed / 0 failed (the suite that
failed 232/1 in CI); pkg/model and core/services/nodes green; make lint
new-from-merge-base reports 0 issues.

Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(distributed): drop replica rows when a worker stops a backend

A worker returns a stopped backend's gRPC port to its allocator as soon as
the process is confirmed dead, and hands it to the next backend that starts.
The controller's NodeModel row for the old address survives, and both
SmartRouter.probeHealth and the HealthMonitor per-model probe verify
liveness, not identity, so once an unrelated backend binds the recycled port
the stale row passes every check and the request is served by the wrong
backend instead of failing.

backend.delete is newly able to trigger this: before #10956 a delete never
actually stopped a process, so it never recycled a port. backend.upgrade has
the identical gap and always did — upgradeBackend force-stops every process
using the binary and starts none back up, while
DistributedBackendManager.UpgradeBackend never removes rows. model.unload is
the one path that gets this right today: it calls RemoveAllNodeModelReplicas
straight after StopBackend.

Report the process keys the worker terminated on the delete and upgrade
replies, and drop the matching rows in RemoteUnloaderAdapter, which already
holds a ModelLocator with RemoveNodeModel. All three call sites funnel
through that adapter, so no new interface, DB migration, or proto change is
needed. A key is reported only once its process is confirmed gone, so the
list stays trustworthy on the partial-failure replies too.

Old workers never populate the new fields. ReportsStoppedProcesses tells
"stopped nothing" apart from "does not report", so an old worker's silence
falls back to the pre-existing probe-based staleness recovery instead of
being mistaken for a completed cleanup.

Quarantine released ports for a short window as an interlock covering the
NATS round-trip between the worker freeing the port and the controller
dropping the row. It is deliberately not derived from HealthCheckInterval:
that cadence is operator-tunable and the per-model reaper can be disabled
outright, so coupling a worker-local constant to it would be silently wrong
on some clusters. Eager row removal is the fix; the delay only closes the
handoff gap.

Identity verification in probeHealth was considered and rejected: Health and
Status carry no backend identity, so it needs a proto change plus an
implementation in 36 Python and 4 C++ Health servicers, it is fail-open for
any backend not yet rebuilt, and the probeCache short-circuit means it would
not even execute during the 30s window where the misroute happens.

Fixes #10952
Refs #10954, #10956

Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(deps): bump go-processmanager, assert real backend termination

go-processmanager wrote Process.PID from readPID() with no synchronization
while its own monitor goroutine cleared the same field on exit, so a bare
Run()+Stop() tripped the race detector without any concurrent access from
the caller. LocalAI hit this on every backend stop.

Upstream fixed it in a94e2b7 by guarding PID with a mutex and adding
CurrentPID() as a race-safe accessor. The exported field was kept to avoid
a breaking change but is now deprecated: a direct read still races the
monitor. No tag carries the fix yet, so pin the pseudo-version.

GetGRPCPID reads through CurrentPID() instead of the field. The accessor
returns the same string under an RLock, so the empty-PID and strconv error
paths are unchanged; it is the only direct field read in the tree.

With the race gone, the Free-timeout spec no longer has to leave its
fixture process unstarted. It now runs a real child and asserts the child
genuinely exits, which is exactly what the earlier workaround gave up: the
spec could show the stop was reached and the slot released, but not that
SIGTERM ever landed. Termination is observed through Done(), which closes
only once the library has waited on the child. The pidfile-based liveness
helpers cannot serve here, because Stop() deletes the pidfile while
releasing the handle and so reports "not alive" even if no signal was sent.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 01:09:22 +02:00
mudler's LocalAI [bot]
5c607c09d5 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to 339e3d7fc7161f8ae61d22c291ff40f68b690266 (#10962)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-20 00:38:25 +02:00
mudler's LocalAI [bot]
2f7b292143 chore: ⬆️ Update CrispStrobe/CrispASR to 5fca47ecf05cd68bb0075f8a00fe04da06f208d0 (#10963)
* ⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(crispasr): initialize only declared submodules

The latest upstream commit contains an undeclared CrispASR gitlink that makes a blanket recursive submodule update fail. Limit initialization to the two submodules used by the backend build.

Assisted-by: Codex:gpt-5 [Codex]

* fix(crispasr): resolve vendored WebRTC from project root

CrispASR now builds a vendored WebRTC VAD, but its include paths assume CrispASR is the top-level CMake project. Extend the existing embedded-project rewrite to the shared third_party root.

Assisted-by: Codex:gpt-5 [Codex]

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-20 00:38:13 +02:00
zjuzhongwen
864c84f48b chore: fix some comments to improve readability (#10960)
Signed-off-by: zjuzhongwen <zjuzhongwen@outlook.com>
2026-07-20 00:37:40 +02:00
mudler's LocalAI [bot]
8cef340659 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to e93292bee1778854ab7dcb2d325ffe531fef910f (#10964)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-20 00:37:23 +02:00
mudler's LocalAI [bot]
c2704dba5b fix(gpu): detect GPUs via sysfs when no pci.ids database is present (#10966)
* fix(gpu): detect GPUs via sysfs when no pci.ids database is present

ghw.GPU() calls pci.New() before it reads /sys/class/drm and fails
outright when it cannot find a pci.ids database file. jaypipes/pcidb
embeds no database and has network fetch disabled by default, so on an
image that ships no pci.ids, GPU enumeration returns an error and every
detection path downstream goes dark.

The Dockerfile installs pciutils only in the vulkan and cublas branches,
so the Intel image had no pci.ids. A correctly passed-through Arc A310
was reported as "No GPU detected" with zero VRAM even though clinfo and
sycl-ls both enumerated it inside the same container. NVIDIA and AMD
images were shielded by their nvidia-smi / rocm-smi binary fallbacks;
Intel has no equivalent, leaving it fully exposed.

Read PCI vendor IDs directly from /sys/class/drm/card*/device/vendor,
which needs no database, and consult that from DetectGPUVendor. The
same scan replaces the ghw-only guard in getIntelGPUMemory, which is
what had been blocking the working clinfo path and keeping VRAM at
zero. Install hwdata in the base image stage as well, so ghw stops
failing for every image variant rather than only Intel.

Also apply the documented NVIDIA > AMD > Intel priority to the ghw
path, which previously returned whichever card DRM enumerated first
and so reported "intel" on a machine with an Intel iGPU at card0 and
an NVIDIA dGPU at card1.

HasGPU() carried the same blindness plus one of its own: it matched
the requested vendor against ghw's card description with a
case-sensitive Contains, so "nvidia" never matched the pci.ids
spelling "NVIDIA Corporation". It only worked because that same
description embeds the lowercase kernel driver name ("nvidia",
"amdgpu"), and it returned false outright whenever ghw errored. Route
it through the shared vendor lookup so it matches case-insensitively
and falls back to sysfs. It feeds the GPU option and NGPULayers
defaults in core/config/gguf.go.

Fixes #10941

Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(gpu): key vendor detection off the numeric PCI ID in both paths

The ghw and sysfs legs were identifying vendors by different means: ghw
by substring-matching the pci.ids vendor name, sysfs by the numeric PCI
vendor ID. ghw already exposes that same numeric ID via
DeviceInfo.Vendor.ID, read from the kernel's modalias rather than from
the database, so the name matching was both a duplicate mechanism and
the weaker of the two.

It is weaker because a card absent from an outdated pci.ids gets
Name: "unknown" while its ID is still correct. Detection then failed
even though ghw had enumerated the card successfully. Verified in a
container with a vendor-less pci.ids and an Arc's modalias: before,
DetectGPUVendor returned ""; after, "intel".

Both legs now resolve through the same pciVendorIDs table and share the
hex parsing, with the vendor name kept only as a fallback for devices
exposing no parseable ID.

ghwHasVendor is deliberately not a priority pick, unlike vendorFromGHW:
HasGPU("intel") must stay true on a hybrid-graphics host whose discrete
NVIDIA card outranks the integrated Intel one.

Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gpu): silence the gosec G304 on the sysfs attribute read

gosec flags os.ReadFile with a non-literal path. The path here is the
DRM root (a package constant in production, a temp dir under test)
joined with a ReadDir entry name and a fixed attribute filename, so no
external input reaches it.

gosec's suggested autofix, os.Root, cannot be used: /sys/class/drm/cardN
is a symlink into the PCI device tree, and os.Root refuses to traverse
it ("path escapes from parent"), which would disable the whole scan.

Assisted-by: Claude:claude-opus-4-8 gosec golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 00:37:06 +02:00
mudler's LocalAI [bot]
92dc326606 chore(model-gallery): ⬆️ update checksum (#10965)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-19 23:35:30 +02:00
Tai An
217fdd2234 fix(qwen-asr): map ISO language codes to the names Qwen3-ASR expects (#10959)
request.language usually carries an ISO 639-1 code (e.g. "de"), which
OpenAI-compatible clients such as Home Assistant / wyoming_openai send,
but qwen_asr.validate_language() only accepts full English names
("German") and raises ValueError otherwise. Normalize the requested
language: accept full names case-insensitively, translate ISO codes
(with optional region suffix like "de-DE") to the expected name, and
pass anything unrecognised through so qwen_asr still reports it clearly.

Fixes #10958

Signed-off-by: Tai An <antai12232931@outlook.com>
2026-07-19 22:00:23 +02:00
mudler's LocalAI [bot]
0e0221b0f5 fix(vision): probe the media marker for pinned llama.cpp backend variants (#10955)
llama.cpp picks a random per-process media marker (ggml-org/llama.cpp#21962),
so LocalAI renders the prompt with a "<__media__>" sentinel and swaps in the
backend's real marker after probing ModelMetadata.

That probe was gated on an exact match against "llama-cpp", the gallery's meta
backend name. A model config pinning a concrete build ("vulkan-llama-cpp",
"cuda12-llama-cpp", "rocm-llama-cpp", ... and their -development counterparts)
runs the same llama.cpp gRPC server but skipped the probe, so MediaMarker
stayed empty, no substitution happened, and the prompt reached mtmd still
carrying the sentinel. mtmd_tokenize then counted zero markers against one
bitmap and every image request failed with "Failed to tokenize prompt".

The same early return also skipped thinking-mode detection and tool-format
marker extraction, so a pinned variant silently lost reasoning and native
tool-call parsing too.

Add IsLlamaCppBackend, which recognises the whole variant family (plus the
empty auto-detect name, which resolves to llama.cpp) while excluding
ik-llama.cpp, a separate engine that merely shares the suffix.

Fixes #10945


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-19 12:46:50 +02:00
mudler's LocalAI [bot]
fb4c61d1c9 fix(distributed): configurable remote model-load timeout, and reap the load when it times out (#10948)
* fix(distributed): make the remote LoadModel deadline configurable

The router hardcoded a 5 minute gRPC deadline for the remote LoadModel
call. Staging finishes before the timer starts, so those five minutes
cover only the worker backend's own checkpoint load and pipeline init.
A cold load of meituan-longcat/LongCat-Video-Avatar-1.5 (~83 GB) on an
ARM64 Thor worker fails at exactly 302s with DeadlineExceeded while the
backend process is still making progress (CPU time accumulating, RSS
moving as weights are mapped), so the load was cut short rather than
wedged.

Add LOCALAI_NATS_MODEL_LOAD_TIMEOUT / --model-load-timeout mirroring the
existing backend-install timeout knob, defaulting to 5m so unset
clusters keep today's behaviour.

The cold-load hold ceiling (which bounds how long one load may hold the
per-model advisory lock) was derived from the install timeout alone, so
raising the load deadline past it would have been silently clipped.
Derive it from both budgets via ModelLoadCeilingFor:

    max(install + load + 5m staging margin, 25m)

With the defaults that is 15m + 5m + 5m = 25m, identical to the previous
constant, and the 25m floor means shrinking either budget can never
tighten the ceiling below what clusters relied on before.

Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(distributed): reap the abandoned replica when a remote load times out

The gRPC deadline on the remote LoadModel call only cancels the client
side. A backend blocked in a synchronous weight load never observes its
cancelled handler context, so when scheduleAndLoad gave up it left the
worker loading with nobody waiting for the result.

Observed on an ARM64 Thor worker loading LongCat-Video-Avatar-1.5: the
client returned DeadlineExceeded at 302s, and the backend process was
still alive 30 minutes later having pulled ~57GB from HuggingFace. Every
retry stacked another multi-GB loader on the worker; they had to be
reaped by hand via POST /api/nodes/:id/models/unload.

Send backend.stop for the exact `modelID#replicaIndex` process key we
just abandoned. The exact key matters: a bare model ID stops every
replica on that node, including healthy ones serving traffic.

Only a deadline or cancellation triggers the reap. Any other LoadModel
failure is the backend answering, which means its handler returned and
the process is idle - stopping it there would discard a warm process and
its downloaded weights. The reap is best-effort and never replaces the
load error the caller is waiting on.

The `modelID#replicaIndex` format was already hand-rolled in two places
(the worker's buildProcessKey and pkg/model's log store). Rather than add
a third, export model.BackendProcessKey from pkg/model, the lowest common
dependency of both sides.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 golangci-lint

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-19 12:01:48 +02:00
mudler's LocalAI [bot]
626ae4d51e fix(model-artifacts): materialize longcat-video on the controller, and support companion repos (#10949)
* fix(model-artifacts): materialize longcat-video checkpoints on the controller

longcat-video loads a checkpoint directory: its backend.py takes
request.ModelFile when os.path.isdir(request.ModelFile) and otherwise
falls back to snapshot_download. That places it in the same class as
transformers/vllm/diffusers/sglang, but the allow-list added in #10910
did not enumerate it, so PrimaryArtifactSpec returned no managed
artifact for a bare HuggingFace repo id.

The consequence in distributed mode: nothing was acquired on the
controller, ModelFileName fell through to the raw repo id, and staging
skipped the resulting phantom /models/<owner>/<repo> path. The worker
received a blank ModelFile, fell back to request.Model, and downloaded
~83GB from HuggingFace inside the remote LoadModel deadline - so the
load could only ever fail with DeadlineExceeded while an abandoned
backend process kept downloading.

Note this materializes the full repository. The backend restricts its
own snapshot_download with allow_patterns, and the avatar repo ships
both base_model/ and base_model_int8/ where only one is ever loaded;
inferred specs have no way to carry patterns today. Tracked separately.

Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(distributed): warn when staging skips a non-existent model path

stageModelFiles logs "Staging model files for remote node" up front, then
silently drops any path field that does not exist on the controller. The
skip itself is legitimate and must stay: a backend outside
managedArtifactBackends that takes a bare HuggingFace repo id gets an
optimistically constructed path (ModelFileName falls through to the raw
model reference) that was never materialized, and sources its own weights
on the worker. Erroring would break those configs.

But at debug level the operator is left with a reassuring staging line and
no trace of the skip, so a genuine controller-side acquisition gap is
indistinguishable from a healthy pass-through - it surfaces much later as
a remote LoadModel timeout, on a worker that is quietly downloading tens
of gigabytes. Raise the skip to warn and name the field, path, node and
tracking key. Behavior is unchanged.

Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(model-artifacts): allow a config to declare companion artifacts

A composed pipeline needs more than one HuggingFace snapshot.
LongCat-Video-Avatar-1.5 loads its own transformer but takes the
tokenizer, text encoder and VAE from the separate LongCat-Video base
repo, so a single-artifact config cannot express it and the backend is
left to fetch the second repo itself at load time.

Widen the artifact model to target: model plus any number of named
target: companion entries. Normalize accepts the new target and
constrains a companion name to [a-z0-9][a-z0-9_-]{0,63} because that
name is the option key the backend later receives; a companion may not
claim primary_file, which only means anything for a load target.
ModelConfig.Validate requires exactly one primary and requires it first,
since Artifacts[0] is what ModelFileName, size estimation and staging all
resolve from.

Both acquisition paths now loop instead of touching index 0 alone:
preloadOne for an already-installed config, bindPrimaryArtifact for a
gallery install. Failure policy differs by provenance. An inferred
primary keeps its warn-and-fall-back, because the legacy download path
still exists for it. Companions are explicit by construction, so they are
all-or-nothing: a config naming one is asserting the backend needs it,
and failing at the acquisition boundary is far more legible than a
missing-weights error surfacing later inside the backend.

The cache key is deliberately unchanged. It hashes source identity only,
never name or target, so every already-installed managed model still hits
its existing snapshot instead of silently re-downloading. Two specs pin
that: one proving a companion and a primary with identical sources agree
on the key, and one pinning the digest of a known primary outright.

Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(model-artifacts): hand resolved companion snapshots to the backend

A materialized companion is useless until the backend can find it, and
its location is a content-addressed cache key that does not exist until
the artifact resolves. A static gallery override cannot carry that, and
persisting it into the config YAML would rot the moment a re-resolve
produced a new key.

Synthesize it instead at load time: each resolved companion becomes
"<artifact name>:<snapshot path>" in ModelOptions.Options, reusing the
key:value convention backends already parse for options like
attention_backend. The value stays relative to the models directory so a
remote worker can resolve it under its own ModelPath once staging has
rewritten the model root. An option the author set explicitly always
wins, so pinning a companion to a local checkout still beats the managed
snapshot.

longcat-video resolves base_model through ModelPath, the same convention
qwen-tts, voxcpm, outetts and ace-step already use for companion assets.
Its sibling-directory heuristic is deleted: it looked for a LongCat-Video
directory next to the model, which cannot exist under the content
addressed .artifacts/huggingface/<key>/snapshot layout, so it was dead
code the moment the model became managed.

The gallery entry declares both repositories and restricts each with
allow_patterns. The avatar repo ships base_model/ and base_model_int8/
and only ever loads one, so fetching the whole repo would roughly double
the download. The patterns match the entry's own options (use_distill
true, use_int8 default false); enabling use_int8 here also requires
adding base_model_int8/**, which is called out in the entry.

Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(distributed): stage managed artifact trees from the models root

Staging anchored the worker's models directory on the primary snapshot
whenever a model was managed, so a companion snapshot could not reach the
worker at all.

frontendModelsDir was derived by stripping the Model relative path off
the end of ModelFile. For a managed artifact nothing matches: ModelFile
is .artifacts/huggingface/<key>/snapshot while Model stays a bare
HuggingFace repo id, so the strip was a no-op and the "models directory"
came out as the snapshot itself. Two consequences, both silent. Staging
keys lost the .artifacts/huggingface/<key>/snapshot prefix, so two
snapshots of one model were indistinguishable on the worker. And a
companion, which lives in a sibling snapshot directory outside the
primary, fell outside that directory entirely: StagingKeyMapper.Key
collapsed its files to bare basenames and resolveOptionPath could not
resolve the relative option at all, so it was skipped without a word.

Derive the models root from the artifact tree instead when the path runs
through it, and compute the worker's ModelPath from the file's path
relative to that root rather than from the Model field. The legacy layout
is unaffected: where Model really is the relative path, the new
derivation reduces to the old one, which a regression spec pins.

This deliberately changes an invariant that router_dirstage_test.go
pinned: for a managed primary, ModelFile and ModelPath were both the
snapshot directory, and staging keys were relative to it. Now ModelFile
is the snapshot, ModelPath is the models root above it, and keys keep the
full relative path. That spec is updated rather than accommodated, with
the reasoning recorded inline, because the old invariant is exactly what
made a sibling companion unreachable.

Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-19 12:01:36 +02:00
localai-org-maint-bot
09b85ee00e fix(ci): build Bonsai backend images (#10939) (#10951)
fix(ci): build Bonsai backend images

Register the Bonsai C++ source path with the backend matrix filter so changes select its image jobs. Also make shared llama.cpp changes rebuild the Bonsai and Turboquant fork images in the actual matrix, not only their test flags.\n\nAssisted-by: Codex:gpt-5 [Codex]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-19 11:49:07 +02:00
mudler's LocalAI [bot]
b19afb192a fix(distributed): backend discovery hid GPU-only backends behind the controller's capability (#10947)
* fix(backends): list backends runnable on worker nodes in distributed mode

GET /backends/available filtered the gallery against the system state of
the host serving the request. In a distributed deployment that host is the
controller, which typically has no GPU, while the GPUs live on worker
nodes. Any meta backend whose capabilities map lacks a "default" (or "cpu")
key was therefore dropped from the listing entirely — longcat-video,
vllm-omni, ltx-video, parakeet, edgetam and qwentts were invisible in the
UI even though installing them by name on a GPU worker worked fine.

Workers now report their own meta-backend capability at registration and
the controller persists it on the node row. The controller cannot derive
it: OS-dependent capabilities (metal, darwin-x86, nvidia-l4t) and the CUDA
runtime refinements are only observable on the worker. Nodes registered
before this field existed fall back to a coarse capability derived from
their GPU vendor and VRAM.

Backend discovery then evaluates compatibility as the union over healthy
backend nodes, so a backend runnable on any node is offered while one no
node can run stays hidden. Each remote capability is evaluated through a
capability-pinned system state, otherwise a forced capability on the
controller image (LOCALAI_FORCE_META_BACKEND_CAPABILITY or
/run/localai/capability) would silently override every worker's verdict.
With no registered nodes the listing is byte-for-byte what it was, so
single-node deployments are unaffected.

Also fixes the same-root-cause misclassification in /api/operations, which
used the capability-filtered listing to decide whether an operation was a
backend or a model install. A GPU-only backend installing on a worker is
still a backend operation on the controller, so that lookup is now
unfiltered.

Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(backends): union worker capabilities in backend discovery

Implementation for the specs added in the previous commit, plus the two
remaining discovery endpoints.

Capability-filtered backend discovery evaluated compatibility against the
system state of the host serving the request. In a distributed deployment
that host is the controller, which typically has no GPU, while the GPUs
live on worker nodes. Any meta backend whose capabilities map lacks a
"default" (or "cpu") key was dropped entirely — longcat-video, vllm-omni,
ltx-video, parakeet, edgetam and qwentts were invisible in the UI even
though installing them by name on a GPU worker worked fine.

Workers now report their own meta-backend capability at registration and
the controller persists it on the node row. The controller cannot derive
it: OS-dependent capabilities (metal, darwin-x86, nvidia-l4t) and the CUDA
runtime refinements are only observable on the worker. Nodes registered
before this field existed fall back to a coarse capability derived from
their GPU vendor and VRAM.

Discovery then evaluates compatibility as the union over healthy backend
nodes, so a backend runnable on any node is offered while one no node can
run stays hidden. Each remote capability is evaluated through a
capability-pinned system state, otherwise a forced capability on the
controller image (LOCALAI_FORCE_META_BACKEND_CAPABILITY or
/run/localai/capability) would silently override every worker's verdict.
With no registered nodes the listing is byte-for-byte what it was, so
single-node deployments are unaffected.

Four surfaces shared this root cause and are all routed through the same
helper now:

  - GET /backends/available
  - GET /api/fine-tuning/backends
  - GET /api/quantization/backends
  - /api/operations backend-vs-model classification, which additionally
    had no reason to filter by capability at all: a GPU-only backend
    installing on a worker is still a backend operation on the
    controller, so that lookup is now unfiltered.

Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-19 07:53:46 +00:00
mudler's LocalAI [bot]
963c637130 fix(gpu-libs): bundle cuDNN only where it is used, and complete it when it is (#10946)
cuDNN 9 is a dispatcher (libcudnn.so.9) plus seven sublibraries the dispatcher
dlopen()s by bare soname. Only the dispatcher is ever a DT_NEEDED, so ldd finds
it and never the seven. The allowlist force-copied three of them
(libcudnn.so*, libcudnn_ops.so*, libcudnn_cnn.so*) into every CUDA backend,
which is wrong in both directions at once: too few libraries for a backend that
uses cuDNN, and too many for one that does not.

On an L4T fleet, ten of the eleven backends carrying cuDNN were in a broken end
state; the one that was correct was correct by accident, being BUILD_TYPE=cpu
so package_cuda_libs never ran for it.

  longcat-video bundled 4 of 8 at 9.24.0 over a complete pip set at 9.20.0.48
  in its venv. libbackend.sh puts lib/ on LD_LIBRARY_PATH, searched before
  DT_RUNPATH, so the bundle won and the rest still came from the venv:
  CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH.

  Nine others bundled 3 of 8 and had no venv cuDNN. None bundled
  libcudnn_graph, which libcudnn_cnn has a hard DT_NEEDED on, so it resolved
  out of the runtime image and the process ran bundled 9.22.0 against system
  9.23.2.

Five of those nine - llama-cpp, whisper, rfdetr-cpp, sam3-cpp,
stablediffusion-ggml - do not reference cuDNN at all. ggml goes through cuBLAS.
They were carrying ~57 MB of cuDNN with no consumer, and completing the family
for them would have taken that to ~576 MB for nothing.

Sizes overall: backends with no cuDNN consumer shed ~57 MB each (seven
instances on the fleet measured, plus longcat's ~60 MB), while the ones that
genuinely use cuDNN grow from ~57 MB to ~576 MB, because the five missing
sublibraries are ~517 MB, dominated by libcudnn_engines_precompiled. Net on
that fleet is an increase of roughly 570 MB. That growth is the bug being paid
off, not a regression: those backends only work today by silently borrowing the
missing five from the runtime image. Whether the engines set can be trimmed is
an open question, not addressed here.

So bundle per backend, by what that backend actually needs:

  - venv has a complete pip cuDNN -> bundle nothing; $ORIGIN resolves the pip
    set, which is the one its torch was built against            (longcat-video)
  - venv has no pip cuDNN         -> bundle the complete family. Stays
    conservative rather than detecting consumers: for a Python backend they sit
    inside the venv (torch, ctranslate2, onnxruntime) where the sweep does not
    look                                                                 (vllm)
  - no venv, nothing references cuDNN -> bundle nothing    (llama-cpp, whisper,
                                     rfdetr-cpp, sam3-cpp, stablediffusion-ggml)
  - no venv, something references it   -> bundle the complete family
                                                  (face-detect, voice-detect)

The no-venv case needs no new machinery. Go backends stage their own shared
object into package/lib, which IS the target dir, so sweep_transitive_deps
already pulls the dispatcher when it is a genuine dependency - that is exactly
how libcudnn_graph reached longcat. cuDNN simply comes off the force-copy list,
and complete_cudnn_family fills in the seven dlopen'd sublibraries around
whatever the sweep found. Detection is a string scan rather than ldd, so a
consumer that only dlopen()s cuDNN is seen too; over-matching costs an unused
library, under-matching costs a backend that cannot load.

Keeping bundled and pip versions in agreement instead is not viable: nothing
here pins nvidia-cudnn (zero occurrences), torch is unpinned for l4t13 except
longcat-video, and the fleet already runs five concurrent cuDNN versions -
9.19.0.56, 9.20.0.48, 9.22.0, 9.23.2, 9.24.0.

verify_cudnn_bundle asserts the end state: exactly one complete cuDNN visible to
whoever needs one - never both, never partial, and never zero for a backend that
references it. Zero is correct and common otherwise. It deliberately does not
accept the build image's system cuDNN as completing a partial bundle, which is
the shape that had been shipping silently; the build image is not the runtime
image. A version check alone would have missed longcat too, whose four bundled
libs were all 9.24.0 and mutually consistent.

Match per family for the other components for the same dlopen reason: TensorRT
(libnvinfer_plugin, libnvinfer_builder_resource), cuBLAS, cuFFT, cuSPARSE,
cuSOLVER, nvRTC. Exclusions bind inside copy_lib so they cover the sweep.

The packaging scripts' shell tests ran nowhere in CI. Add make
test-build-scripts and a lint workflow job so they gate every PR.

Fixes #10905


Assisted-by: Claude:claude-opus-4-8 golangci-lint shellcheck

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-19 07:48:51 +00:00
localai-org-maint-bot
71e98c13a3 fix(vllm): generate protobuf 6 compatible stubs (#10944)
Pin vLLM protogen to grpcio-tools 1.78.0 so its generated code remains importable by protobuf 6.33.x, and remove stale generated artifacts before regeneration.

Closes #10940

Assisted-by: Codex:gpt-5 [Codex]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-19 08:56:57 +02:00
mudler's LocalAI [bot]
10211948b5 chore(model gallery): 🤖 add 1 new models via gallery agent (#10942)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-19 08:45:41 +02:00
mudler's LocalAI [bot]
139470cca0 chore: ⬆️ Update ggml-org/llama.cpp to 571d0d540df04f25298d0e159e520d9fc62ed121 (#10935)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-19 08:45:08 +02:00
mudler's LocalAI [bot]
078614c701 chore: ⬆️ Update CrispStrobe/CrispASR to 1e6f3ad962dc46d86422c3baa4f3c1110d037e4d (#10934)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-19 08:44:57 +02:00
mudler's LocalAI [bot]
c1efdbeb9e chore: ⬆️ Update leejet/stable-diffusion.cpp to ea4e566ccffa10f853ecc3f29e74b1820bc91beb (#10936)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-19 08:44:45 +02:00
mudler's LocalAI [bot]
7f72dc3412 chore: ⬆️ Update PrismML-Eng/llama.cpp to 9fcaed763ccda38ea81068ad9d7f991aaddca451 (#10937)
⬆️ Update PrismML-Eng/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-19 08:44:27 +02:00
mudler's LocalAI [bot]
81c407bc40 chore(model-gallery): ⬆️ update checksum (#10938)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-19 08:44:02 +02:00
Richard Palethorpe
9c43b2da8f fix(model): make backend shutdown model-scoped (#10865)
Avoid holding the global loader lock across backend lifecycle waits and propagate forced shutdown through distributed workers. Track parallel requests with in-flight counters and reserve worker ports until process termination.

Add focused race tests and an authoritative FizzBee lifecycle model with a fail-closed conformance target.

Assisted-by: Codex:GPT-5 [FizzBee] [Ginkgo]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-07-19 08:43:17 +02:00
mudler's LocalAI [bot]
27955e0a33 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 9d07d8681ece159a89fb4e16a1f9c9f3a5fac20f (#10933)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-19 00:43:49 +02:00
dependabot[bot]
036eccc32d chore(deps): bump actions/setup-node from 6 to 7 (#10915)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 22:36:37 +02:00
dependabot[bot]
a15b23b775 chore(deps): bump torch from 2.12.1+xpu to 2.13.0+xpu in /backend/python/common/template (#10917)
chore(deps): bump torch in /backend/python/common/template

Bumps torch from 2.12.1+xpu to 2.13.0+xpu.

---
updated-dependencies:
- dependency-name: torch
  dependency-version: 2.13.0+xpu
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 22:36:16 +02:00
dependabot[bot]
a4a14c6263 chore(deps): bump grpcio from 1.80.0 to 1.82.1 in /backend/python/common/template (#10918)
chore(deps): bump grpcio in /backend/python/common/template

Bumps [grpcio](https://github.com/grpc/grpc) from 1.80.0 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.80.0...v1.82.1)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-version: 1.82.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 22:35:55 +02:00
dependabot[bot]
00cbfc369b chore(deps): bump grpcio from 1.80.0 to 1.82.1 in /backend/python/rerankers (#10921)
chore(deps): bump grpcio in /backend/python/rerankers

Bumps [grpcio](https://github.com/grpc/grpc) from 1.80.0 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.80.0...v1.82.1)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-version: 1.82.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 22:35:34 +02:00
dependabot[bot]
cee6780ea7 chore(deps): bump grpcio from 1.80.0 to 1.82.1 in /backend/python/coqui (#10922)
Bumps [grpcio](https://github.com/grpc/grpc) from 1.80.0 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.80.0...v1.82.1)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-version: 1.82.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 22:35:17 +02:00
dependabot[bot]
79113c7f90 chore(deps): update transformers requirement from >=5.9.0 to >=5.14.1 in /backend/python/transformers (#10926)
chore(deps): update transformers requirement

Updates the requirements on [transformers](https://github.com/huggingface/transformers) to permit the latest version.
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.9.0...v5.14.1)

---
updated-dependencies:
- dependency-name: transformers
  dependency-version: 5.14.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 22:35:01 +02:00
dependabot[bot]
e7520af5d7 chore(deps): bump grpcio from 1.81.0 to 1.82.1 in /backend/python/transformers (#10925)
chore(deps): bump grpcio in /backend/python/transformers

Bumps [grpcio](https://github.com/grpc/grpc) from 1.81.0 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.81.0...v1.82.1)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-version: 1.82.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 22:34:29 +02:00
dependabot[bot]
a9456bbce9 chore(deps): bump sentence-transformers from 5.5.1 to 5.6.0 in /backend/python/transformers (#10927)
chore(deps): bump sentence-transformers in /backend/python/transformers

Bumps [sentence-transformers](https://github.com/huggingface/sentence-transformers) from 5.5.1 to 5.6.0.
- [Release notes](https://github.com/huggingface/sentence-transformers/releases)
- [Commits](https://github.com/huggingface/sentence-transformers/compare/v5.5.1...v5.6.0)

---
updated-dependencies:
- dependency-name: sentence-transformers
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 22:33:46 +02:00
dependabot[bot]
24c16c9bb5 chore(deps): bump vllm from 0.25.0 to 0.25.1 in /backend/python/vllm (#10929)
Bumps [vllm](https://github.com/vllm-project/vllm) from 0.25.0 to 0.25.1.
- [Release notes](https://github.com/vllm-project/vllm/releases)
- [Changelog](https://github.com/vllm-project/vllm/blob/main/RELEASE.md)
- [Commits](https://github.com/vllm-project/vllm/compare/v0.25.0...v0.25.1)

---
updated-dependencies:
- dependency-name: vllm
  dependency-version: 0.25.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 21:37:41 +02:00
localai-org-maint-bot
0389495388 fix(webui): use relative asset base so fonts and lazy chunks honor X-Forwarded-Prefix (#10889) (#10904)
The Vite build emitted path-absolute asset URLs (base: '/'). index.html
entry scripts and the favicon were rewritten to include the reverse-proxy
prefix in serveIndex, but two reference kinds are not in index.html and so
bypassed that rewrite:

  - CSS `url()` font references (e.g. Font Awesome .woff2), which the browser
    resolves relative to the stylesheet and which `<base href>` never affects
  - lazily-imported route chunks, whose preload base came from the absolute
    Vite base

Under a subpath mount (X-Forwarded-Prefix: /llm/) both were fetched from the
origin root, 404ing — missing-glyph "tofu" icons and broken lazy-loaded pages.

Switch Vite to a relative base ('./') so every generated URL resolves against
the file that references it: CSS fonts and route chunks now load from
`/llm/assets/...`, and index.html's now-relative entry refs resolve via the
`<base href>` serveIndex already injects on every response. Root deployments
are unaffected. The existing path-absolute rewrite in app.go still covers the
public `/favicon.svg`.


Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-18 08:38:26 +02:00
dependabot[bot]
2f011094d9 chore(deps): bump torch from 2.8.0 to 2.12.1+xpu in /backend/python/common/template in the pip group across 1 directory (#10911)
chore(deps): bump torch

Bumps the pip group with 1 update in the /backend/python/common/template directory: torch.


Updates `torch` from 2.8.0 to 2.12.1+xpu

---
updated-dependencies:
- dependency-name: torch
  dependency-version: 2.12.1+xpu
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 08:37:03 +02:00
localai-org-maint-bot
bc653c9b09 ci(dependabot): ignore torch/transformers for diffusers to fix Jetson-index auth failure (#10913)
The weekly "Dependabot Updates" pip job for /backend/python/diffusers has been
failing with `private_source_authentication_failure` against the Jetson pip
index (https://pypi.jetson-ai-lab.io/jp6/cu129/), referenced by that backend's
requirements-l4t12.txt. diffusers is the only dependabot-configured pip
directory that pulls from that private index, so it is the only update job that
fails; the other backends update cleanly.

torch and transformers are deliberately pinned in this backend for
reproducibility (see backend/python/diffusers/requirements-*.txt and #9979), so
we do not want dependabot bumping them anyway. Ignoring both dependencies for
this directory stops dependabot from resolving them against the unreachable
Jetson index and keeps the weekly update job green, without removing update
coverage for the rest of the backend's dependencies.


Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-18 08:36:23 +02:00
mudler's LocalAI [bot]
f9a2d9be32 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260717051959 (#10903)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-18 08:35:41 +02:00
mudler's LocalAI [bot]
f40e07d72e chore: ⬆️ Update CrispStrobe/CrispASR to c96281d6d409a7f97edbce62c12a6dd2f4da6a92 (#10900)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-18 08:35:28 +02:00
mudler's LocalAI [bot]
911fb754a6 chore: ⬆️ Update ggml-org/llama.cpp to 6bdd77f13cf11b264b4231d320afc404f48d576e (#10898)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-18 08:35:15 +02:00
mudler's LocalAI [bot]
2dade4a9f9 fix(model-artifacts): gate inferred artifact materialization by backend (#10910)
The managed-artifact materializer stages a HuggingFace snapshot into a
directory (.artifacts/huggingface/<key>/snapshot/). That is the right load
target for directory-consuming backends (transformers, vLLM, diffusers, ...),
but PrimaryArtifactSpec inferred a managed artifact from ANY HuggingFace-shaped
model reference regardless of backend. A single-file backend such as llama.cpp
or whisper was therefore handed the snapshot directory instead of the weight
file and failed to load it.

The /import-model importer already guards this with a backend allow-list
(managedArtifactBackends), but the loader-side inference did not. Move the
allow-list into core/config as IsManagedArtifactBackend and apply it in
PrimaryArtifactSpec: only directory-consuming backends may have an artifact
inferred from a bare reference; every other backend stays on the legacy
download-to-file path. An explicit artifacts: block still bypasses the gate,
where single-file snapshot resolution handles the load path.

The importer now shares the same predicate, so both paths agree on which
backends auto-materialize.

Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 23:26:23 +00:00
mudler's LocalAI [bot]
c0a20d6ab1 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to 73a88bf6323f7b9dfff8dde76b4fffcc2fd618ce (#10896)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-18 01:05:26 +02:00
mudler's LocalAI [bot]
78775c77d8 chore: ⬆️ Update mudler/parakeet.cpp to 1da853421de9710cbe894a0110711de5a0516486 (#10899)
⬆️ Update mudler/parakeet.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-18 01:05:14 +02:00
mudler's LocalAI [bot]
525af1df1b chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to 95b4840ad3722b0b67acb945cd57682aae1ac9ca (#10902)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-18 00:46:10 +02:00
localai-org-maint-bot
279f5b8a93 fix(model-artifacts): load single-file HF snapshots from the file, not the directory (#10909)
fix(model-artifacts): load single-file HF snapshots from the file, not the dir

The managed Hugging Face artifact materializer (#10825) always pointed
backends at the snapshot *directory*
(.artifacts/huggingface/<key>/snapshot). For a single-file model
reference such as huggingface://nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf,
the GGUF lives *inside* that directory, so llama.cpp was handed a
directory and failed with "gguf_init_from_reader: failed to read magic".
This has kept the tests-aio job red on master since the feature merged
(the embeddings e2e tests could not load text-embedding-ada-002).

Record the single file of a one-file snapshot as Resolved.PrimaryFile and
have ModelFileName() resolve to snapshot/<PrimaryFile> when it is set.
Multi-file snapshots (e.g. transformers repos consumed as a directory)
keep pointing at the snapshot directory. PrimaryFile is derived from the
resolved contents and is deliberately excluded from the artifact cache
key. estimateModelSizeBytes now derives the snapshot directory from the
cache key instead of ModelFileName(), so its manifest lookup is unaffected
by the file-vs-directory resolution.


Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 22:42:50 +00:00
mudler's LocalAI [bot]
9edb08ea94 chore: ⬆️ Update ikawrakow/ik_llama.cpp to fbcc743c70391e63fba74a16740f8157b469feeb (#10897)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-18 00:13:02 +02:00
mudler's LocalAI [bot]
2ad3b5088b chore: ⬆️ Update PrismML-Eng/llama.cpp to 79697f23a2c8f3aa2ccb2fd7406095a8dbfbb454 (#10901)
⬆️ Update PrismML-Eng/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-18 00:02:20 +02:00
mudler's LocalAI [bot]
a89d780707 fix(gallery): keep multi-file HF install progress proportional during verify (#10908)
The artifact progress bridge mapped every PhaseVerifying event to a flat
95%. The materializer emits PhaseVerifying once per file (from each file's
AfterDownload hook) and downloads run sequentially, so the first small file
to finish pinned the bar at 95% - and, because progress is monotonic, it
stayed at 95% for the entire remaining download (e.g. a 70GB checkpoint
reporting 95% at 410MB / 69.7GB).

Track per-file verify proportionally to the running aggregate bytes, the
same way downloading does. CurrentBytes already reflects "completed files +
this file", so the percentage advances honestly. The flat 95%/99% is now
reserved for the genuinely once-per-install Committing/Persisting phases.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-18 00:01:24 +02:00
mudler's LocalAI [bot]
55e2726958 chore(model-gallery): ⬆️ update checksum (#10906)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 23:47:43 +02:00
localai-org-maint-bot
4be6e22b5f feat(webui): surface user, client IP and user agent in API traces (#10886, #10887) (#10907)
The Operate → Traces "API Traces" panel already recorded who made each
request (user_id/user_name) but never showed it, and did not capture the
caller's network identity at all. Operators asked to see the requesting
user (#10886) and the client IP + user agent (#10887) so a trace can be
attributed to who/what issued it.

Backend: add ClientIP and UserAgent to APIExchange and populate them from
echo's c.RealIP() (honours X-Forwarded-For / X-Real-IP behind a trusted
proxy) and the request's User-Agent header. Both are omitempty and the
/api/traces swagger response is map[string]any, so this is additive.

UI: add a sortable "User" column to the API traces table and a metadata
block (User / Client IP / User Agent) at the top of the expanded row
detail. Fields render only when present, so older buffered traces and
unauthenticated/local requests degrade cleanly.

Adds an e2e spec covering the new column value and the expanded metadata.


Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 23:47:31 +02:00
localai-org-maint-bot
bf484c5181 feat(webui): show date alongside time in the Traces view (#10888) (#10905)
The Operate -> Traces table rendered the request time with the time of day
only, so entries that span more than one day were ambiguous. Add a
formatDateTime helper (localized date + existing time-with-millis) and use it
for the Traces "Time" column, keeping the cell on a single line. The shared
formatTimestamp used by the log views is unchanged.


Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 23:47:10 +02:00
mudler's LocalAI [bot]
40d35c0385 docs: onboarding overhaul, dedup, and error docs (#7711) (#10895)
* docs: fix CPU image tag (latest, not latest-cpu)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: use canonical localai/localai registry in models guide

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: replace dead llama-stable backend with llama-cpp

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: correct mitm-proxy intercept config and redaction tier

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: fix text-to-audio endpoint and broken notice block

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: fix VAD example, stale FAQ, broken link, CLI list, whats-new dump

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: render advanced/reference section indexes (consolidate _index)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: remove duplicate getting-started build/kubernetes pages

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: fold container image reference into installation/containers

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: remove stale advanced fine-tuning page (superseded by features/fine-tuning)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: fold distribution/longcat/sound pages into their parents

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: make getting-started index accurate and complete

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: carry one concrete model through the getting-started path

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: add end-to-end 'build your first agent' walkthrough

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: add runtime errors reference; consolidate troubleshooting from FAQ

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: add agent actions catalog

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: agent-scoped MCP, skills walkthrough, agentic disambiguation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: add concrete gallery install lines to media feature pages

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: merge installation into getting-started (URLs preserved via aliases)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: add Operations section; move operator pages and P2P API reference

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: journey-ordered top nav and grouped feature sections

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: add docs-with-code process gate (PR template + agent instructions)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: remove em/en dashes from documentation prose

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 22:08:20 +02:00
futurehua
d3ea65a112 refactor: replace Split in loops with more efficient SplitSeq (#10879)
Signed-off-by: futurehua <futurehua@outlook.com>
2026-07-17 22:07:16 +02:00
mudler's LocalAI [bot]
4f592c8734 chore(model-gallery): ⬆️ update checksum (#10891)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 20:06:32 +02:00
walcz-de
6ccb1130d8 fix(agent-ui): reset streamed text at generation boundaries in agent chat (#10664)
One agent turn runs several internal LLM generations (tool selection,
reasoning, final answer) that all emit stream_event deltas over the same
per-agent SSE channel. The chat page accumulated every 'content' delta
into a single live bubble and ignored the 'done' boundary events, so the
internal generations' text (e.g. the English tool-selection rationale)
merged with — and visually corrupted — the streamed final answer.

Reset the accumulated content/reasoning on 'done': each generation gets
a clean live bubble, and the authoritative full answer still arrives via
the final json_message event as before.

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-07-17 15:26:50 +02:00
LocalAI [bot]
3f8806b0b2 chore(model gallery): 🤖 add 1 new models via gallery agent (#10881)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 15:22:11 +02:00
LocalAI [bot]
14c7c04feb chore(model gallery): 🤖 add 1 new models via gallery agent (#10874)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 12:58:47 +02:00
LocalAI [bot]
ec933b837d chore(moss-transcribe-cpp): bump pin to CUDA K-quant embed fix (#10862) (#10878)
chore(moss-transcribe-cpp): bump pin to CUDA K-quant embed fix

Bumps the moss-transcribe.cpp pin to 190a569c, which merges the
host-side embed-lookup fallback for K-quant token_embd tensors
(localai-org/moss-transcribe.cpp#2).

Before this, running a q5_K/q4_K/q6_K moss-transcribe GGUF on CUDA
(or any non-CPU backend) aborted in getrows.cu with
"unsupported src0 type: q5_K" because ggml's GET_ROWS op has no
K-quant implementation on GPU, killing the backend process on the
first request. The engine now dequantizes the needed rows on the
host when the backend cannot run GET_ROWS for the tensor type,
producing bit-identical results.

Fixes #10862


Assisted-by: Claude:claude-opus-4-8 [Bash] [Edit]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 12:47:13 +02:00
LocalAI [bot]
cc26083423 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260716042225 (#10849)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 12:46:56 +02:00
Nicholas Ciechanowski
8aa8e0fac0 fix(distributed): setup script (#10551)
Assisted-by: OpenCode:GPT-5.5 [Read] [Edit]

Signed-off-by: Nicholas Ciechanowski <nicholas@ciech.anow.ski>
2026-07-17 10:14:19 +02:00
LocalAI [bot]
e9056399a7 feat(gallery): add MOSS-TTS-Local v1.5 models for the moss-tts-cpp backend (#10877)
Add the q8_0 (default) and f16 gallery entries for the moss-tts-cpp backend, each
pulling the MOSS-TTS-Local v1.5 GGUF plus the MOSS-Audio-Tokenizer-v2 codec and
the text tokenizer from mudler/MOSS-TTS-Local-Transformer-v1.5-GGUF. The backend
auto-discovers the codec and tokenizer siblings; output is 48 kHz stereo with
reference-audio voice cloning.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 10:02:01 +02:00
LocalAI [bot]
3bb0d1cb49 feat(backend): add moss-tts-cpp text-to-speech backend (#10860)
* feat(backend): add moss-tts-cpp text-to-speech backend

Add a Go + purego backend wrapping the moss-tts.cpp ggml port of the OpenMOSS
MOSS-TTS-Local v1.5 text-to-speech model (GPT-J local transformer decoded through
MOSS-Audio-Tokenizer-v2), producing 48 kHz stereo audio with optional
reference-audio voice cloning. Mirrors the qwen3-tts-cpp backend: dlopen the
static-ggml shared library, bind the moss-tts.cpp C-API via purego, and serve
the gRPC TTS method. A thin C shim holds the pipeline handle and copies engine
PCM into a Go-freeable buffer.

Wires the CI registration: backend-matrix.yml (CPU, CUDA 12/13, Intel SYCL
f16/f32, Vulkan, ROCm, NVIDIA L4T, plus Darwin metal), backend/index.yaml metas
and image entries pointing at mudler/MOSS-TTS-Local-Transformer-v1.5-GGUF, the
root Makefile build targets, and the changed-backends.js path mapping.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: list the moss-tts-cpp backend among the LocalAI-maintained engines

Add moss-tts.cpp to the README "Backends built by us" table, the
Text-to-Speech compatibility table, and the reference-audio voice-cloning
backend list, so the new backend is documented alongside its peers.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* backend(moss-tts-cpp): pin moss-tts.cpp to the squashed single-commit release

moss-tts.cpp history was collapsed to a single commit; repoint MOSSTTS_CPP_VERSION
to ee722b8e9205ee9b1b1c398a4e87e4e393e9be41.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* backend(moss-tts-cpp): add the moss-tts-cpp-development gallery meta

The gallery had the -development image entries but no matching -development
meta anchor (as locate-anything-cpp and depth-anything-cpp have), so the master
build was not installable as a gallery backend. Add moss-tts-cpp-development
mirroring the production meta with the -development capability image names.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 09:26:12 +02:00
LocalAI [bot]
0bd7a29f31 feat(gallery): add Gemma 4 llama.cpp MTP variants; fix gemmable-4-12b-mtp (#10876)
Google shipped the Gemma 4 MTP drafter heads and llama.cpp merged native
support in ggml-org/llama.cpp#23398. LocalAI's pinned llama.cpp already
carries it, and the config plumbing (draft_model + core/config/mtp.go)
was built for exactly this path, but no official Gemma 4 gallery entry
wired it up.

Add llama.cpp draft-mtp speculative-decoding variants for the dense
sizes, sourced from the unsloth QAT GGUF repos (target UD-Q4_K_XL +
mtp-*.gguf drafter + BF16 mmproj):

  - gemma-4-e2b-it-qat-mtp
  - gemma-4-e4b-it-qat-mtp
  - gemma-4-12b-it-qat-mtp
  - gemma-4-31b-it-qat-mtp

These replace the previously commented-out attempts, which were disabled
because the Janvitos/boxwrench drafter GGUFs declared the architecture as
`gemma4_assistant` (underscore) and failed to load on stock llama.cpp.
The unsloth drafters use the upstream `gemma4-assistant` (hyphen) spelling
that mtp.go's isDraftOnlyAssistantArch expects, so they load without any
backend patch. The 26B-A4B MoE is intentionally omitted (the upstream PR
reports no meaningful MTP speedup for it).

Also fix gemmable-4-12b-mtp: it loaded the draft-only `-mtp` GGUF as the
main model with no draft_model set, which cannot run standalone. It now
loads the target as the model, wires the drafter via draft_model, enables
spec_type:draft-mtp, and downloads both files.

All sha256 pins were taken from the HuggingFace API lfs.oid (reliable
content hash even for Xet-backed repos).


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 09:05:44 +02:00
Tai An
45b8047736 fix(p2p): serialize access to p2pCtx/p2pCancel (#10839) (#10861)
StopP2P() read and wrote a.p2pCtx/a.p2pCancel without holding
a.p2pMutex, and StartP2P() reassigned both fields with no lock at
all -- including when RestartP2P() calls it from a background
goroutine after releasing the mutex. Both paths are reachable from
POST /api/settings (empty p2p_token -> StopP2P, non-empty ->
RestartP2P), so concurrent requests race on the same fields.

Take a.p2pMutex in StopP2P and around the field publication in
StartP2P, factor the shared teardown into stopP2PLocked() so
RestartP2P reuses it, and route the goroutine error path through
StopP2P instead of touching a.p2pCancel unlocked.

Signed-off-by: Anai-Guo <antai12232931@anaiguo.com>
Co-authored-by: Anai-Guo <antai12232931@anaiguo.com>
2026-07-17 09:02:05 +02:00
LocalAI [bot]
fd0d1b946d chore: ⬆️ Update PrismML-Eng/llama.cpp to 62061f91088281e65071cc38c5f69ee95c39f14e (#10869)
⬆️ Update PrismML-Eng/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 09:00:44 +02:00
LocalAI [bot]
6dfda9c4b6 chore: ⬆️ Update ggml-org/llama.cpp to e8f19cc0ad70a243c8012bf17b4be601abfc8ea2 (#10870)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 09:00:30 +02:00
LocalAI [bot]
dffcbd7e5d chore(model-gallery): ⬆️ update checksum (#10871)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 09:00:17 +02:00
LocalAI [bot]
7c542fb979 chore: ⬆️ Update leejet/stable-diffusion.cpp to b2906939774dc73453467215c80390404d0a2701 (#10872)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-17 09:00:02 +02:00
LocalAI [bot]
cbf232e5fe chore: ⬆️ Update CrispStrobe/CrispASR to a38cb89f7b9a743db2e8e50869fa646f91dc7f08 (#10873)
* ⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(crispasr): rewrite c2pa-audio submodule path for subproject builds

CrispASR a38cb89 adds a crispasr_c2pa_native static library whose sources
live in the new third_party/c2pa-audio git submodule, located via
CMAKE_SOURCE_DIR in src/CMakeLists.txt. That variable assumes CrispASR is
the top-level CMake project; LocalAI embeds it via add_subdirectory, so
the path resolved to backend/go/crispasr/third_party/c2pa-audio and every
build variant failed at CMake generate with 'Cannot find source file:
c2pa_native.cpp'.

Extend the existing talk-llama sed workaround to also rewrite the
c2pa-audio reference to PROJECT_SOURCE_DIR, which is correct both
standalone and as a subproject. The submodule itself is already checked
out by the recursive submodule init. Verified locally: the exact CI error
reproduces with CMAKE_SOURCE_DIR, and with the rewrite CMake configure,
crispasr_c2pa_native, and crispasr-lib all build cleanly on a CPU-only
fallback configuration.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 08:59:48 +02:00
LocalAI [bot]
1f53dff436 fix(turboquant,bonsai): do not apply vendored llama.cpp patches to fork trees (#10866)
The turboquant and bonsai backends copy backend/cpp/llama-cpp/ wholesale
into their build directories and reuse its Makefile/prepare.sh against
their own llama.cpp forks. When PR #10837 added
backend/cpp/llama-cpp/patches/0001-add-minimax-m3-support.patch, the
copied patches/ directory was mis-applied to the fork checkouts: the
fork trees diverge from upstream, hunks rejected, and because the
patch-apply loop in prepare.sh ran before set -e took effect the build
kept going and died much later with a confusing compile error
("'LLM_ARCH_MINIMAX_M3' was not declared in this scope"). This broke
tests-turboquant-grpc on that PR.

Two hardening changes:

- turboquant/bonsai Makefiles: delete the copied patches/ directory
  right after the cp -rf of backend/cpp/llama-cpp/. Patches vendored
  for upstream llama.cpp must never be applied to the forks; each fork
  carries its own patch series under backend/cpp/<backend>/patches/,
  applied by its apply-patches.sh.

- llama-cpp prepare.sh: run the patch-apply loop under set -e so a
  rejecting patch fails fast and loudly at apply time instead of
  surfacing as a downstream compile error. A missing or empty patches/
  directory remains a no-op success, so all existing callers (the
  llama-cpp Makefile targets and the turboquant/bonsai copies) are
  unaffected when no patches ship.

Exposed by PR #10837.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-17 00:28:10 +02:00
LocalAI [bot]
c1a891662c refactor(settings): single declarative registry for runtime settings (fixes the #10845 bug class) (#10864)
* feat(settings): add declarative runtime-settings field registry

One fieldSpec row per RuntimeSettings field, with a reflection
completeness spec so a field added without a registry row is a red
test instead of a silently-dropped setting (the #10845 bug class).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5

* refactor(settings): drive ToRuntimeSettings/ApplyRuntimeSettings from the field registry

Behavior-preserving: ~350 hand-written per-field lines become two loops
over runtimeSettingsFields, gated by a To->Apply->To round-trip spec.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5

* feat(settings): baseline-driven startup merge for persisted runtime settings

ApplyRuntimeSettingsAtStartup compares the live config against
DefaultRuntimeBaseline (option-less-run defaults incl. kong-injected
flag defaults) instead of per-field == 0 guards. Fixes persisted
lru_eviction_max_retries, tracing_max_items, agent_job_retention_days,
memory_reclaimer_threshold, galleries and autoload flags being
silently ignored at boot.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5

* fix(settings): registry-driven startup merge, applied before consumers

loadRuntimeSettingsFromFile becomes a thin wrapper over
ApplyRuntimeSettingsAtStartup and runs at the top of New(), before
model configs capture app-level defaults. WithThreads stops eagerly
resolving 0 so a persisted thread count survives restart while
LOCALAI_THREADS still wins (#10845); the physical-core fallback moves
after the merge.

Also: run.go now injects the memory-reclaimer threshold unconditionally
so the option-less boot matches DefaultRuntimeBaseline and a UI-saved
threshold survives restart.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5

* refactor(settings): file watcher delegates to the registry merge; shared API-key merge

Manual edits to runtime_settings.json now behave like a boot-time load
(env still wins) instead of the inverted diverged-from-startup guard
that ignored most manual edits. MergeAPIKeys dedups env keys in one
place for the endpoint and the watcher.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5

* docs(settings): document unified runtime-settings precedence

Document the single env/CLI > runtime_settings.json > defaults rule,
applied identically at boot, on POST /api/settings, and on manual file
edits, plus the two known limitations (default-valued env vars are
indistinguishable from unset; API-changed fields hot-apply on the next
restart only). Also add a completion debug log when the watcher applies
runtime_settings.json.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5

* test(settings): reset the global VRAM cap leaked by the round-trip spec

The round-trip spec applies vram_budget=12GiB, whose post-loop hook
installs a process-global default cap; without a reset every spec
ordered after it runs under that phantom budget. Also drop a stale
enumeration in the ApplyRuntimeSettings doc comment.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-16 22:39:59 +02:00
pos-ei-don
06b4a29387 docs(config): document grpc.attempts timing + tuning guidance (#10868)
The gRPC configuration table only listed the two fields with a one-line
description each, without defaults, without explaining what the total
load window looks like, and without hinting when a user should adjust
them. In practice the default 20 attempts x 2 s = 40 s window is way
too tight for large NVFP4 / FP8 models on slow storage or first-run
CUDA-graph capture, and the resulting kill (exitCode=120, 'context
canceled') looks like a backend crash even though the backend is still
making legitimate forward progress.

Extend the section with:
- Defaults column (20 and 2) added to the table
- Prose explaining that these govern the readiness handshake between
  LocalAI and a freshly spawned backend (Health polling loop)
- Total-load-window formula
- Concrete failure signature so users can recognize a timeout-kill
  vs. a real backend crash
- Example configuration for a ~10 min cold-load window (grpc.attempts
  140, attempts_sleep_time 5), with a note that inference-timeouts and
  the watchdog are unaffected.
2026-07-16 22:18:47 +02:00
pos-ei-don
e62221b020 fix(sglang): implement Status RPC to unblock backend-monitor polling (#10867)
The sglang Python backend inherits the default Status RPC from
backend_pb2_grpc.BackendServicer, which raises NotImplementedError.
LocalAI's backend-monitor polls /backend.Backend/Status periodically on
every registered backend; when the call fails, /backend/monitor returns
HTTP 500 and downstream inference requests to the sglang backend are
blocked even though the model is loaded and answering directly via the
gRPC endpoint.

Add a minimal Status shim that mirrors the existing Health method and
returns StatusResponse{state=READY} unconditionally. This unblocks the
monitor path; a state-aware follow-up (UNINITIALIZED during load, BUSY
under active inference) is left for a subsequent change.

Reproduced on DGX Spark (GB10, arm64-l4t-cuda-13 image) with the sglang
v0.5.15 backend and Qwen3-Coder-Next-NVFP4-GB10; verified locally that
patching the shim in place immediately restores /backend/monitor and
inference across the sglang slot.
2026-07-16 22:18:12 +02:00
Tai An
dc2cc4da43 fix(audio-transform): serialize WebSocket writes to avoid concurrent-write panic (#10857)
* fix(audio-transform): serialize WebSocket writes to avoid concurrent-write panic

AudioTransformStreamEndpoint writes to the same Gorilla WebSocket connection
from two goroutines: the backend-forwarding goroutine emits binary PCM frames
(and can call sendWSError on a backend recv error), while the read loop calls
sendWSError for malformed mid-stream JSON or a backend send failure. Gorilla
WebSocket permits only one concurrent writer, so these writers race and can
panic with "concurrent write to websocket connection", resetting the client
session; a -race build reports the data race directly.

Wrap the connection in a lockedConn that serializes WriteMessage behind a
mutex, mirroring the existing lockedConn used by the openresponses WebSocket
endpoint. Reads stay on the single read loop, so only writes need the lock.

Fixes #10844

Signed-off-by: Tai An <antai12232931@outlook.com>

* chore: empty commit to re-trigger checks

Signed-off-by: Anai-Guo <antai12232931@anaiguo.com>

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Signed-off-by: Anai-Guo <antai12232931@anaiguo.com>
Co-authored-by: Anai-Guo <antai12232931@anaiguo.com>
2026-07-16 16:25:31 +01:00
Nandana Dileep
ab7b58fc85 fix(watchdog): force-kill stuck-busy backends instead of deadlocking the loader (#10578)
When the watchdog's busy-killer decides a backend has been busy past the
busy timeout, it shuts it down via ModelLoader.ShutdownModel -> deleteProcess,
which grabs ml.mu and then waits for IsBusy() to clear BEFORE stopping the
process. But a backend that exceeds the busy timeout is, by definition,
stuck on an in-flight gRPC call, so the graceful wait never returns, ml.mu
is held forever, and every other ml.Load blocks — including the shared
opus backend load at the start of every realtime (WebRTC) session. New
realtime connections then hang at "Connected, waiting for session..."
whenever the watchdog is enabled, while logs repeatedly print the
watchdog's busy / "active connection" line.

Fix: add a force shutdown path (ShutdownModelForce / deleteProcess(s,
force=true)) that stops the process FIRST — dropping the stuck call's
gRPC connection and unblocking it — instead of waiting on it. Route the
watchdog's busy-killer and busy LRU / group / memory evictions through
the force path; keep the graceful wait for idle and user-initulated
unloads. Graceful/unforced kills are unchanged.

Regression test: the watchdog busy-killer uses ShutdownModelForce.

Fixes #10391


Assisted-by: opencode:glm-5.2 [opencode]

Signed-off-by: Nandana Dileep <110280757+nandanadileep@users.noreply.github.com>
2026-07-16 12:36:23 +00:00
LocalAI [bot]
bcdb8debfe chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to 9e11ce41b90a2238ca1ec09e0c71fcc913544f2a (#10850)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-16 10:10:55 +02:00
LocalAI [bot]
bbe018c1a0 feat(bonsai): PrismML llama.cpp fork backend + Bonsai/Ternary-Bonsai gallery models (#10834)
feat(bonsai): add PrismML llama.cpp fork backend + Bonsai gallery models

Adds a new `bonsai` backend that runs the PrismML fork of llama.cpp
(github.com/PrismML-Eng/llama.cpp, `prism` branch), which ships the Q1_0
(1-bit) and Q2_0 (ternary / 1.58-bit) weight-quantization kernels used by the
Bonsai and Ternary-Bonsai models. Stock llama.cpp cannot decode these quants.

Modeled on the turboquant backend: reuses backend/cpp/llama-cpp/grpc-server.cpp
against the fork's libllama via a thin wrapper Makefile, so the sub-2-bit models
are served with the same OpenAI-compatible API. No grpc-server allow-list patch
is needed (bonsai adds weight quants, transparent to the server, not KV-cache
types), and the reused server compiles cleanly against the fork with no skew
patches (validated locally via a CPU docker build; patches/ is present but empty
for any future re-pin skew).

Backend wiring: backend/cpp/bonsai/, .docker/bonsai-compile.sh,
backend/Dockerfile.bonsai, top-level Makefile targets, backend-matrix.yml build
rows (CPU, CUDA 12/13, L4T, SYCL f32/f16, Vulkan, ROCm/hipblas), backend/index.yaml
meta-backend + per-platform images, and a nightly bump_deps entry tracking the
`prism` branch.

Gallery: 8 entries across 4 families - bonsai-8b-1bit, ternary-bonsai-8b (+g64,
+pq2), bonsai-27b-1bit (vision), ternary-bonsai-27b (+pq2, +g64, vision). The 27B
models wire the mmproj vision tower; the DSpark speculative drafter GGUFs are not
wired (custom semi-autoregressive drafter, not a standard llama.cpp draft model).


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-16 10:09:14 +02:00
LocalAI [bot]
3880812ed6 chore: ⬆️ Update CrispStrobe/CrispASR to 5b38179a4a3281fcdba4220ff285f32e80df43a8 (#10851)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-16 10:02:09 +02:00
Tai An
808312b4b9 fix(watchdog): guard StopWatchdog with watchdogMutex to prevent double close (#10841) (#10859)
fix(watchdog): guard StopWatchdog with watchdogMutex to prevent double close

StopWatchdog checked, closed and cleared a.watchdogStop without holding
a.watchdogMutex, while startWatchdog and RestartWatchdog reassign and close the
same channel under that lock.

POST /api/settings dispatches to StopWatchdog or RestartWatchdog depending on
ApplicationConfig.WatchdogShouldRun(), so both are reachable concurrently. Two
callers can observe a non-nil watchdogStop and both close it, which panics with
'close of closed channel' and takes the server down.

Take the mutex, matching the other two writers. StopWatchdog is only called from
the settings handler, which holds no lock, so this cannot deadlock.

Fixes #10841

Co-authored-by: Anai Guo <antai12232931@anaiguo.com>
2026-07-16 09:40:29 +02:00
LocalAI [bot]
6a985d13ea chore: ⬆️ Update ikawrakow/ik_llama.cpp to 1fddd12ba861c4815a8633f14d9c5670692099cc (#10762)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-16 09:06:24 +02:00
Tai An
5fe48e4910 fix(backend): don't crash the whole process on an invalid cutstrings/extract_regex (#10855)
Finetune() compiled every model cutstrings/extract_regex entry via regexp.Compile
and called xlog.Fatal on failure, which terminates the entire local-ai process.
A single model config with an invalid regex (e.g. cutstrings: ["("]) turns one
/v1/chat/completions request into a process-level denial of service.

Log the compile error and skip the offending pattern instead. The mutex is
released before continuing, and skipping avoids dereferencing the nil regexp
that removing the fatal would otherwise leave behind.

Fixes #10843

Signed-off-by: Tai An <antai12232931@outlook.com>
2026-07-16 08:54:35 +02:00
LocalAI [bot]
ff8774327f feat(swagger): update swagger (#10847)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-16 08:53:02 +02:00
Tai An
688f904a10 fix(runtime-settings): apply persisted threads/context_size/f16 at startup (#10853)
ApplyRuntimeSettings persists the performance settings (threads,
context_size, f16) on the live /api/settings path, but the startup
loader loadRuntimeSettingsFromFile never read them back, so a value
saved via the Middleware UI was silently ignored on the next restart:
the model booted with the CLI/physical-core default and GET /api/settings
echoed that default instead of the saved value (#10845).

Threads needs special handling: unlike context_size/f16, WithThreads
eagerly resolves an unset (0) value to xsysinfo.CPUPhysicalCores() at
option-apply time, so options.Threads is never 0 in the loader and the
usual "== default" heuristic cannot tell an env/CLI value from the
physical-core fallback. Detect LOCALAI_THREADS/THREADS explicitly so the
env still wins over the persisted file value.

Signed-off-by: Anai-Guo <Anai-Guo@users.noreply.github.com>
Co-authored-by: Anai-Guo <Anai-Guo@users.noreply.github.com>
2026-07-16 08:52:11 +02:00
LocalAI [bot]
8c9b3b2e33 chore(model-gallery): ⬆️ update checksum (#10854)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-16 08:41:35 +02:00
LocalAI [bot]
e488884b20 chore: ⬆️ Update ggml-org/llama.cpp to 505b1ed15ca80e2a19f12ff4ac365e40fb374053 (#10848)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-16 08:36:59 +02:00
LocalAI [bot]
e062179d4d chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to 11c67198db58f75bf1bafc9051c2b018aaf1a3da (#10852)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-16 08:36:41 +02:00
Richard Palethorpe
b9d6d49e31 fix(cloud-proxy): publish backend gallery entries (#10858)
Add stable and development gallery variants for Linux and Darwin, and wire the backend build matrix so the referenced images are published.

Assisted-by: Codex:gpt-5 [yq]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-07-16 08:36:23 +02:00
LocalAI [bot]
a23fcc90c3 feat(gallery): add Qwen3.5-4B DFlash speculative-decoding model (#10842)
Pairs unsloth/Qwen3.5-4B-GGUF (Q4_K_M target) with the
AtomicChat/Qwen3.5-4B-DFlash-GGUF Q8_0 drafter (quantized from
z-lab/Qwen3.5-4B-DFlash, upstream GGUF arch `dflash`), same shape as
the existing DFlash entries.

Assisted-by: Claude Code:claude-fable-5 [Bash] [Read] [Edit]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-15 15:45:27 +02:00
LocalAI [bot]
d19c9875ed chore: ⬆️ Update ggml-org/llama.cpp to 00fa7cb284cbf133fc426733bd64238a3588a33e (#10814)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-15 09:59:46 +02:00
LocalAI [bot]
8cec22c3b7 feat(vram): per-node VRAM allocation budget (LOCALAI_VRAM_BUDGET) (#10833)
* feat(vram): add vrambudget primitive for per-node VRAM caps

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): apply default VRAM budget in xsysinfo aggregate getters

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): wire LOCALAI_VRAM_BUDGET flag to xsysinfo default budget

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): persist VRAM budget via runtime settings with live apply

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(vram): reset process-global VRAM budget after runtime-settings spec

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): add VRAM budget field to Settings page

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): store and enforce per-node VRAM budget in the node registry

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): apply per-node VRAM budget in router hardware defaults

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): report worker VRAM budget in node registration

The distributed worker now reports its operator-set VRAM budget string
(LOCALAI_VRAM_BUDGET) to the server on registration. The worker keeps
reporting RAW total/available VRAM and never sets the xsysinfo
process-global budget (that stays standalone-only); the server resolves
and enforces the budget uniformly (Task 6).

Also closes a Task 6 gap: on re-registration, a struct Updates zero-skips
an empty budget, so a worker that dropped LOCALAI_VRAM_BUDGET left the
stale cap in place. For non-admin-override nodes the budget columns are
now force-written (map Updates) even when empty, so removing the env var
clears the cap; admin overrides are preserved unchanged.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* style(vram): drop em dash from worker-clear comment

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): add node VRAM budget admin endpoints

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): add node VRAM budget control to the node UI

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): expose set_node_vram_budget MCP admin tool

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(vram): document LOCALAI_VRAM_BUDGET and node VRAM budget UI

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(vram): avoid double-applying VRAM budget in GetResourceAggregateInfo

The GPU-branch aggregate returned by GetResourceInfo is sourced from
GetGPUAggregateInfo, which already caps total/free/used against the
process-wide VRAM budget. GetResourceAggregateInfo then applied the
budget a second time. For an absolute budget this is idempotent, but for
a percentage budget b.Apply resolves the ceiling as a fraction of its
input total, so a second pass yields P*(P*T) instead of P*T and distorts
UsagePercent (read by the memory reclaimer in pkg/model/watchdog.go).

Remove the redundant second application so the budget is applied exactly
once, against the raw physical totals, upstream in GetGPUAggregateInfo.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(vram): implement SetNodeVRAMBudget on mcp assistant test stub

The LocalAIClient interface gained SetNodeVRAMBudget; the stubClient in
core/http/endpoints/mcp used by the assistant tests is a separate
implementer and needs the method too (broke golangci-lint typecheck and
both test jobs).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-15 09:58:45 +02:00
LocalAI [bot]
3601174ce0 fix(distributed): make per-node backend upgrade actually upgrade (#10838)
* test(core/http): make the suite's HTTP port overridable

app_test.go and openresponses_test.go hardcoded 127.0.0.1:9090. When
another service already listens on 9090 the suite does not fail fast:
the server goroutine logs the bind error and the specs then poll
whatever is squatting the port until Eventually times out. On machines
where 9090 is permanently taken this makes the pre-commit coverage gate
impossible to pass.

Introduce testHTTPAddr, defaulting to 127.0.0.1:9090 (what CI has
always used) and overridable via LOCALAI_TEST_HTTP_PORT for local runs.

Assisted-by: Claude:claude-fable-5 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(distributed): make per-node backend upgrade actually upgrade

The node detail page's Upgrade button reused the node-scoped install
path (POST /api/nodes/:id/backends/install). That fires NATS
backend.install with force=false, and the worker's install handler is
deliberately "ensure installed": when the backend binary already exists
on disk it short-circuits without touching the gallery. Since only an
installed backend can be upgraded, the whole chain was a guaranteed
successful no-op - the UI then toasted "backend upgraded" without even
waiting for the async job.

Route upgrades through the real force-reinstall path instead:

- BackendManager.UpgradeBackend now receives the ManagementOp (like
  InstallBackend already did) so implementations can honor
  op.TargetNodeID.
- DistributedBackendManager.UpgradeBackend scopes the backend.upgrade
  fan-out to op.TargetNodeID when set, and errors when the target node
  does not report the backend as installed.
- New POST /api/nodes/:id/backends/upgrade endpoint enqueues an
  Upgrade=true node-scoped op (async 202 + jobID, mirroring install).
- NodeDetail UI calls the new endpoint and reports the dispatch
  ("Upgrading ... on this node...") instead of claiming success; the
  Operations panel tracks the actual job.

Verified against a live local cluster (NATS + Postgres + two workers):
the target worker stops the running process, force-reinstalls from the
gallery and re-downloads the OCI image; the second worker receives no
backend.upgrade event; upgrading a backend missing from the target node
fails the job with a clear error.

Assisted-by: Claude:claude-fable-5 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-15 09:16:55 +02:00
LocalAI [bot]
40763d1181 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to 7bb91886f613f4f54407604f4284e5b6ecd2acdf (#10832)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-15 09:02:14 +02:00
LocalAI [bot]
afbed9d49b chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to 98a5d5fb43268fb85c637ac0a29ed67cc6a1f7d9 (#10830)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-15 01:09:46 +02:00
LocalAI [bot]
bcc41219f7 feat: materialize Hugging Face model artifacts (#10825)
* feat(config): add model artifact source contract

Assisted-by: Codex:GPT-5 [Codex]

* feat(downloader): add authenticated raw-byte progress

Assisted-by: Codex:GPT-5 [Codex]

* feat(huggingface): resolve immutable snapshot manifests

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): add artifact storage primitives

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): materialize pinned Hugging Face snapshots

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): bind managed snapshots at runtime

Assisted-by: Codex:GPT-5 [Codex]

* feat(gallery): materialize model artifacts during install

Assisted-by: Codex:GPT-5 [Codex]

* feat(gallery): declare managed Hugging Face artifacts

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): preload managed model artifacts

Assisted-by: Codex:GPT-5 [Codex]

* fix(gallery): retain shared artifact caches on delete

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): report artifact acquisition progress

Assisted-by: Codex:GPT-5 [Codex]

* refactor(backends): load managed models from ModelFile

Assisted-by: Codex:GPT-5 [Codex]

* refactor(backends): load staged speech model snapshots

Assisted-by: Codex:GPT-5 [Codex]

* refactor(backends): use staged snapshots in engine backends

Assisted-by: Codex:GPT-5 [Codex]

* test(distributed): cover staged artifact snapshots

Assisted-by: Codex:GPT-5 [Codex]

* docs: explain managed model artifacts

Assisted-by: Codex:GPT-5 [Codex]

* docs: add product design context

Assisted-by: Codex:GPT-5 [Codex]

* feat(ui): show model artifact download progress

Assisted-by: Codex:GPT-5 [Codex]

* Eagerly materialize Hugging Face artifacts

Materialize HF-backed model references as managed GGUF artifacts during load, with lazy download retained only as fallback.

Assisted-by: Codex:GPT-5 [shell]

* Refactor HF
  downloads through a shared executor

Assisted-by: Codex:GPT-5 [shell]

* drop

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-15 01:09:33 +02:00
LocalAI [bot]
d82c38ee77 chore: ⬆️ Update leejet/stable-diffusion.cpp to a8a91b24cdf18a3e415d7f2a28f69b5be8a17700 (#10828)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-15 01:09:18 +02:00
LocalAI [bot]
64124f3fa1 chore: ⬆️ Update CrispStrobe/CrispASR to 40d508096bb52850862edafc9741da509c5ede97 (#10829)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-15 00:53:17 +02:00
LocalAI [bot]
88cc80ee3d chore(model-gallery): ⬆️ update checksum (#10831)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-14 23:53:45 +02:00
LocalAI [bot]
bed5e7417c docs: ⬆️ update docs version mudler/LocalAI (#10826)
⬆️ Update docs version mudler/LocalAI

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-14 23:53:28 +02:00
LocalAI [bot]
ba1d0f5507 chore: ⬆️ Update vllm-project/vllm cu130 wheel to 0.25.1 (#10827)
⬆️ Update vllm-project/vllm cu130 wheel

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-14 23:53:16 +02:00
LocalAI [bot]
2bed6f65ba fix(kokoro): pin compatible Intel XPU runtime (#10823)
PyTorch 2.13 XPU pulls oneAPI 2026 libraries that conflict with the oneAPI 2025.3 backend image. Pin torch and torchaudio to the matching 2.11 XPU pair so the build resolves a coherent 2025.3 runtime.

Assisted-by: Codex:GPT-5 [uv]

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-14 18:58:13 +02:00
LocalAI [bot]
b224c96db6 fix(config): only inject llama.cpp serving options on the llama.cpp path (#10822)
SetDefaults injected the llama.cpp server options cache_reuse
(ApplyServingDefaults) and parallel (ApplyHardwareDefaults, re-applied
per selected node by the distributed router) onto every model config
regardless of backend. Every other backend ignores options it does not
understand, so this was harmless until longcat-video, which strictly
validates its options and fails LoadModel with
"unknown model option(s): cache_reuse, parallel".

Gate both injections behind a new UsesLlamaCppServingOptions allow-list
(llama-cpp plus the empty/auto-detect case that resolves to llama.cpp
from a GGUF file, mirroring how llamaCppDefaults is registered). This
follows the existing UsesLlamaSamplerDefaults precedent for llama-only
defaults. The typed NBatch field is deliberately left alone: it is a
proto field every backend simply ignores, which is why batch never
triggered the error.

Also harden the longcat-video backend to warn-and-ignore unknown model
options and request params through a testable select_known_options
helper, matching the other LocalAI Python backends, so a future
server-injected option cannot break loading again.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-14 17:46:15 +02:00
LocalAI [bot]
9f14571397 chore(model-gallery): ⬆️ update checksum (#10816)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-14 11:25:00 +02:00
Ijas
a5aa56db81 fix: preserve uploaded file content when regenerating a non-last answer (#10819)
handleRegenerate rebuilt the outbound message from the display-only
message.files metadata ({name, type: 'file'|'image'|..., content}),
which doesn't carry the base64/textContent payload sendMessage's
file-building loop expects. As a result, regenerating any answer whose
own question had an attachment silently dropped that attachment from
the resent message. This wasn't fork-specific, but forking a chat and
then regenerating an earlier (now non-last) answer is the natural way
to hit it.

Fix by reusing the original message's already-assembled `content`
verbatim (it already has the file text / image_url / audio_url /
video_url parts embedded from the first send) instead of trying to
reconstruct it from lossy display metadata.

Fixes #10806

Assisted-by: Claude:claude-sonnet-5

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
2026-07-14 11:24:42 +02:00
LocalAI [bot]
05b8d8aafe chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to bbdf4be03aa5bc3c188b5db778b6f3fd63ceff6c (#10794)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-14 08:38:20 +02:00
LocalAI [bot]
3c1e583985 chore: ⬆️ Update CrispStrobe/CrispASR to d76cce027e3b183fc3d8c72e976e69d11f71bc8b (#10813)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-14 08:38:05 +02:00
LocalAI [bot]
2609848d80 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260713103604 (#10812)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-14 08:36:49 +02:00
dependabot[bot]
cdb6702ca6 chore(deps): bump actions/stale from 10.3.0 to 10.4.0 (#10807)
Bumps [actions/stale](https://github.com/actions/stale) from 10.3.0 to 10.4.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](eb5cf3af3a...1e223db275)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 22:50:01 +02:00
dependabot[bot]
0d8bea0158 chore(deps): update charset-normalizer requirement from >=3.4.7 to >=3.4.9 in /backend/python/vllm (#10809)
chore(deps): update charset-normalizer requirement

Updates the requirements on [charset-normalizer](https://github.com/jawah/charset_normalizer) to permit the latest version.
- [Release notes](https://github.com/jawah/charset_normalizer/releases)
- [Changelog](https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jawah/charset_normalizer/compare/3.4.7...3.4.9)

---
updated-dependencies:
- dependency-name: charset-normalizer
  dependency-version: 3.4.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 22:49:44 +02:00
dependabot[bot]
0b0f52bedc chore(deps): bump grpcio from 1.81.1 to 1.82.1 in /backend/python/vllm (#10808)
Bumps [grpcio](https://github.com/grpc/grpc) from 1.81.1 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.81.1...v1.82.1)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-version: 1.82.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 22:49:28 +02:00
dependabot[bot]
48b1ab28b7 chore(deps): bump vllm from 0.24.0 to 0.25.0 in /backend/python/vllm (#10811)
Bumps [vllm](https://github.com/vllm-project/vllm) from 0.24.0 to 0.25.0.
- [Release notes](https://github.com/vllm-project/vllm/releases)
- [Changelog](https://github.com/vllm-project/vllm/blob/main/RELEASE.md)
- [Commits](https://github.com/vllm-project/vllm/compare/v0.24.0...v0.25.0)

---
updated-dependencies:
- dependency-name: vllm
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 22:48:35 +02:00
Dedy F. Setyawan
b10e330590 feat(react-ui): localize MediaHistory, AudioTransform, and Sound components (#10802)
- Replace hardcoded text with useTranslation hook in UI components
- Add localization support for both English (en) and Indonesian (id) locales

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
2026-07-13 11:51:23 +00:00
LocalAI [bot]
4056283aa4 [voice] feat: add managed voice cloning profiles (#10799)
* feat(ui): add voice library workflow

Give administrators a production-ready flow to record or upload consented reference audio, manage reusable profiles, inspect API usage, discover compatible models, and hand a saved voice directly to text-to-speech.

Assisted-by: Codex:gpt-5

* feat(voice): add managed voice cloning profiles

Make reusable reference voices manageable through the admin API instead of requiring model-directory and YAML edits. Discover compatible installed and gallery models from server-side backend capabilities, retain explicit model configuration controls, and stage saved references for supported backends.

Expose profile management through REST and MCP, document backend-specific behavior, and cover the workflow from profile creation through real Qwen3-TTS synthesis. Harden the agent-job HTTP test against completion racing cancellation.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-13 09:54:46 +02:00
LocalAI [bot]
b90e1cae73 chore: ⬆️ Update ggml-org/llama.cpp to 6b4dc2116a92c5c8f2782bfe51fabe5ee66fb5ef (#10797)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-13 09:05:44 +02:00
LocalAI [bot]
c43ee40eb7 chore: ⬆️ Update CrispStrobe/CrispASR to 841281c46ce5b34323b2861ae5714c09aaa2542e (#10795)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-13 01:10:16 +02:00
LocalAI [bot]
659b9f02e0 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260711221406 (#10796)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-13 01:10:03 +02:00
LocalAI [bot]
67c14e1b7e chore(model-gallery): ⬆️ update checksum (#10798)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-13 01:09:13 +02:00
LocalAI [bot]
0e6241a5aa chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to d17c33d4ee2f56d15f9ca8a1bb82f7389305f838 (#10793)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-13 01:09:03 +02:00
LocalAI [bot]
b00422e45f feat(backends): add LongCat video and avatar generation (#10792)
* feat(backends): add LongCat video and avatar generation

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] [web]

* refactor(config): declare model I/O modalities

Make model configs declare input and output modalities so capability discovery no longer branches on backend or checkpoint names. Complete the LongCat gallery and user documentation, make the SDPA patch apply to the pinned upstream revision, and stabilize the Agent Jobs race exposed by the required hook.

Assisted-by: Codex:GPT-5 [web]

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-12 23:58:46 +02:00
LocalAI [bot]
af8f74cba2 chore: ⬆️ Update CrispStrobe/CrispASR to 4beda42f63bf5c813c1e1bb55249efb36f918c80 (#10784)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-12 11:27:12 +02:00
LocalAI [bot]
cdd9582653 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to bfc447c47a592698b539e0b819b1a4e3c91c4730 (#10786)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-12 11:14:22 +02:00
LocalAI [bot]
fb0f5e4bdd feat(gallery): add Qwen DFlash speculative-decoding models (#10791)
Add four ready-to-run DFlash speculative-decoding entries for the
llama.cpp backend, now that upstream DFlash support (draft-dflash) is in
the pinned llama.cpp. Each entry bundles a full target model with its
small z-lab block-diffusion drafter and sets spec_type:draft-dflash,
spec_n_max:15, and flash attention (required by DFlash):

- qwen3-4b-dflash          (Qwen3-4B + Qwen3-4B-DFlash drafter)
- qwen3.5-9b-dflash        (Qwen3.5-9B + Qwen3.5-9B-DFlash drafter)
- qwen3.6-27b-dflash       (Qwen3.6-27B dense + drafter)
- qwen3.6-35b-a3b-dflash   (Qwen3.6-35B-A3B MoE + drafter)

The 4B pair uses the base Qwen3-4B target (not Qwen3.5-4B): its drafter
reports general.name "Qwen3 4B DFlash" and is the canonical pairing
documented upstream. All drafters were downloaded and verified to carry
GGUF architecture "dflash" (not the fork-only "dflash-draft" /
"DFlashDraftModel") so they load in the upstream backend, and every
drafter SHA256 was confirmed against the downloaded bytes.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-12 11:14:06 +02:00
LocalAI [bot]
5013d53a1c chore: ⬆️ Update ggml-org/llama.cpp to e3546c7948e3af463d0b401e6421d5a4c2faf565 (#10787)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-12 10:07:58 +02:00
LocalAI [bot]
e8d8f5b0b8 chore: ⬆️ Update ggml-org/whisper.cpp to 080bbbe85230f624f0b52127f1ae1218247989f9 (#10785)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-12 10:07:45 +02:00
LocalAI [bot]
459ffb3054 chore: ⬆️ Update leejet/stable-diffusion.cpp to b5d812008eb7082a238fc589444544b3278187ae (#10774)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-12 10:07:34 +02:00
LocalAI [bot]
cad07be2fc chore(model-gallery): ⬆️ update checksum (#10789)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 23:41:48 +02:00
hogeheer499-commits
2634b13a5d fix(ds4): bundle transitive runtime dependencies (#10783)
Bundle the resolved dependency closure for grpc-server and ds4-worker, then validate that packaged dependencies resolve only from package/lib.

Assisted-by: Codex:gpt-5 shellcheck

Signed-off-by: JS van Dijk <267467744+hogeheer499-commits@users.noreply.github.com>
Co-authored-by: JS van Dijk <267467744+hogeheer499-commits@users.noreply.github.com>
2026-07-11 23:41:27 +02:00
LocalAI [bot]
8786eace97 chore: ⬆️ Update vllm-project/vllm cu130 wheel to 0.25.0 (#10788)
⬆️ Update vllm-project/vllm cu130 wheel

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 23:39:51 +02:00
Ettore Di Giacinto
a1cefe862d Rename voxtral.c to voxtral-tts.c in README
Signed-off-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-07-11 16:24:44 +02:00
LocalAI [bot]
50cd897719 docs: refresh LocalAI homepage (#10780)
* docs: refresh LocalAI homepage

Reframe the homepage around LocalAI's modular multimodal runtime, native inference engines, deployment range, and built-in platform capabilities. Remove the outdated video in favor of current product visuals and clearer paths into the documentation.

Assisted-by: Codex:gpt-5

* docs: give homepage a full-width canvas

Let the product homepage opt out of Relearn's persistent sidebar and duplicate title while preserving the documentation shell on interior pages. Tighten the responsive bounds for narrow screens.

Assisted-by: Codex:gpt-5

* docs: fit homepage to the Relearn content flow

Remove the full-width shell exception and use a single-column homepage inside the standard documentation layout. This avoids competing scroll containers and the compressed split hero.

Assisted-by: Codex:gpt-5

* docs: hide homepage scroll rail

Preserve Relearn's content scrolling while removing the visible scrollbar beside the landing-page hero.

Assisted-by: Codex:gpt-5

* docs: contain homepage sections within docs column

Prevent landing-page headings, figures, and section grids from widening Relearn's content pane or exposing overflow rails.

Assisted-by: Codex:gpt-5

* docs: remove nested homepage scrollbars

Wrap the quick-start command within its column and suppress component-level scrollbar tracks across the landing page.

Assisted-by: Codex:gpt-5

* docs: remove outdated gallery screenshot

Drop the stale Model Gallery image from the homepage until a current product visual is available.

Assisted-by: Codex:gpt-5

* docs: fix homepage architecture link

Point the homepage CTA at the generated reference/architecture route.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-11 09:33:41 +02:00
LocalAI [bot]
8ff3c8c466 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to bb250f57e48d7dbaff8f66b8125c42a14ddabbe7 (#10759)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 09:17:48 +02:00
Richard Palethorpe
1f9fda7138 fix(backends): refuse foreign model loads in opus and local-store (#10769)
When a model config has no explicit backend, the model loader greedily
probes every installed backend and binds to the first Load that
succeeds. opus and local-store were the only in-tree backends with no
model artefact to validate, so they accepted anything — an LLM
installed after them could silently bind to the audio codec or the
vector store and then fail at inference with "unimplemented"
(see #9287).

opus now accepts only its own name (what the realtime WebRTC path
sends) or none. local-store namespaces are arbitrary (router caches,
biometrics, user-named stores), so core's StoreBackend now marks
genuine store loads with a store:// prefix on the gRPC model name and
the backend refuses names without it; core and backend ship from the
same release, so the convention upgrades in lockstep.

Also repair the bit-rotted 'make test-stores' bootstrap (the suite
never registered external backends, so BACKENDS_PATH was dead weight)
and add the Load-validation rule to the adding-backends checklist.

Related: #9287

Assisted-by: Claude:claude-fable-5 golangci-lint

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-07-11 09:17:34 +02:00
LocalAI [bot]
23a044ee0b chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260710141421 (#10775)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 09:16:12 +02:00
LocalAI [bot]
921a8ffc8b chore: ⬆️ Update mudler/moss-transcribe.cpp to 92a923dca88a41a34e47a364d55ee25731a9a0a2 (#10771)
⬆️ Update mudler/moss-transcribe.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 09:15:58 +02:00
LocalAI [bot]
a8f1c92a24 chore: ⬆️ Update CrispStrobe/CrispASR to 74efaf24d457e34cd200d138f9724987e7ececc3 (#10773)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 09:15:45 +02:00
LocalAI [bot]
6084497da1 chore: ⬆️ Update ggml-org/llama.cpp to 4f37f519722aa3242eecb7649466b4a4a2d6d6da (#10772)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 09:15:32 +02:00
LocalAI [bot]
2482f075a2 chore: ⬆️ Update ggml-org/whisper.cpp to 7695a5331230c585f5ce92291c4256973985ae5a (#10776)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 09:14:19 +02:00
LocalAI [bot]
9b4f373bc4 chore(model-gallery): ⬆️ update checksum (#10778)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 09:14:06 +02:00
LocalAI [bot]
185956154a chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to b297bc36d5e54ac37263fc8f2766e96179bf1e8a (#10761)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-10 10:43:11 +02:00
LocalAI [bot]
d3d5488dc7 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260708043308 (#10760)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-10 10:42:56 +02:00
LocalAI [bot]
ae58115ee6 chore: ⬆️ Update leejet/stable-diffusion.cpp to cc734292286f85f9c48305d94d7fd22f42838522 (#10738)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-10 10:42:42 +02:00
LocalAI [bot]
c7a9db29a6 chore: ⬆️ Update CrispStrobe/CrispASR to aa23c61c8ba483f539fa669708ea7ddbba72f293 (#10758)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-10 10:42:26 +02:00
LocalAI [bot]
c46a224b44 chore: ⬆️ Update ggml-org/llama.cpp to 049326a00025d00b08cc188ed716b681e984a3f8 (#10757)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-10 10:42:12 +02:00
LocalAI [bot]
7e8542ba32 fix(tests): make e2e backend model downloads resumable and stall-based (#10766)
The vibevoice transcription e2e hangs until the go test timeout when
the HF CDN is slow: the ASR Q4_K model is >10 GB, downloadFile capped
every curl attempt at --max-time 600 (needs a sustained ~17 MB/s to
fit), and curl's --retry restarts from byte zero, so no attempt ever
makes forward progress. This killed the job twice on PR #10764 and
previously forced skipping it on release tags (#10567).

Replace the wall-clock cap with stall detection (--speed-limit 1 MiB/s
over --speed-time 120s) and resume from the bytes already on disk with
-C -, retrying from Go because curl does not re-evaluate the resume
offset on its internal retries. Resume against the HF Xet CDN was
verified by killing a transfer mid-flight and confirming the next
invocation appended (114 MB -> 235 MB, GGUF magic intact).

Also parameterize the suite timeout (BACKEND_TEST_TIMEOUT, default
30m) and raise it to 120m for the vibevoice transcription wrapper: a
10 GB download plus 25 specs does not fit in 30m even on a good day,
and the job-level GHA timeout there is already 150m.


Assisted-by: Claude:claude-fable-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-10 09:19:17 +02:00
LocalAI [bot]
3c2d85aae4 feat(vibevoice-cpp): true streaming TTS (TTSStream via vv_capi_tts_stream) (#10764)
* feat(vibevoice-cpp): true streaming TTSStream via vv_capi_tts_stream

Replaces the synth-to-tempfile TTSStream hack with a real streaming path:
binds the new vv_capi_tts_stream callback ABI via a single reusable purego
callback (CGO_ENABLED=0-safe, no runtime/cgo), copies each int16 PCM window
into the gRPC results channel after the streaming WAV header.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* test(vibevoice-cpp): real-model streaming integration test with TTFA measurement

Gated behind VIBEVOICE_IT=1, this Ginkgo spec dlopens the engine .so and
drives the exact Go->purego->C TTSStream/TTS path against the real
vibevoice-realtime-0.5B model. It measures time-to-first-audio for the
streaming path versus the batch path and asserts the streaming win:
44-byte WAV header first, >=2 PCM windows, non-silent audio, and
TTFA < total_stream. Without the env var the spec skips so CI and
normal go test are unaffected.

Measured: TTFA 2.38s vs batch deliver-time 39.96s (first audio in 5.9%
of the batch time, ~17x faster), 18 stream chunks, non-silent 24kHz PCM.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* chore(vibevoice-cpp): pin streaming-decoder engine build

Bumps VIBEVOICE_CPP_VERSION to the streaming-decoder engine commit that
adds vv_capi_tts_stream (localai-org/vibevoice.cpp#8). Re-pin to the
merged master commit once that PR lands.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* chore(vibevoice-cpp): re-pin to merged streaming-decoder commit

localai-org/vibevoice.cpp#8 merged to master as 000e372; move the pin
off the PR branch commit onto the merged master commit.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* test(vibevoice-cpp): check writer errors in TTFA report (errcheck)

golangci-lint errcheck flagged the unchecked fmt.Fprintf calls that
print the streaming TTFA headline. Build the report once with
fmt.Sprintf and write it per destination with an explicitly discarded
error, matching the GinkgoWriter reporting idiom used by the other
backend tests.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-fable-5 [Claude Code]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 23:48:44 +00:00
LocalAI [bot]
6ceb2f86a7 chore(model-gallery): ⬆️ update checksum (#10763)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-10 00:41:19 +02:00
LocalAI [bot]
c5b36639d4 chore(model gallery): 🤖 add 1 new models via gallery agent (#10755)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-09 23:28:44 +02:00
LocalAI [bot]
94bdc825dc feat(backend): add moss-transcribe-cpp backend (MOSS-Transcribe-Diarize) (#10756)
C++/ggml transcription + speaker diarization + timestamps backend. Purego
dlopens libmoss-transcribe.so (ggml statically linked) from moss-transcribe.cpp
and serves offline AudioTranscription, parsing the [start][Sxx]text[end] output
into segments with nanosecond timestamps. Adds the importer (surfaces in
GET /backends/known), backend-matrix (Linux + Darwin/metal), backend/index.yaml,
and a gallery entry (default q5_k GGUF from mudler/moss-transcribe.cpp-gguf).

Local L0 smoke (go build + go test ./... = 16 pass, golangci-lint 0 issues)
passed against the real libmoss-transcribe.so. The pre-commit coverage gate
(full pkg/core + tests/e2e) could not run in the authoring sandbox (no live
models, port 9090 held); CI must enforce it before merge.

Assisted-by: Claude:claude-opus-4-8 golangci-lint

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 23:27:11 +02:00
Dedy F. Setyawan
294487eb61 fix(ui): prevent table container from breaking flexbox layout (#10754)
Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
2026-07-09 23:21:07 +02:00
Ettore Di Giacinto
fa3e139540 docs(readme): add face-detect.cpp, voice-detect.cpp and free-splatter.cpp to native engines list
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 11:51:28 +00:00
LocalAI [bot]
35024338a6 feat(crispasr): add F5-TTS support and gallery model (#10753)
Link the f5-tts library into the crispasr backend so CrispASR's native
F5-TTS runtime (SWivid F5-TTS, 22-layer DiT flow-matching + built-in Vocos
vocoder) is compiled in. The single self-contained GGUF auto-detects as
f5-tts through the session router, so no explicit backend selector is
needed. Add the f5-tts-crispasr gallery entry (cstr/f5-tts-GGUF) and an
env-gated e2e synthesis spec.

F5-TTS is voice-cloning only and has no baked speaker: it clones from a
reference WAV plus its transcript, supplied via the voice/voice_text
options. The gallery description documents this bring-your-own-reference
requirement.

Verified e2e on the pinned engine (278fb79): the GGUF auto-detects as
f5-tts, the reference voice loads, and synthesis produces a valid 24 kHz
mono WAV.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 08:09:00 +00:00
LocalAI [bot]
b987f39de8 feat(swagger): update swagger (#10745)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-09 09:35:22 +02:00
LocalAI [bot]
16d028a127 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to e03824a9f874fe648b8f26bf293703af60afe936 (#10715)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-09 09:05:40 +02:00
LocalAI [bot]
70e15679cd chore: ⬆️ Update ggml-org/llama.cpp to a646006f09d2f76f2d62d6c0d5e8e8490d570720 (#10747)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-09 09:03:51 +02:00
LocalAI [bot]
5569b2de56 feat(config): context_size: -1 to auto-use model's full trained context (#10752)
* feat(config): clamp negative context_size to default in EffectiveContextSize

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* feat(config): resolve context_size=-1 to model trained max with VRAM warn

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* fix(config): treat negative context_size as unset when GGUF is unparseable

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* docs(config): document context_size=-1 auto-max

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* docs(backend): drop em dashes from EffectiveContextSize comment

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 09:03:40 +02:00
LocalAI [bot]
c9f73f40ff chore: ⬆️ Update CrispStrobe/CrispASR to 278fb7927633d47fa0aeb6f81491b9952ed17c35 (#10737)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-09 01:13:06 +02:00
LocalAI [bot]
4cd3dbe931 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 6198a356a85ed71534c02a9c1026203389f341e5 (#10746)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-09 01:12:48 +02:00
LocalAI [bot]
1a04b670f4 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to e93b3fedd53504c29c1c1a9bed4fbe722bbb1df5 (#10748)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-09 01:12:35 +02:00
LocalAI [bot]
e948f27965 chore(model-gallery): ⬆️ update checksum (#10749)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-09 01:12:22 +02:00
LocalAI [bot]
40dae953f4 feat: interleaved thinking with tool calls (reasoning_content alias + Anthropic thinking blocks) (#10744)
* feat(schema): accept reasoning_content as inbound alias for reasoning

Interleaved-thinking clients (cogito, vLLM/DeepSeek-style) emit reasoning_content
on assistant turns. Accept it as an inbound alias so reasoning survives the
tool-result loop; canonical reasoning wins when both are present. Emission is
unchanged (still reasoning).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(schema): pin interleaved reasoning+tool_calls round-trip

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(openai): pin reachedTokenBudget truncation detection

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(anthropic): add thinking and signature fields to content blocks

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(anthropic): parse inbound thinking blocks into reasoning

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(anthropic): emit thinking blocks with synthetic signature on tool turns

Extract buildAnthropicContentBlocks so non-streaming content assembly is
unit-testable, and prepend a thinking block (with an opaque synthetic
signature) before text/tool_use blocks when the request opts into thinking.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(anthropic): stream thinking_delta and signature_delta before tool_use

Extract anthropicStreamSequence so the streaming block order is unit-testable,
and emit content_block_start(thinking) -> thinking_delta -> signature_delta ->
content_block_stop before the tool_use block sequence when thinking is enabled.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: add interleaved thinking with tool calls guide

Add a features guide describing interleaved thinking: an assistant turn
carrying reasoning and tool_calls together, the reasoning-round-trip
contract (including the reasoning_content inbound alias and Anthropic
thinking blocks with a synthetic signature), per-backend enablement
(reasoning_format for llama.cpp, reasoning_parser/tool_call_parser for
vLLM/SGLang plus the vLLM auto-config hook), a worked request/response
example, and known limitations. Cross-link from model-configuration,
text-generation, and openai-functions.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-08 16:45:43 +00:00
LocalAI [bot]
8671c8adac chore(model gallery): 🤖 add 1 new models via gallery agent (#10743)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-08 17:43:02 +02:00
LocalAI [bot]
d5d659bb65 fix(backends): pin grpcio-tools to installed grpcio in runProtogen (protobuf gencode mismatch) (#10735)
fix(backends): pin grpcio-tools to the installed grpcio in runProtogen

runProtogen installed grpcio-tools unpinned, so the protoc it bundles
stamped backend_pb2.py with the newest Protobuf gencode (7.35.0). When a
backend caps the protobuf runtime lower -- vLLM pins protobuf to 6.33.6 --
the import-time guarantee runtime >= gencode fails:

  google.protobuf.runtime_version.VersionError: Detected incompatible
  Protobuf Gencode/Runtime versions ... gencode 7.35.0 runtime 6.33.6

The backend crashes on `import backend_pb2` before it can serve, which
surfaces to the user as "grpc service not ready". It was mis-reported as a
ROCm/gfx1201 failure in #10718 but is not GPU-specific and affects every
vLLM variant (and any backend that caps protobuf below the latest gencode).

Pin grpcio-tools to the grpcio version the backend already installed --
they release in lockstep -- so the generated gencode stays in step with
the protobuf runtime. Falls back to unpinned when grpcio isn't present.

Closes #10718


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-08 15:24:42 +02:00
LocalAI [bot]
a0ed395cf9 fix(auth): accept EC/PS/EdDSA-signed OIDC ID tokens, not just RS256 (#10736)
The OIDC verifier was built with a bare oidc.Config{ClientID: ...}, so
go-oidc applied its default of accepting RS256-signed ID tokens only. An
identity provider configured with an EC signing key (e.g. Authentik) issues
ES256-signed tokens, and the callback failed verification with:

  failed to verify ID token: oidc: malformed jwt: unexpected signature
  algorithm "HS256"; expected ["RS256"]

surfacing to the user as HTTP 500 "failed to fetch user info" (#10677; the
underlying cause became visible after the logging fix in #10679).

Set SupportedSigningAlgs to the standard asymmetric algorithms
(RS256/384/512, ES256/384/512, PS256/384/512, EdDSA). All are verified
against the provider's published JWKS. HS256 is intentionally excluded: it
is symmetric and would validate against the client secret, a different and
security-sensitive trust model.

Tested with a functional spec that signs an ES256 ID token and confirms it
verifies with the configured algorithms and is rejected under go-oidc's
RS256-only default (using oidc.StaticKeySet, no network).

Closes #10677


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-08 15:24:29 +02:00
LocalAI [bot]
40c29db8c4 fix(logs): capture backend logs by default in single mode (#10742)
Backend log capture into the per-model BackendLogStore (which feeds the
UI "Backend Logs" page and /api/backend-logs) was opt-in and off by
default in single mode, while worker/distributed mode force-enables it
via SetBackendLoggingEnabled(true). There was no CLI flag either, so the
only way to populate the store was the Settings UI toggle - and the page
was silently empty out of the box. Distributed "just worked"; single
mode looked broken.

Default EnableBackendLogging to true in NewApplicationConfig so single
mode matches worker mode. The store is a small in-memory ring buffer, so
the cost is negligible.

Now that the default is on, loadRuntimeSettingsFromFile's usual
"only flip false->true" merge would ignore a persisted false and revert
the UI toggle-off on every restart. There is no env var/CLI flag for
this setting, so an explicit persisted value is now authoritative in
both directions, letting the toggle-off survive a restart.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-08 12:13:52 +00:00
LocalAI [bot]
0aaf7cce76 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 5c2552b6e820cc791ba9cfa6ffdfd98002b4cd82 (#10732)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-08 08:50:00 +02:00
LocalAI [bot]
731bf04668 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260707023344 (#10733)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-08 08:49:48 +02:00
LocalAI [bot]
d829e818d0 chore: ⬆️ Update ggml-org/llama.cpp to bec4772f6a2527d371557b5d2032641e5ff7619c (#10739)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-08 08:49:34 +02:00
github-actions[bot]
0ae84be362 chore: bump inference defaults from unsloth (#10741)
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-08 08:49:22 +02:00
LocalAI [bot]
d521608e6d chore: ⬆️ Update mudler/locate-anything.cpp to ade2634f7f79b56121125e5885628744795a478f (#10734)
⬆️ Update mudler/locate-anything.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 22:03:16 +00:00
LocalAI [bot]
7dde5a4225 fix(diffusers,vllm-omni,tinygrad): save generated images as PNG explicitly (#10729)
The image backends call PIL Image.save(request.dst) without a format, so
Pillow infers the encoder from the file extension. The core passes an
absolute staging path ending in .tmp (e.g. /staging/localai-output-*.tmp),
which Pillow can't map to a format, raising "unknown file extension: .tmp"
and crashing the worker right after a successful GPU inference.

Pass format="PNG" explicitly. LocalAI serves generated images as PNG
regardless of the temporary path, so this is always correct and no longer
depends on the extension of the destination the core happens to allocate.

diffusers is the reported backend (#10727); vllm-omni and tinygrad carry
the identical latent crash for any .tmp staging destination.

Closes #10727


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 21:24:58 +00:00
LocalAI [bot]
cd65a1f645 fix(transcription): honor model-config language/translate + OpenAI language form field (#10731)
fix(transcription): honor model-config language/translate, add language form field

The /v1/audio/transcriptions endpoint read only input.Language /
input.Translate from the parsed request, and the request middleware never
populates those from a multipart upload -- nor did it read a `language`
form field. As a result the model config's parameters.language /
parameters.translate (a valid PredictionOptions field under `parameters:`)
were silently ignored, and multilingual models like canary defaulted to
translating into English even when the YAML set language: ru,
translate: false (#10655).

Resolve both with clear precedence: the request form field wins, then any
language on the parsed request, then the model config default. This also
makes the endpoint honor OpenAI's `language` form parameter, which was
not read before.

Applies to both the streaming and non-streaming paths (the resolved
values are built into the shared TranscriptionRequest). Note this ensures
the language/translate flags reach the backend; whether a given engine
acts on them is up to the backend.

Closes #10655


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 21:16:25 +00:00
LocalAI [bot]
97175f4b5a feat(model): debounce model loads after a failure to stop retry-storms (#10728)
A client that keeps polling a model whose load fails (e.g. a backend that
crashes deterministically on init) triggered a fresh backend start on
every request: request -> load -> crash in ~10s -> 500, repeat on the
next poll. Each attempt could leak GPU/CUDA state, and under
LOCALAI_SINGLE_ACTIVE_BACKEND it kept stealing the active slot from
healthy models. The existing loading-coalesce map only dedups
*concurrent* loads, so sequential polls were never covered.

Track load failures per modelID in ModelLoader. After a load fails,
refuse fresh load triggers for that model until a cooldown elapses,
returning a typed ModelLoadCooldownError that the HTTP layer maps to 503
with a Retry-After header. The cooldown grows exponentially per
consecutive failure (base, doubling, capped at 5m) and resets on a
successful load. The coalesced follower-retry of an in-flight burst
bypasses the gate, so a genuinely concurrent burst still gets its one
retry -- only new, independent triggers are refused, matching the
report's "refuse new load-triggers" wording.

Configurable via --model-load-failure-cooldown /
LOCALAI_MODEL_LOAD_FAILURE_COOLDOWN (default 10s, 0 disables), plumbed
through ApplicationConfig and applied unconditionally at startup.

Closes #10719


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 20:55:24 +00:00
LocalAI [bot]
1d5139f0a0 fix(gallery): make backend (re)install a clean replace instead of an overlay (#10726)
InstallBackend extracted the artifact directly into the target directory
with no pre-clean, so a reinstall overlaid the new files onto the old
ones. Files present in a previous version but absent in the new artifact
(a stale .so, an orphaned package dir) survived and could shadow the new
build at import time -- e.g. an old vllm shared object lingering next to
a freshly pulled one. Only a failed download cleaned the directory.

Stage the download/extraction into a `<name>.install-tmp` dir, validate
run.sh is present, write metadata, then atomically swap it into place
(rename current -> .install-backup, staging -> current, drop backup),
rolling back on failure. This mirrors the atomic swap UpgradeBackend
already performs, so install and upgrade now leave identical on-disk
state with no orphaned files.

Reported as part of #10720.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 20:23:13 +00:00
LocalAI [bot]
8565febe45 fix(vllm): pin L4T arm64 backend to vllm==0.24.0 for GB10 stability (#10725)
The nvidia-l4t-cuda-13-arm64 vLLM backend left `vllm` unpinned, so the
prebuilt image drifted onto whatever aarch64 wheel was latest at build
time (0.23.x). On GB10 / DGX Spark (Grace Blackwell, unified memory),
0.23 crashes deterministically during cold model loads with an empty
"Engine core initialization failed" set and pins GPU memory until a host
reboot.

vLLM 0.24.0 carries vllm-project/vllm#45179 ("release cached device
memory under pressure on UMA GPUs during weight loading"), which the
reporter verified fixes the crash on GB10. Pin the L4T requirements to
0.24.0 to match the already-pinned cublas13 build
(requirements-cublas13-after.txt) and keep the image deterministic.

Editing this file also re-triggers the single-arch L4T image build via
the path filter, republishing the gallery image with 0.24.0 (the
single-arch matrix builds again after #10703).

Closes #10722


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 20:18:23 +00:00
Roman Mazurenko
a3fdfbc0d1 feat(llama-cpp): add device selection option (#10724)
Allow llama.cpp model configs to select the backend devices used for offload, matching upstream --device behavior so users can exclude a display or debug GPU.

Signed-off-by: rvmzes <rvmzes@rvmzess-MacBook-Pro.local>
Co-authored-by: rvmzes <rvmzes@rvmzess-MacBook-Pro.local>
2026-07-07 20:09:05 +00:00
Tai An
2f33cc7bc4 fix(vram): report largest GGUF quant instead of whole HF repo for gallery size (#10700) (#10707)
* fix(vram): report largest GGUF quant, not whole repo, for HF gallery size (#10700)

Signed-off-by: Tai An <antai12232931@outlook.com>

* test(vram): cover multi-GGUF quant repo size estimation (#10700)

Signed-off-by: Tai An <antai12232931@outlook.com>

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-07-07 11:50:27 +00:00
LocalAI [bot]
22225217e0 docs: ⬆️ update docs version mudler/LocalAI (#10709)
⬆️ Update docs version mudler/LocalAI

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 08:55:01 +02:00
LocalAI [bot]
c1fd12a506 chore: ⬆️ Update ggml-org/llama.cpp to f36e5c348bc8795c34f9a038e58876e7a8423d4d (#10710)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 08:54:33 +02:00
LocalAI [bot]
d01b2c4f46 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to 0725d2e53b7d2749a99ff33d3a460b954ffa7805 (#10711)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 08:41:46 +02:00
LocalAI [bot]
fb9ff061f1 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260706123649 (#10712)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 08:41:34 +02:00
LocalAI [bot]
40f847745e chore: ⬆️ Update ikawrakow/ik_llama.cpp to a8cf53fd69bada5450bd653eb0e32d1113fbd7fe (#10713)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 08:41:21 +02:00
LocalAI [bot]
ba9327b9f8 chore: ⬆️ Update CrispStrobe/CrispASR to 0a7643f1006c6bf2d1f37f5c63e9726f5eb4f364 (#10716)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 08:41:00 +02:00
LocalAI [bot]
fd467c5b3b chore: ⬆️ Update leejet/stable-diffusion.cpp to bb84971129d2a094ab8051c6feed5406d3b4409d (#10684)
* ⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(stablediffusion-ggml): pass chroma knobs via model_args after upstream API change

Upstream stable-diffusion.cpp bb849711 removed the dedicated
chroma_use_dit_mask / chroma_use_t5_mask / chroma_t5_mask_pad fields from
sd_ctx_params_t and now reads them from the generic model_args key=value
spec (parse_key_value_args). Assigning the old struct members broke the
gosd.cpp build. Emit the three options into model_args instead so the
existing chroma controls keep working. Verified by building
libgosd-fallback.so against the pinned upstream commit.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-06 23:57:50 +00:00
dependabot[bot]
fa0622604a chore(deps): bump actions/cache from 4 to 6 (#10704)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 21:39:37 +02:00
Dennis Huang
ff5758113b chore(model gallery): add MiniCPM series models (#10699)
Add 9 MiniCPM models to the gallery:
- MiniCPM-V 4.6 (1.3B multimodal, edge-optimized)
- MiniCPM-V 4.6 Thinking (1.3B multimodal with reasoning)
- MiniCPM-V 4 (multimodal)
- MiniCPM-o 4.5 (8B omni-modal, vision+speech)
- MiniCPM-o 2.6 (7.6B omni-modal)
- MiniCPM5-1B (text)
- MiniCPM4.1-8B (text)
- MiniCPM4-8B (text)
- MiniCPM3-4B (text)

All sha256 checksums sourced from HuggingFace LFS metadata.

Signed-off-by: Dennis Huang <huangsiyuan20060408@hotmail.com>
2026-07-06 21:39:12 +02:00
LocalAI [bot]
29db4ab414 fix(ci): shard single-arch backend matrix under GitHub's 256-job limit (#10703)
GitHub Actions refuses to instantiate a matrix that would generate more
than 256 jobs. It does so silently: the job hangs forever at "Waiting for
pending jobs" and the whole run is marked `failure` while every other job
stays green. This is exactly what happened on the v4.6.1 tag build
(run 28786533892): the single-arch build matrix had grown to 268 entries,
so `backend-jobs-singlearch` (and its downstream merge) never produced a
single job, and the release build "failed" with no failing job to point at.

The single-arch list is the one that grows unbounded as backends are added,
so shard it across a fixed number of matrix jobs (SINGLEARCH_SHARDS=4,
~67 entries each today, headroom to ~1020 backends). Each merge shard
`needs:` only its matching build shard, preserving the "merge waits only on
its own build" property that keeps slow CUDA/ROCm builds from gating
multi-arch manifest assembly.

changed-backends.js now emits per-shard matrix/has-* outputs and throws
loudly if a shard ever reaches the 256 limit (telling the maintainer to
bump SINGLEARCH_SHARDS and add matching job blocks) instead of letting
GitHub drop the overflow silently. backend.yml and backend_pr.yml define
the four build + four merge shard jobs; multi-arch and darwin groups are
untouched.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-06 21:38:12 +02:00
weifanglab
a6cf67cc6b refactor: use slices.Contains to simplify code (#10702)
Signed-off-by: weifanglab <weifanglab@outlook.com>
2026-07-06 19:33:28 +02:00
LocalAI [bot]
85f5267ed2 fix(llama-cpp): cap single-pass embedding batch to fit VRAM (#10695)
* fix(llama-cpp): cap single-pass embedding batch to fit VRAM

Embedding/score/rerank all decode or pool the whole input in one physical
batch, so EffectiveBatchSize sized the batch to the full context window. For
a large context that makes n_ubatch huge, and the per-device CUDA compute
buffer (forward-graph scratch, ~n_ubatch * n_ctx, NOT split across GPUs)
balloons into multi-GiB: a large-context embedding model then aborts on load
(exitCode=-1) even with plenty of free VRAM. Reproduced with qwen3-embedding-4b
(context 40960 -> n_batch 40960 -> abort) and qwen3-embedding-0.6b
(n_batch 8192); pinning batch:512 avoided it.

This is the same root cause as issue #10485 (a large context turns the batch
into multi-GiB of scratch that must fit on a SINGLE card), but the single-pass
path bypassed the VRAM headroom guard the config layer already had — it
returned the unbounded context as the batch with no GPU awareness.

Make the single-pass batch VRAM-aware: cap it to the largest batch whose
compute buffer fits the per-device VRAM headroom, clamped to
[DefaultPhysicalBatch, ctx], reusing the existing computeBufferBytesPerCell and
headroom-divisor math (no duplication). Unknown per-device VRAM (0) stays
conservative (DefaultPhysicalBatch, not the context) so a detection gap can't
OOM. The GPU is resolved through an injectable package var (config.LocalGPU,
backed by sync.Once-cached xsysinfo detection) so the per-request router call
stays cheap and tests inject a deterministic device. Explicit batch: still
wins. An input longer than the cap can no longer be pooled in one pass — the
accepted tradeoff, since a batch that OOMs the device processes nothing.

Assisted-by: Claude:claude-opus-4-8 golangci-lint go-test
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(config): single-pass batch follows context on unknown VRAM

The single-pass (embedding/score/rerank) batch cap must only shrink the batch
when the per-device VRAM ceiling is KNOWN. On unknown VRAM (CPU-only or a GPU
detection gap) SinglePassBatchForContext returned DefaultPhysicalBatch, which
under-sized the batch below the context — over-trimming score/embed/rerank
inputs (the modelTokenTrim middleware regression) with no OOM benefit on CPU
where the compute buffer lives in system RAM. Return the full context instead,
preserving the original single-pass behavior; the VRAM cap stays a downward
safety that only engages when VRAM is known.

Assisted-by: Claude:claude-opus-4-8 [go-test go-vet]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-06 12:56:09 +02:00
LocalAI [bot]
ed3b59baf1 fix(config): cap auto-derived context to fit VRAM (#10696)
When a model is imported without an explicit context_size, the GGUF
importer defaulted the model's context to its full trained window
(n_ctx_train). For long-context models (128k / 256k / 1M) that KV cache
cannot fit a consumer GPU, so the backend aborts on load (exitCode=-1)
even though the model file is perfectly fine. Reproduced live:
gemma-4-26b-a4b-it-qat-q4_0 defaulted to context=262144 and
qwythos-9b-claude-mythos-5-1m to 1048576, both aborting on a 20 GB card.

Instead of chasing the trained max, auto-derive a conservative default:
min(trainedMax, DefaultAutoContextSize=8192). A small model keeps its
trained window; a long-context model caps at 8k and users opt into more
via context_size. This cap applies always, including CPU / unknown-VRAM
hosts, so it never regresses those paths.

Per-device VRAM is used only as a DOWNWARD safety: when a per-device
ceiling is detected (xsysinfo.MinPerGPUVRAM) and even the 8k cap would
not fit it with headroom, step down through candidate contexts to the
largest that fits, floored at DefaultContextSize. When VRAM is unknown
(0) or no GPU is detected we do NOT clamp — the bug is GPU OOM and the
8k cap is already safe, so detection gaps must not shrink the window.

The footprint estimate reuses gpustack/gguf-parser-go's
EstimateLLaMACppRun at a given context with all layers offloaded, taking
the per-device NonUMA VRAM figure. The estimate and VRAM detection are
package vars so tests inject deterministic values. Explicit context_size
always wins (guessGGUFFromFile only acts when it is nil).

Assisted-by: Claude:claude-opus-4-8 [golangci-lint go-test]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-06 12:53:45 +02:00
LocalAI [bot]
461ae84732 fix(startup): scope generated-content and upload dirs to the current user (#10698)
The `--generated-content-path` and `--upload-path` defaults were the fixed
shared locations `/tmp/generated/content` and `/tmp/localai/upload`. On any
multi-user host these collide across accounts: macOS routes `/tmp` to the
shared `/private/tmp` for every user, so whichever account starts LocalAI
first creates the parent with 0750 perms and every other account then fails
startup with:

    unable to create ImageDir: "mkdir /tmp/generated/content: permission denied"
    unable to create UploadDir: "mkdir /tmp/localai/upload: permission denied"

The same happens on Linux once a stale root-owned `/tmp/generated` (e.g. from
a prior `sudo` run) is left behind. This bites the desktop launcher and any
app embedding the raw binary (Wingman, nib-desktop), which start `local-ai
run` with no path flags.

Default both paths under the OS temp dir (`os.TempDir()`, honoring `$TMPDIR`;
already per-user on macOS) namespaced by the current UID
(`TMPDIR/localai-<uid>/...`), so accounts never collide while the paths stay
ephemeral. Wired via new kong vars in main.go so every consumer of the raw
binary inherits the fix. All content subdirs (audio, images) derive from
`GeneratedContentDir`, so they are fixed transitively.

As defense in depth, the launcher also anchors these two paths under its own
per-user data directory (mirroring the #10610 fix for data/config), extracted
into a testable `BuildRunArgs`.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-06 12:53:25 +02:00
Tai An
2a4426c5ec fix(reasoning): don't persist request-scoped reasoning_effort as an operator disable (#10622) (#10623)
* fix(reasoning): don't persist request-scoped reasoning_effort into model config

When a model sets `reasoning_effort: none` (or any default) in its YAML
without an explicit `reasoning.disable`, ApplyReasoningEffort resolves that
default at request time and sets ReasoningConfig.DisableReasoning on the
request-scoped config copy. The post-load thinking/marker probe then wrote
that request-scoped value back into the loader's persistent config via
UpdateModelConfig, making it look as though the operator had explicitly set
reasoning.disable=true. From then on, per-request `reasoning_effort` overrides
were silently ignored (an explicit operator disable wins over a request
asking to think).

DetectThinkingSupportFromBackend only fills reasoning slots that are still
nil, so a slot already set here came from ApplyReasoningEffort, not the probe.
Snapshot which slots were nil before the probe and only persist those, so the
probe's genuine backend detection is still saved while request-time reasoning
effort never leaks into the persistent config.

Fixes #10622

Signed-off-by: Tai An <antai12232931@outlook.com>

* test(reasoning): cover persist-guard added in this PR, extract for testability

ModelInference's post-probe persistence of ReasoningConfig.DisableReasoning /
DisableReasoningTagPrefill had no test: the guard logic lived inline in a
closure only reachable through a live gRPC backend. Extract it into
persistProbedReasoning (pure refactor, no behavior change) so it can be
exercised directly against a ModelConfigLoader, then add specs covering:

- a probe-filled slot (nil beforehand) gets persisted
- a slot that already carried a request-scoped value (e.g. from
  reasoning_effort: none) is left alone, i.e. the #10622 regression stays
  fixed
- an operator's explicit persisted disable is preserved when the guard is
  false
- the media marker still persists unconditionally

Verified red/green: reverting persistProbedReasoning to the old unconditional
copy fails exactly the two guard specs.

Assisted-by: Claude:claude-sonnet-5 go vet
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(reasoning): ignore os.Remove error in temp file cleanup (errcheck)

Signed-off-by: Tai An <antai12232931@outlook.com>

* chore: empty commit to re-trigger flaky Agent Jobs CI test

Signed-off-by: Tai An <antai12232931@outlook.com>

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-07-06 09:23:10 +02:00
LocalAI [bot]
2348bdc16d chore: ⬆️ Update ggml-org/llama.cpp to 2da668617612d2df773f966e3b0ee22dc2beef7b (#10694)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 01:46:47 +02:00
walcz-de
2ccc67bc7f feat(agents): native Prometheus metrics for agent chat runs (#10689)
Operators need a scrape-friendly signal for agent-turn health (completing,
erroring, cancelled, duration) — log-derived counters proved brittle (ANSI/
timezone parsing, restart gaps). Adds localai_agent_runs_total{agent,outcome}
and localai_agent_run_seconds histogram, recorded at the Chat() response
handoff (single choke point of the local execution path). Lazy meter init,
same pattern as the PII events counter (#10641).

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-07-06 01:06:15 +02:00
LocalAI [bot]
0a6c62bb59 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to 73fe0c67bbf0898ba2999535e0680a02a7f8537d (#10683)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 01:05:43 +02:00
LocalAI [bot]
1297356e29 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to daedb763fd442e0916eb130a479fdd74947291c0 (#10682)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 01:05:25 +02:00
LocalAI [bot]
3f36b1dbed chore: ⬆️ Update CrispStrobe/CrispASR to 09df654e304947f7521e1f52992ceacccf03c300 (#10693)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 00:32:28 +02:00
LocalAI [bot]
783222baf4 docs: ⬆️ update docs version mudler/LocalAI (#10680)
⬆️ Update docs version mudler/LocalAI

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 00:32:00 +02:00
LocalAI [bot]
bd3f2588fd fix(ui): center the home empty-state wizard (#10691)
The no-models getting-started wizard (`.home-wizard`) rendered
left-aligned instead of centered. `.home-page` is a column flexbox with
the default `align-items: stretch`; a child with `max-width: 48rem`
cannot be stretched past its max-width, so it falls back to the
cross-start (left) edge. The populated home branch never exposed this
because its children are full-width.

Add `margin: 0 auto` to `.home-wizard` so the max-width block centers
horizontally, for both the admin getting-started wizard and the
non-admin no-models hero.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-05 13:12:09 +02:00
LocalAI [bot]
40e659974d chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260704102955 (#10668)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 10:20:19 +02:00
LocalAI [bot]
deb43e56c0 chore(model-gallery): ⬆️ update checksum (#10686)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 10:20:02 +02:00
LocalAI [bot]
33869da527 chore: ⬆️ Update ggml-org/llama.cpp to 665892536dfb1b7532161e3182304bd35c33e768 (#10681)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 10:19:36 +02:00
LocalAI [bot]
8059117c2d chore: ⬆️ Update CrispStrobe/CrispASR to 1109cb3fcae2e242c2b3d42ec0e3fd6e813f2ce7 (#10685)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 09:29:18 +02:00
LocalAI [bot]
b0959d4756 feat(api): add GET /v1/models/capabilities endpoint (#10687)
Additive superset of /v1/models that enriches each model entry with the
capabilities it supports plus its input/output modalities
(text / image / audio / video). Clients that only understand /v1/models
are unaffected -- they simply never call the new route.

Audio and video *input* are derived from the model's multimodal limits
(vLLM limit_mm_per_prompt), which no single usecase FLAG expresses. That
gap is exactly why a plain capability list is insufficient and this
enriched endpoint exists: an attachment router can now decide whether an
image/audio/video file can go to the active model directly, or must be
converted/transcribed first.

Capability derivation lives in core/config as the single source of truth
(ModelConfig.Capabilities / InputModalities / OutputModalities /
VisionSupported / ...); the Ollama capability surface now delegates to
it instead of keeping a parallel copy. Vision is gated on
chat/completion capability so a MediaMarker hydrated onto a non-chat
model (e.g. a pure ASR/TTS backend) no longer reports a false vision
capability.

Read-only listing: no new FLAG_* flag, reuses the existing `models`
swagger tag, and intentionally exposes no MCP admin tool (there is
nothing to manage conversationally).

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-05 08:51:55 +02:00
LocalAI [bot]
9e41be4bfb fix(auth): log the real cause of OIDC/OAuth user-info failures (#10679)
The OAuth callback discarded the error returned by user-info resolution
before sending the generic 500, so real failures were completely opaque
in the logs: ID-token verification errors (e.g. issuer/audience mismatch
behind a reverse proxy), a missing id_token, claim-parse errors, or a
rejecting GitHub userinfo endpoint all collapsed into
"failed to fetch user info" with nothing logged.

Log the wrapped cause with xlog.Error (provider + error), matching the
code-exchange step just above it. The client-facing message is unchanged,
so no internal detail leaks to the browser.

Refs #10677


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-04 19:33:53 +02:00
687 changed files with 54812 additions and 6346 deletions

View File

@@ -34,7 +34,7 @@ The build matrix is data-only YAML at `.github/backend-matrix.yml` (not inside `
**Without an entry here no image is ever built or pushed, and the gallery entry in `backend/index.yaml` will point at a tag that does not exist.** The `dockerfile:` field must point at `./backend/Dockerfile.<lang>` matching the language bucket from step 1 (e.g. `Dockerfile.python`, `Dockerfile.golang`, `Dockerfile.rust`). The `tag-suffix` must match the `uri:` in the corresponding `backend/index.yaml` image entry exactly.
**`scripts/changed-backends.js` registration — REQUIRED for any new dockerfile suffix.** This is the single most common omission, because it has no effect on the PR that adds the backend (when no prior path filter could catch it anyway) — it only breaks the *next* PR that touches your backend's directory, which then gets zero CI jobs and looks broken for unrelated reasons. Edit `scripts/changed-backends.js:inferBackendPath` and add a branch BEFORE the more-generic suffixes:
**Path-filter registration — REQUIRED for any new dockerfile suffix.** This is the single most common omission, because it has no effect on the PR that adds the backend (when no prior path filter could catch it anyway) — it only breaks the *next* PR that touches your backend's directory, which then gets zero CI jobs and looks broken for unrelated reasons. Edit `scripts/lib/backend-filter.mjs:inferBackendPath` and add a branch BEFORE the more-generic suffixes:
```js
if (item.dockerfile.endsWith("<your-dockerfile-suffix>")) {
@@ -54,7 +54,9 @@ for (const e of m.include.filter(e => e.backend === '<your-backend>')) {
}"
```
A quick way to find the right insertion point: `grep -n 'item.dockerfile.endsWith' scripts/changed-backends.js`.
A quick way to find the right insertion point: `grep -n 'item.dockerfile.endsWith' scripts/lib/backend-filter.mjs`.
If your backend consumes a *shared* build input that lives outside its own directory (a new script under `scripts/build/`, a new file copied into every image), add a rule to `SHARED_BUILD_INPUTS` in the same file — the per-backend prefix match cannot see those, and a miss ships your change to no image at all. See `scripts/lib/backend-filter_test.mjs` for the pattern; `make test-ci-scripts` runs it.
**`bump_deps.yaml` registration — REQUIRED for any backend pinning an upstream commit.** If your backend's Makefile has a `*_VERSION?=<sha>` pin to a third-party repo, the daily auto-bump bot at `.github/workflows/bump_deps.yaml` won't notice it unless you register the backend in its matrix. The bot runs `.github/bump_deps.sh` which `grep`s for `^$VAR?=` in the Makefile you list — so the pin MUST live in the Makefile (not in a separate shell script). The bump for ds4 (#9761) had to walk this back because the original landed the pin in `prepare.sh`, which the bot can't see. Pattern (for `antirez/ds4`):
@@ -115,7 +117,7 @@ Wiring a backend into `includeDarwin:` is more than the matrix entry:
1. **`includeDarwin:` entry** — `tag-suffix: "-metal-darwin-arm64-<backend>"`, `build-type: "metal"`, `lang: "go"` for go+ggml backends; omit `build-type` for the bespoke C++ ones (llama-cpp / ds4 / privacy-filter). Match an existing entry of the same shape.
2. **`backend/index.yaml`** — add `metal:` to the backend's `capabilities` map (main and `-development`) and concrete `metal-<backend>` / `metal-<backend>-development` image entries pointing at the `-metal-darwin-arm64-<backend>` images.
3. **C/C++ backends only** — add an `inferBackendPathDarwin` case in `scripts/changed-backends.js` returning `backend/cpp/<backend>/` (the generic fallthrough assumes `backend/<lang>/`, which is wrong for a C++ source tree driven with `lang: go`), and give `run.sh` a Darwin branch that exports `DYLD_LIBRARY_PATH` instead of `LD_LIBRARY_PATH`. If the build is bespoke (single `grpc-server` + dylib bundling), model it on `scripts/build/ds4-darwin.sh` and add a `backends/<backend>-darwin` make target plus a gated step in `.github/workflows/backend_build_darwin.yml`.
3. **C/C++ backends only** — add an `inferBackendPathDarwin` case in `scripts/lib/backend-filter.mjs` returning `backend/cpp/<backend>/` (the generic fallthrough assumes `backend/<lang>/`, which is wrong for a C++ source tree driven with `lang: go`), and give `run.sh` a Darwin branch that exports `DYLD_LIBRARY_PATH` instead of `LD_LIBRARY_PATH`. If the build is bespoke (single `grpc-server` + dylib bundling), model it on `scripts/build/ds4-darwin.sh` and add a `backends/<backend>-darwin` make target plus a gated step in `.github/workflows/backend_build_darwin.yml`.
4. **C++ proto gotcha** — if the backend compiles the generated gRPC/protobuf in a separate CMake target (e.g. `hw_grpc_proto`), that target must link `protobuf::libprotobuf` + `gRPC::grpc++` so the Homebrew include dirs propagate; otherwise macOS fails with `google/protobuf/runtime_version.h not found` (Linux hides this because apt headers sit in `/usr/include`).
The CI path filter only builds a backend on a PR when a file under its directory changes, so a darwin-only YAML edit builds nothing — touch a file under `backend/<lang>/<backend>/` (a one-line comment is enough) in the same PR.
@@ -216,6 +218,69 @@ docker-build-backends: ... docker-build-<backend-name>
- If the backend is in `backend/python/<backend-name>/` but uses `.` as context in the workflow file, use `.` context
- Check similar backends to determine the correct context
## Engine preference for gallery model variants
A gallery entry can declare `variants`, alternative builds of the same weights,
and LocalAI picks one per host: it drops builds whose backend cannot run here or
that do not fit memory, then ranks the survivors by **engine preference
first, serving feature second, size third** (`SelectVariant` in
`core/gallery/resolve_variant.go`).
Ask whether your backend should outrank another one on some hardware. If it
should, add it to `engineNamePreferenceRules` in `pkg/system/capabilities.go`,
best engine first for that capability:
```go
{Nvidia, []string{engineVLLM, engineSGLang, engineLlamaCpp}},
+ {Nvidia, []string{engineVLLM, engineSGLang, engineMyEngine, engineLlamaCpp}},
```
That is the ENGINE NAME table, matched as a substring of a gallery entry's
`backend:` value. Two sibling tables in the same file speak different
vocabularies and are matched against different things:
| Table | Vocabulary | Matched against | Consumer |
|-------|-----------|-----------------|----------|
| `backendBuildTagPreferenceRules` | build tags (`cuda`, `rocm`, `metal`) | installed build directory names, as a substring | alias resolution in `ListSystemBackends` |
| `engineNamePreferenceRules` | engine names (`vllm`, `llama-cpp`, `mlx`) | a gallery entry's `backend:`, as a substring | gallery variant ranking |
| `servingFeaturePreferenceTokens` | serving features (`dflash`, `mtp`) | a gallery entry's `tags:`, compared whole and case-insensitively, and nothing else | gallery variant ranking, one rank below the engine |
**Putting a token in the wrong table matches nothing and does not error**: every
candidate scores equal and the next sort key decides, so the preference silently
stops existing. The block comment above all three tables spells the contract out.
The serving feature table is the odd one: it is not keyed by capability, because
no hardware prefers a plain build over an equivalent faster build of the same
weights. It reads a declared tag and nothing else. The entry name was the
original signal and is gone: a naming convention is not a contract, and names
are author-supplied free text where a short marker like `mtp` turns up inside
unrelated words or on weights whose entry enables nothing.
`overrides.options` was rejected for the mirror-image reason: `spec_type:` is
llama.cpp's config vocabulary, whereas a cross-backend ranking decision must
work the same for `ds4`'s `mtp_path:` and `sglang`'s `speculative_algorithm:`.
**If your backend can serve the same weights faster** (speculative decoding,
multi-token prediction), say so in the docs for its gallery entries so curators
tag them: the tagging rule and the per-backend evidence table live in
[adding-gallery-models.md](adding-gallery-models.md). A backend never needs to
appear in the token table itself; it ranks builds, not engines.
Leaving your backend out is a valid choice when no ordering can be justified for
it. It then ranks below every known engine and selection falls back to size,
which is the behaviour that predates preference.
**Leaving a whole capability out is not.** A missing row gives that host an
empty preference list, so size alone decides among everything that survives the
filters, and the filter will not save you: `IsBackendCompatible` derives hardware
support from the engine NAME, so `vllm` and `sglang` carry no darwin, cuda, rocm
or sycl token and are never dropped on a host with no GPU. That is why `default`
(no usable accelerator, including a GPU under the 4 GiB VRAM floor) and
`darwin-x86` both have rows putting `llama-cpp` first. Every capability
`getSystemCapabilities()` can return needs a row unless every engine really is
equally at home there. When you add one, enumerate the engines you are demoting
rather than relying on them falling through unmatched: unmatched engines all tie
with each other, so size decides among them.
## Documenting the backend (README + docs)
A backend is not "added" until it is discoverable. Update the user-facing docs:
@@ -243,7 +308,7 @@ After adding a new backend, verify:
- [ ] Backend directory structure is complete with all necessary files
- [ ] Build configurations added to `.github/backend-matrix.yml` for all desired platforms (per-arch entries with `platform-tag` for multi-arch; `builder-base-image` for llama-cpp / ik-llama-cpp / turboquant)
- [ ] **OS coverage considered**: added to `includeDarwin:` (macOS/Apple Silicon) if the backend can build there — with the `backend/index.yaml` `metal:` capability + `metal-<backend>` image entries, a `run.sh` Darwin/DYLD branch and `inferBackendPathDarwin` case for C++ backends — or the PR explains why an OS is unsupported. Do not ship Linux-only by default.
- [ ] **OS coverage considered**: added to `includeDarwin:` (macOS/Apple Silicon) if the backend can build there — with the `backend/index.yaml` `metal:` capability + `metal-<backend>` image entries, a `run.sh` Darwin/DYLD branch and `inferBackendPathDarwin` case (in `scripts/lib/backend-filter.mjs`) for C++ backends — or the PR explains why an OS is unsupported. Do not ship Linux-only by default.
- [ ] Meta definition added to `backend/index.yaml` in the `## metas` section
- [ ] Image entries added to `backend/index.yaml` for all build variants (latest + development)
- [ ] Tag suffixes match between workflow file and index.yaml
@@ -251,6 +316,8 @@ After adding a new backend, verify:
- [ ] No YAML syntax errors (check with linter)
- [ ] No Makefile syntax errors (check with linter)
- [ ] Follows the same pattern as similar backends (e.g., if it's a transcription backend, follow `faster-whisper` pattern)
- [ ] **`Load` validates its input and refuses models it can't serve.** When a model config has no explicit `backend:`, the model loader greedily probes *every* installed backend with the model's name and binds to the first `Load` that succeeds — an accept-anything `Load` will capture arbitrary LLMs (issue #9287). Backends that load a real artefact get this for free (the load fails); backends with no artefact must gate on the name: `opus` accepts only its own name (or none), `local-store` requires the `store.NamespacePrefix` namespace marker sent by `core/backend/stores.go`.
- [ ] **Gallery variant ranking considered**: if this backend should be preferred over another on some hardware, it is listed in `engineNamePreferenceRules` (NOT `backendBuildTagPreferenceRules`, NOT `servingFeaturePreferenceTokens`) in `pkg/system/capabilities.go`. A missing entry silently ranks it last and lets the next sort key decide.
- [ ] Documented: added to the category list in `docs/content/features/backends.md` (and any new endpoint/realtime capability documented under `docs/content/`)
- [ ] If it is an in-house native C/C++/GGML engine, added to the maintained-engines table in the top-level `README.md`

View File

@@ -91,6 +91,108 @@ To add a variant (e.g., different quantization), use YAML merge:
uri: huggingface://<gguf-org>/<gguf-repo>/<filename>-Q8_0.gguf
```
## Offering several builds of one model (`variants`)
When the same model is published in more than one quantization, or is also
servable by another engine, add each build as its own ordinary gallery entry and
then point one of them at the others with `variants`:
```yaml
- !!merge <<: *chatml
name: "nanbeige4.1-3b-q4"
# ... the usual urls / overrides / files for the Q4 build ...
variants:
- model: nanbeige4.1-3b-q8
```
Rules:
- The declaring entry is a **complete, normal entry**. It keeps its own
`files`/`overrides` and stays installable on every host and by every older
LocalAI release, which simply ignore `variants`.
- A variant references another gallery entry **by name**. That entry must exist
and must not declare `variants` of its own.
- **A referenced entry keeps its own gallery row by default.** It is hidden only
in the collapsed listing (`collapse_variants=true`, which the web UI requests
by default), where the declaring entry stands in for it. Searching there still
matches the referenced entry and answers with the entry declaring it, so
referencing an entry never makes it unfindable; turning the collapse off
returns it under its own name.
- **Order carries no meaning.** Do not try to encode a preference; write the
list in whatever order reads best.
- **A variant may be smaller than the declaring entry.** Offering a downgrade
for small hosts is a normal shape: the declaring entry's own build competes
like every other candidate, so a large host keeps the large build.
- **Do not describe hardware.** At install time LocalAI drops variants whose
backend cannot run on the host, then drops those that do not fit available
memory. The declaring entry's own build is exempt from both filters, so
selection always terminates on something installable. Sizes are measured live
from the weights and cached, so nothing has to be written down.
- **Engine preference outranks size.** Among the builds that survive the
filters, the host's preferred engine wins first and only then does the larger
footprint win. On NVIDIA a vLLM build beats a larger llama.cpp one; on Apple
silicon an MLX build beats a larger GGUF one; on a host with no preference for
either engine the larger build wins, since a bigger footprint is a higher
quality quantization of the same weights. Predict what a user gets by asking
which engine the host prefers before asking which build is biggest. The
per-capability order lives in `engineNamePreferenceRules`
(`pkg/system/capabilities.go`); see
[adding-backends.md](adding-backends.md) for how a backend gets into it.
- **Serving feature preference sits between engine and size.** Among builds on
an equally preferred engine, one that speculates or predicts several tokens
per step beats the plain build of the same weights, because it answers faster
for the same output: a `dflash` build beats an `mtp` one, and either beats a
plain build. The order lives in `servingFeaturePreferenceTokens`
(`pkg/system/capabilities.go`) and is matched against the entry's `tags:` and
**nothing else**: not the entry name, not `overrides.options`. See
[the tagging rule](#the-dflash--mtp-tagging-rule) below. Engine deliberately
outranks it: a serving feature makes the right engine faster, it does not make
a wrong engine right. Fit still outranks both, so a drafter pairing (strictly
larger than the plain build, since it ships a drafter alongside it) is dropped
on a host too small for it before this order is ever consulted.
- A variant is nothing but a name; there is no per-variant memory field. When
the measured size for a build is wrong, correct it on the referenced entry by
setting that entry's own `size:` (e.g. `size: "20GiB"`). The estimator prefers
a declared size over its own guesswork, so the fix applies everywhere the size
is shown or compared rather than only to variant selection.
Users can override the automatic choice with `variant` on `POST /models/apply`,
`local-ai models install --variant`, or the `install_model` MCP tool. See
`docs/content/features/model-gallery.md`.
The gallery lint specs live in `core/gallery`, so run that suite after adding a
`variants` list.
### The `dflash` / `mtp` tagging rule
**Tag an entry `dflash` or `mtp` when the entry actually configures that
feature. Variant ranking reads the tag and nothing else.**
Decide by looking at what the entry configures, in whatever vocabulary its
backend uses:
| Backend | Configures the feature when it declares |
|---------|------------------------------------------|
| `llama-cpp` | `overrides.options` contains `spec_type:draft-dflash` or `spec_type:draft-mtp` |
| `ds4` | `overrides.options` contains `mtp_path:` / `mtp_draft:` |
| `sglang` | the referenced `gallery/*.yaml` sets `speculative_algorithm:` |
That check is curation-time only. `spec_type` is llama.cpp's config vocabulary,
and a cross-backend ranking decision must not depend on one backend's option
syntax, which is precisely why the ranker reads the tag instead of the options.
Two mistakes the rule exists to prevent:
- **Weights that carry the heads are not an entry that enables them.** The
NVFP4 GGUF entries ship MTP-bearing weights but set only `use_jinja:true`, so
they enable no speculative decoding and must NOT be tagged. Tagging them wins
them the feature axis without being any faster.
- **A name is not a declaration.** An entry whose name spells `-mtp` while
configuring nothing gets no tag, and an entry that configures the feature is
tagged even when its name says nothing (`hy3`, `glm-5.2`). Ranking never reads
the name, so an untagged build that does enable the feature is simply ranked
as plain rather than promoted on a marker nobody meant.
## Available template configs
Look at existing `.yaml` files in `gallery/` to find the right prompt template for your model architecture:

View File

@@ -114,6 +114,24 @@ Both `backend.yml` (push) and `backend_pr.yml` (PR) generate their matrix dynami
- **Tag pushes**: `FORCE_ALL=true` is set from the workflow side (`startsWith(github.ref, 'refs/tags/')`) — releases rebuild every backend regardless of diff.
- **Schedule / `workflow_dispatch`**: no `event.before`, falls through to "run everything" automatically.
### Shared build inputs
The per-backend prefix match only sees files under a backend's own directory, so a change to shared build infrastructure would rebuild *nothing* — an empty matrix, every job green, and the change reaching no image. That silently un-shipped PR #10946 (a partial-cuDNN packaging fix in `scripts/build/package-gpu-libs.sh`), which merged 1h48m after the weekly cron and so sat unbuilt for a week.
`SHARED_BUILD_INPUTS` in `scripts/lib/backend-filter.mjs` closes that hole. Each rule maps a shared path to the narrowest set of matrix entries it can honestly invalidate, since a full matrix is 417 Linux + 56 Darwin builds:
| Changed path | Rebuilds |
|---|---|
| `backend/backend.proto` | everything (all languages compile or copy it) |
| `backend/Dockerfile.<x>` | the Linux entries whose `dockerfile:` names it |
| `backend/python/common/` | Python, Linux + Darwin |
| `scripts/build/package-gpu-libs.sh` | Python, Linux only |
| `scripts/build/<lang>-darwin.sh` | the Darwin entries that build target routes to |
| `.github/workflows/backend_build[_darwin].yml` | everything on that OS |
| anything else under `scripts/build/` (except `*_test.sh`) | everything — conservative default for unclassified packaging inputs |
Deliberately excluded: `backend/index.yaml` (gallery metadata, never enters an image), `.github/backend-matrix.yml` (adding a backend would rebuild all of them), `backend/Dockerfile.base-grpc-builder` (owned by `base-images.yml`), and the root `Makefile` (touched in ~11% of commits, and its backend-relevant edits arrive alongside the backend directory anyway). `make test-ci-scripts` pins all of this.
The Sunday 06:00 UTC cron on `backend.yml` exists specifically because path filtering can leave Python backends frozen on stale wheels. `DEPS_REFRESH` (below) only fires when the build actually runs, so an untouched Python backend would never re-resolve its unpinned deps. The weekly cron is the safety net.
## The `DEPS_REFRESH` cache-buster (Python backends)

View File

@@ -65,6 +65,7 @@ This is enforced by `forbidigo` (see `.golangci.yml`): `http.DefaultClient` and
The project documentation is located in `docs/content`. When adding new features or changing existing functionality, it is crucial to update the documentation to reflect these changes. This helps users understand how to use the new capabilities and ensures the documentation stays relevant.
- **Docs-with-code rule**: When you change user-facing behavior (API endpoints, CLI flags, config keys, or features), update the corresponding page under `docs/content/` in the SAME change, not as a follow-up. A user-facing change without a matching docs update is incomplete. The PR template carries a checklist item for this.
- **Feature Documentation**: If you add a new feature (like a new backend or API endpoint), create a new markdown file in `docs/content/features/` explaining what it is, how to configure it, and how to use it.
- **Configuration**: If you modify configuration options, update the relevant sections in `docs/content/`.
- **Examples**: providing concrete examples (like YAML configuration blocks) is highly encouraged to help users get started quickly.

39
.docker/bonsai-compile.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Shared compile logic for backend/Dockerfile.bonsai.
# Sourced (via bind mount) from both builder-fromsource and builder-prebuilt stages.
set -euxo pipefail
export CCACHE_DIR=/root/.ccache
ccache --max-size=5G || true
ccache -z || true
export CMAKE_ARGS="${CMAKE_ARGS:-} -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CUDA_COMPILER_LAUNCHER=ccache"
if [[ -n "${CUDA_DOCKER_ARCH:-}" ]]; then
CUDA_ARCH_ESC="${CUDA_DOCKER_ARCH//;/\\;}"
export CMAKE_ARGS="${CMAKE_ARGS} -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCH_ESC}"
echo "CMAKE_ARGS(env) = ${CMAKE_ARGS}"
rm -rf /LocalAI/backend/cpp/bonsai-*-build
fi
cd /LocalAI/backend/cpp/bonsai
if [ -z "${BUILD_TYPE:-}" ]; then
# Pure CPU image: one ggml CPU_ALL_VARIANTS build replaces the per-microarch binaries.
# arm64: the armv9.2 SME variants need gcc-14 (gcc-13 rejects +sme).
if [ "${TARGETARCH}" = "arm64" ]; then
apt-get update -qq && apt-get install -y -qq gcc-14 g++-14
export CC=gcc-14 CXX=g++-14
fi
make bonsai-cpu-all
else
# GPU build (cublas/hipblas/sycl/vulkan/...): single fallback CPU build, the accelerator
# does the compute. Keeps the GPU compile from also building the CPU variant matrix and
# avoids the gcc-14 apt step on GPU base images such as nvidia l4t.
make bonsai-fallback
fi
make bonsai-grpc
make bonsai-rpc-server
ccache -s || true

View File

@@ -7,6 +7,7 @@ This PR fixes #
**[Signed commits](../CONTRIBUTING.md#signing-off-on-commits-developer-certificate-of-origin)**
- [ ] Yes, I signed my commits.
- [ ] Documentation updated (docs/content/) for user-facing changes, or not applicable
<!--
Thank you for contributing to LocalAI!

View File

@@ -23,7 +23,7 @@
# checklist in .agents/adding-backends.md (includeDarwin entry, the index.yaml
# `metal:` capability + `metal-<backend>` image entries, a `run.sh` Darwin/DYLD
# branch for C/C++ backends, and the inferBackendPathDarwin case in
# scripts/changed-backends.js so the path filter actually builds it).
# scripts/lib/backend-filter.mjs so the path filter actually builds it).
# Linux matrix (consumed by backend-jobs).
include:
@@ -452,6 +452,22 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-12-amd64'
# bigger-runner: same rationale as -gpu-nvidia-cuda-12-llama-cpp above
# (observed 6h5m wall-clock on v4.2.1, just past the 6h job timeout).
runs-on: 'bigger-runner'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -478,6 +494,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-longcat-video'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "longcat-video"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -790,6 +819,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -816,6 +858,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-moss-tts-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1056,6 +1111,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-13-amd64'
# bigger-runner: observed 6h5m wall-clock on v4.2.1 — at the GHA timeout.
runs-on: 'bigger-runner'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1084,6 +1154,20 @@ include:
backend: "turboquant"
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-13-arm64'
base-image: "ubuntu:24.04"
runs-on: 'ubuntu-24.04-arm'
ubuntu-version: '2404'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1136,6 +1220,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-longcat-video'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "longcat-video"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1344,6 +1441,19 @@ include:
backend: "vllm-omni"
dockerfile: "./backend/Dockerfile.python"
context: "./"
- build-type: 'l4t'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-longcat-video'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
ubuntu-version: '2404'
backend: "longcat-video"
dockerfile: "./backend/Dockerfile.python"
context: "./"
- build-type: 'l4t'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1721,6 +1831,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1760,6 +1883,19 @@ include:
backend: "parakeet-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-moss-transcribe-cpp'
base-image: "ubuntu:24.04"
ubuntu-version: '2404'
runs-on: 'ubuntu-24.04-arm'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1786,6 +1922,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-moss-tts-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1838,6 +1987,19 @@ include:
backend: "qwen3-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-moss-tts-cpp'
base-image: "ubuntu:24.04"
ubuntu-version: '2404'
runs-on: 'ubuntu-24.04-arm'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1905,6 +2067,20 @@ include:
dockerfile: "./backend/Dockerfile.llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-rocm-amd64'
runs-on: 'ubuntu-latest'
base-image: "rocm/dev-ubuntu-24.04:7.2.1"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2169,6 +2345,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f32-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-intel-amd64'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2197,6 +2387,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f16-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-intel-amd64'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
- build-type: 'intel'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2649,6 +2853,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -2664,6 +2883,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-arm64'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -2806,6 +3040,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2204'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-arm64-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-l4t-cuda-12-arm64'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2204'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2852,6 +3100,22 @@ include:
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2868,6 +3132,22 @@ include:
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-bonsai'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-arm64'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "bonsai"
dockerfile: "./backend/Dockerfile.bonsai"
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -3597,6 +3877,115 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# moss-transcribe-cpp
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-moss-transcribe-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f32-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f16-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-moss-transcribe-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-arm64-moss-transcribe-cpp'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-moss-transcribe-cpp'
base-image: "rocm/dev-ubuntu-24.04:7.2.1"
runs-on: 'ubuntu-latest'
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# ced
- build-type: 'cublas'
cuda-major-version: "12"
@@ -4179,6 +4568,35 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# moss-tts-cpp
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-moss-tts-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-moss-tts-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# omnivoice-cpp
- build-type: ''
cuda-major-version: ""
@@ -4221,6 +4639,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f32-moss-tts-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
@@ -4247,6 +4678,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f16-moss-tts-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
@@ -4274,6 +4718,20 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-moss-tts-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
@@ -4302,6 +4760,20 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-moss-tts-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
@@ -4329,6 +4801,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-arm64-moss-tts-cpp'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
@@ -4355,6 +4840,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-moss-tts-cpp'
base-image: "rocm/dev-ubuntu-24.04:6.4.4"
runs-on: 'ubuntu-latest'
skip-drivers: 'false'
backend: "moss-tts-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
@@ -4652,7 +5150,6 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# rfdetr
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -4667,6 +5164,35 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# cloud-proxy
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-cloud-proxy'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "cloud-proxy"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-cloud-proxy'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "cloud-proxy"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# rfdetr
- build-type: ''
cuda-major-version: ""
@@ -5220,6 +5746,10 @@ includeDarwin:
tag-suffix: "-metal-darwin-arm64-parakeet-cpp"
build-type: "metal"
lang: "go"
- backend: "moss-transcribe-cpp"
tag-suffix: "-metal-darwin-arm64-moss-transcribe-cpp"
build-type: "metal"
lang: "go"
- backend: "ced"
tag-suffix: "-metal-darwin-arm64-ced"
build-type: "metal"
@@ -5240,6 +5770,10 @@ includeDarwin:
tag-suffix: "-metal-darwin-arm64-qwen3-tts-cpp"
build-type: "metal"
lang: "go"
- backend: "moss-tts-cpp"
tag-suffix: "-metal-darwin-arm64-moss-tts-cpp"
build-type: "metal"
lang: "go"
- backend: "omnivoice-cpp"
tag-suffix: "-metal-darwin-arm64-omnivoice-cpp"
build-type: "metal"
@@ -5365,6 +5899,10 @@ includeDarwin:
tag-suffix: "-metal-darwin-arm64-local-store"
build-type: "metal"
lang: "go"
- backend: "cloud-proxy"
tag-suffix: "-metal-darwin-arm64-cloud-proxy"
build-type: "metal"
lang: "go"
- backend: "llama-cpp-quantization"
tag-suffix: "-metal-darwin-arm64-llama-cpp-quantization"
build-type: "mps"

14
.github/bump_deps.sh vendored
View File

@@ -9,7 +9,19 @@ if [ -z "$FILE" ]; then
FILE="Makefile"
fi
LAST_COMMIT=$(curl -s -H "Accept: application/vnd.github.VERSION.sha" "https://api.github.com/repos/$REPO/commits/$BRANCH")
# -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 —
# either corrupting it silently or blowing up sed with an unterminated
# expression, which is how this job failed for a renamed repo.
if ! [[ "$LAST_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
echo "Refusing to bump $VAR: expected a 40-char commit SHA for $REPO@$BRANCH, got: $LAST_COMMIT" >&2
exit 1
fi
# Read $VAR from Makefile (only first match)
set +e

133
.github/ci/variantproposals/body.go vendored Normal file
View File

@@ -0,0 +1,133 @@
package main
import (
"fmt"
"strings"
)
// RenderBody writes the pull request body.
//
// The body is the product of this job, not the diff. Grouping is a judgement
// call that has gone wrong in both directions before, so a reviewer has to be
// able to accept or reject each family from the body alone, without opening
// HuggingFace to work out whether two entries hold the same weights.
func RenderBody(r *Result, ledgerPath string) string {
var b strings.Builder
b.WriteString("## Proposed gallery variant groupings\n\n")
b.WriteString("This is a proposal, not a decision. The gallery agent adds one build per model and never joins an existing family, so entries that are alternative builds of the same weights drift apart as the gallery grows. This job re-applies the grouping heuristics from the manual sweeps and asks a human to confirm.\n\n")
b.WriteString("Each family below lists the parent, the variants, and the evidence that they are the same weights. **Reject anything whose evidence you do not believe.**\n\n")
b.WriteString(fmt.Sprintf("To decline a family permanently, add one line to `%s` in this pull request and close it:\n\n", ledgerPath))
b.WriteString("```yaml\npairs:\n - {parent: some-model, variant: some-model-thing, reason: \"different finetune\"}\n```\n\n")
b.WriteString(fmt.Sprintf("### Proposed families (%d)\n\n", len(r.Families)))
if len(r.Families) == 0 {
b.WriteString("None.\n\n")
}
for _, f := range r.Families {
b.WriteString(fmt.Sprintf("#### `%s`\n\n", f.Parent))
b.WriteString("| variant | signals | evidence |\n|---|---|---|\n")
for _, p := range f.Proposals {
b.WriteString(fmt.Sprintf("| `%s` | %s | %s |\n", p.Variant, joinSignals(p.Evidence.Signals), describeEvidence(p.Evidence)))
}
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("### Declined by the ledger (%d)\n\n", len(r.Suppressed)))
if len(r.Suppressed) == 0 {
b.WriteString("Nothing the heuristics found was already on the ledger.\n\n")
} else {
b.WriteString("Candidates the heuristics found and the ledger has already settled. They are listed so the ledger's effect stays visible rather than silently shrinking the job's output.\n\n")
for _, s := range r.Suppressed {
b.WriteString(fmt.Sprintf("- `%s` + `%s`: %s\n", s.A, s.B, s.Reason))
}
b.WriteString("\n")
}
if len(r.AliasSkipped) > 0 {
b.WriteString(fmt.Sprintf("### Aliases, not variants (%d)\n\n", len(r.AliasSkipped)))
b.WriteString("These entries install byte for byte the same payload. An alias exists so clients can send a particular name; folding it under another entry would hide that name.\n\n")
for _, s := range r.AliasSkipped {
b.WriteString(fmt.Sprintf("- `%s` + `%s`: %s\n", s.A, s.B, s.Reason))
}
b.WriteString("\n")
}
if len(r.Refusals) > 0 {
b.WriteString(fmt.Sprintf("### Found but refused (%d)\n\n", len(r.Refusals)))
b.WriteString("Candidates the heuristics found but the authoring rules would not let this job write. They need a human edit or a rule change.\n\n")
for _, ref := range r.Refusals {
b.WriteString(fmt.Sprintf("- %s: %s\n", codeList(ref.Members), ref.Reason))
}
b.WriteString("\n")
}
b.WriteString("---\n\nOpened by `.github/ci/variantproposals`. Heuristics and the rejection ledger live there and in the ledger file; a wrong proposal is a bug in one of the two.\n")
return b.String()
}
func joinSignals(signals []Signal) string {
if len(signals) == 0 {
return "inferred through another member of the family"
}
out := make([]string, 0, len(signals))
for _, s := range signals {
out = append(out, "`"+string(s)+"`")
}
return strings.Join(out, ", ")
}
func describeEvidence(e Evidence) string {
var parts []string
if e.SharedStem != "" {
parts = append(parts, fmt.Sprintf("same name once quantization markers are stripped: `%s`", e.SharedStem))
}
if e.SharedFile != "" {
parts = append(parts, fmt.Sprintf("same primary weight filename once quantization markers are stripped: `%s`", e.SharedFile))
}
if e.SharedRepo != "" {
parts = append(parts, fmt.Sprintf("same upstream repo `%s`", e.SharedRepo))
}
if len(e.QuantTokens) > 0 {
parts = append(parts, "differing quantization tokens: `"+strings.Join(e.QuantTokens, "`, `")+"`")
}
if len(parts) == 0 {
return "reached this family through another member"
}
return strings.Join(parts, "; ")
}
func codeList(names []string) string {
out := make([]string, 0, len(names))
for _, n := range names {
out = append(out, "`"+n+"`")
}
return strings.Join(out, " + ")
}
// RenderSummary is the terminal-facing digest of a run, so the workflow log
// says what happened without anyone opening the pull request.
func RenderSummary(r *Result) string {
var b strings.Builder
fmt.Fprintf(&b, "families proposed: %d\n", len(r.Families))
for _, f := range r.Families {
names := make([]string, 0, len(f.Proposals))
for _, p := range f.Proposals {
names = append(names, p.Variant)
}
fmt.Fprintf(&b, " %s <- %s\n", f.Parent, strings.Join(names, ", "))
}
fmt.Fprintf(&b, "declined by ledger: %d\n", len(r.Suppressed))
for _, s := range r.Suppressed {
fmt.Fprintf(&b, " %s\n", s)
}
fmt.Fprintf(&b, "aliases skipped: %d\n", len(r.AliasSkipped))
for _, s := range r.AliasSkipped {
fmt.Fprintf(&b, " %s\n", s)
}
fmt.Fprintf(&b, "refused: %d\n", len(r.Refusals))
for _, ref := range r.Refusals {
fmt.Fprintf(&b, " %s: %s\n", strings.Join(ref.Members, " + "), ref.Reason)
}
return b.String()
}

120
.github/ci/variantproposals/edit.go vendored Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"fmt"
"regexp"
"sort"
"strings"
)
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 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()
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)
}
items := make([]string, 0, len(f.Proposals))
for _, p := range f.Proposals {
items = append(items, " - model: "+quoteName(p.Variant))
}
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})
}
// 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
}

153
.github/ci/variantproposals/edit_test.go vendored Normal file
View File

@@ -0,0 +1,153 @@
package main
import (
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ApplyFamilies", func() {
apply := func(ix *Index, families []Family) []string {
lines, err := ApplyFamilies(ix, families)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return lines
}
// insertedLines is what a reviewer would see in the diff. A textual editor
// that reflowed the file would show thousands here, which is the failure
// this whole approach exists to avoid.
insertedLines := func(before, after []string) int {
remaining := map[string]int{}
for _, l := range before {
remaining[l]++
}
n := 0
for _, l := range after {
if remaining[l] > 0 {
remaining[l]--
continue
}
n++
}
return n
}
It("adds a variants block right after the entry's name and touches nothing else", func() {
ix := indexOf(
entryYAML("foo-model", "acme/repo", "foo-model-Q4_K_M.gguf", "aa"),
entryYAML("foo-model-q8_0", "acme/repo", "foo-model-Q8_0.gguf", "bb"),
)
out := apply(ix, []Family{{Parent: "foo-model", Proposals: []Proposal{{Variant: "foo-model-q8_0"}}}})
Expect(out[0]).To(Equal("- name: foo-model"))
Expect(out[1]).To(Equal(" variants:"))
Expect(out[2]).To(Equal(" - model: foo-model-q8_0"))
Expect(len(out)).To(Equal(len(ix.Lines) + 2))
Expect(insertedLines(ix.Lines, out)).To(Equal(2))
})
It("appends to a variants block that already exists", func() {
ix := indexOf(`- name: partial
variants:
- model: partial-q8_0
url: u
overrides:
parameters:
model: partial-Q4_K_M.gguf
`, entryYAML("partial-f16", "acme/repo", "partial-f16.gguf", "cc"))
out := apply(ix, []Family{{Parent: "partial", Proposals: []Proposal{{Variant: "partial-f16"}}}})
Expect(out[1]).To(Equal(" variants:"))
Expect(out[2]).To(Equal(" - model: partial-q8_0"))
Expect(out[3]).To(Equal(" - model: partial-f16"))
Expect(out[4]).To(Equal(" url: u"))
})
It("replaces an explicit empty list rather than leaving two variants keys", func() {
ix := indexOf(`- name: emptied
variants: []
url: u
`, entryYAML("emptied-q8_0", "acme/repo", "emptied-Q8_0.gguf", "cc"))
out := apply(ix, []Family{{Parent: "emptied", Proposals: []Proposal{{Variant: "emptied-q8_0"}}}})
Expect(strings.Join(out[:4], "\n")).To(Equal("- name: emptied\n variants:\n - model: emptied-q8_0\n url: u"))
Expect(strings.Count(strings.Join(out, "\n"), "variants:")).To(Equal(1))
})
It("quotes a config-suffixed name so the reference stays a string", func() {
ix := indexOf(
entryYAML("phi-2-chat", "acme/repo", "phi-2-chat-Q4_K_M.gguf", "aa"),
entryYAML("phi-2-chat:Q8_0", "acme/repo", "phi-2-chat-Q8_0.gguf", "bb"),
)
out := apply(ix, []Family{{Parent: "phi-2-chat", Proposals: []Proposal{{Variant: "phi-2-chat:Q8_0"}}}})
Expect(out[2]).To(Equal(` - model: "phi-2-chat:Q8_0"`))
// The result has to still be a gallery, and the reference has to
// resolve to the entry it names.
reparsed, err := ParseIndex(strings.Join(out, "\n"))
Expect(err).ToNot(HaveOccurred())
Expect(reparsed.Entries[0].Variants).To(ConsistOf(VariantRef{Model: "phi-2-chat:Q8_0"}))
})
It("keeps line numbers correct when several entries are edited at once", func() {
ix := indexOf(
entryYAML("alpha", "acme/repo", "alpha-Q4_K_M.gguf", "aa"),
entryYAML("alpha-q8_0", "acme/repo", "alpha-Q8_0.gguf", "bb"),
entryYAML("beta", "acme/repo", "beta-Q4_K_M.gguf", "cc"),
entryYAML("beta-q8_0", "acme/repo", "beta-Q8_0.gguf", "dd"),
)
out := apply(ix, []Family{
{Parent: "alpha", Proposals: []Proposal{{Variant: "alpha-q8_0"}}},
{Parent: "beta", Proposals: []Proposal{{Variant: "beta-q8_0"}}},
})
reparsed, err := ParseIndex(strings.Join(out, "\n"))
Expect(err).ToNot(HaveOccurred())
Expect(reparsed.Entries).To(HaveLen(4))
Expect(reparsed.Entries[0].Variants).To(ConsistOf(VariantRef{Model: "alpha-q8_0"}))
Expect(reparsed.Entries[2].Variants).To(ConsistOf(VariantRef{Model: "beta-q8_0"}))
Expect(reparsed.Entries[1].Variants).To(BeEmpty())
Expect(reparsed.Entries[3].Variants).To(BeEmpty())
})
It("fails loudly rather than editing an entry it cannot find", func() {
ix := indexOf(entryYAML("only", "acme/repo", "only-Q4_K_M.gguf", "aa"))
_, err := ApplyFamilies(ix, []Family{{Parent: "missing", Proposals: []Proposal{{Variant: "x"}}}})
Expect(err).To(MatchError(ContainSubstring("not in the index")))
})
})
var _ = Describe("ParseIndex", func() {
It("records the anchor an entry defines and the anchor an entry merges", func() {
ix := indexOf(`- &anc
name: anchored
url: u
`, `- !!merge <<: *anc
name: child
`)
Expect(ix.Entries[0].AnchorName).To(Equal("anc"))
Expect(ix.Entries[1].MergesFrom).To(Equal("anc"))
Expect(ix.MergeChildren("anc")).To(HaveLen(1))
})
It("carries merged values into the child, so an inherited variants key is visible", func() {
ix := indexOf(`- &anc
name: anchored
url: u
variants:
- model: something
`, `- !!merge <<: *anc
name: child
`)
Expect(ix.Entries[1].HasVariants()).To(BeTrue())
})
It("refuses a list item that decodes to nothing", func() {
// Every line number the editor works from comes from pairing decoded
// entries with top level list items. If those two views can disagree,
// the editor writes into the wrong entry, so the parse refuses instead.
_, err := ParseIndex("- name: one\n url: u\n-\n")
Expect(err).To(MatchError(ContainSubstring("empty")))
})
})

286
.github/ci/variantproposals/index.go vendored Normal file
View File

@@ -0,0 +1,286 @@
package main
import (
"fmt"
"os"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// File is the subset of a gallery file entry the proposer reads.
type File struct {
Filename string `yaml:"filename"`
URI string `yaml:"uri"`
SHA256 string `yaml:"sha256"`
}
// VariantRef mirrors the gallery's variant reference.
type VariantRef struct {
Model string `yaml:"model"`
}
// GalleryEntry is one gallery entry, carrying both the semantics the heuristics need
// and the text range the editor needs.
//
// The two views are kept together deliberately. The editor must not round-trip
// the index through a YAML marshaller: the gallery is 40,000 lines and a
// reflowed diff cannot be reviewed, which defeats the entire point of a job
// whose output is a human decision.
type GalleryEntry struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
ConfigFile map[string]any `yaml:"config_file"`
Overrides map[string]any `yaml:"overrides"`
Files []File `yaml:"files"`
Variants []VariantRef `yaml:"variants"`
// Index is the entry's position in gallery order.
Index int `yaml:"-"`
// StartLine and EndLine bound the entry's lines, zero based and half open.
StartLine int `yaml:"-"`
EndLine int `yaml:"-"`
// AnchorName is set when the entry defines a YAML anchor. Adding a variants
// key to such an entry is inherited by everything that merges it, which is
// why proposals involving anchors get special treatment.
AnchorName string `yaml:"-"`
// MergesFrom is the anchor this entry pulls in with "!!merge <<:".
MergesFrom string `yaml:"-"`
}
// Index is a parsed gallery index: entries plus the exact lines they came from.
type Index struct {
Lines []string
Entries []*GalleryEntry
}
var (
entryStart = regexp.MustCompile(`^-(?: |$)`)
anchorStart = regexp.MustCompile(`^- &(\S+)`)
mergeStart = regexp.MustCompile(`^- !!merge <<: \*(\S+)`)
)
// LoadIndex reads and parses a gallery index file.
func LoadIndex(path string) (*Index, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseIndex(string(data))
}
// ParseIndex builds an Index from the raw text of a gallery index.
//
// The YAML decode and the textual scan are cross checked against each other: if
// they disagree on how many entries there are, every line number the editor
// would use is suspect, so the run fails rather than editing the wrong entry.
func ParseIndex(text string) (*Index, error) {
var entries []*GalleryEntry
if err := yaml.Unmarshal([]byte(text), &entries); err != nil {
return nil, fmt.Errorf("decoding gallery index: %w", err)
}
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))
}
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)
}
e.Index = i
e.StartLine = starts[i]
if i+1 < len(starts) {
e.EndLine = starts[i+1]
} else {
e.EndLine = len(lines)
}
if m := anchorStart.FindStringSubmatch(lines[e.StartLine]); m != nil {
e.AnchorName = m[1]
}
if m := mergeStart.FindStringSubmatch(lines[e.StartLine]); m != nil {
e.MergesFrom = m[1]
}
}
return &Index{Lines: lines, Entries: entries}, nil
}
// MergeChildren lists the entries that pull in the given anchor.
func (ix *Index) MergeChildren(anchor string) []*GalleryEntry {
var out []*GalleryEntry
for _, e := range ix.Entries {
if e.MergesFrom == anchor {
out = append(out, e)
}
}
return out
}
// ByName indexes entries by lowercased name. A name appearing twice keeps the
// first occurrence, matching the gallery's own first-match-wins resolution, and
// the duplicates are returned so the caller can refuse to touch them: a
// proposal naming an ambiguous entry cannot be reviewed.
func (ix *Index) ByName() (map[string]*GalleryEntry, map[string]int) {
byName := make(map[string]*GalleryEntry, len(ix.Entries))
counts := make(map[string]int, len(ix.Entries))
for _, e := range ix.Entries {
key := strings.ToLower(e.Name)
counts[key]++
if _, seen := byName[key]; !seen {
byName[key] = e
}
}
dupes := map[string]int{}
for name, n := range counts {
if n > 1 {
dupes[name] = n
}
}
return byName, dupes
}
// Installable reports whether installing this entry would put anything on disk.
// A variant target that installs nothing is a dead end for the selector, so it
// is never proposed as one.
func (e *GalleryEntry) Installable() bool {
return e.URL != "" || len(e.ConfigFile) > 0 || len(e.Overrides) > 0 || len(e.Files) > 0
}
// HasVariants reports whether the entry already offers builds of its own. Such
// an entry cannot be a variant target: nesting is what the gallery's own
// resolution refuses.
func (e *GalleryEntry) HasVariants() bool {
return len(e.Variants) > 0
}
// auxiliaryFile matches the shared side files that several unrelated models
// legitimately hand out the same copy of. Grouping on one of these is how an
// earlier sweep linked four wan-2.1 entries to each other and Z-Image-Turbo to
// qwen3-4b: they shared a text encoder, not weights.
var auxiliaryFile = regexp.MustCompile(`(?i)(mmproj|vae|clip|t5|umt5|text_?encoder|tokenizer|\bae\b|^ae\.|scheduler|config)`)
// IsAuxiliaryFile reports whether a filename is a side file rather than the
// model's own weights.
func IsAuxiliaryFile(filename string) bool {
base := filename
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
return auxiliaryFile.MatchString(base)
}
// PrimaryWeightFile returns the filename of the entry's own weights, and
// whether one could be identified unambiguously.
//
// The declared overrides.parameters.model wins because that is the file the
// backend is actually pointed at. Falling back to the file list only works when
// exactly one non-auxiliary file is present; anything else is ambiguous, and
// guessing is precisely the failure mode this heuristic has already had.
func (e *GalleryEntry) PrimaryWeightFile() (string, bool) {
if params, ok := e.Overrides["parameters"].(map[string]any); ok {
if model, ok := params["model"].(string); ok && model != "" && !IsAuxiliaryFile(model) {
return model, true
}
}
var candidates []string
for _, f := range e.Files {
if f.Filename == "" || IsAuxiliaryFile(f.Filename) {
continue
}
candidates = append(candidates, f.Filename)
}
if len(candidates) == 1 {
return candidates[0], true
}
return "", false
}
// SourceRepo returns the upstream repository the entry's files come from, as a
// coarse "host + owner + repo" key.
func (e *GalleryEntry) SourceRepo() string {
for _, f := range e.Files {
if f.URI == "" {
continue
}
return repoKey(f.URI)
}
return ""
}
func repoKey(uri string) string {
u := strings.ToLower(uri)
u = strings.TrimPrefix(u, "huggingface://")
u = strings.TrimPrefix(u, "https://huggingface.co/")
u = strings.TrimPrefix(u, "http://huggingface.co/")
parts := strings.Split(u, "/")
if len(parts) >= 2 {
return parts[0] + "/" + parts[1]
}
return u
}
// SameInstallPayload reports whether two entries install byte for byte the same
// thing.
//
// Entries like this are aliases, not variants. whisper-1 exists so a client
// speaking the OpenAI API can send that name and get whisper-base; folding it
// under whisper-base as a variant would hide the very name clients send.
func SameInstallPayload(a, b *GalleryEntry) bool {
if a.URL != b.URL {
return false
}
if !sameYAML(a.Overrides, b.Overrides) || !sameYAML(a.ConfigFile, b.ConfigFile) {
return false
}
return sameChecksums(a.Files, b.Files)
}
func sameChecksums(a, b []File) bool {
if len(a) != len(b) || len(a) == 0 {
return false
}
ha := make([]string, 0, len(a))
hb := make([]string, 0, len(b))
for _, f := range a {
if f.SHA256 == "" {
return false
}
ha = append(ha, f.SHA256)
}
for _, f := range b {
if f.SHA256 == "" {
return false
}
hb = append(hb, f.SHA256)
}
sort.Strings(ha)
sort.Strings(hb)
for i := range ha {
if ha[i] != hb[i] {
return false
}
}
return true
}
func sameYAML(a, b any) bool {
ba, err := yaml.Marshal(a)
if err != nil {
return false
}
bb, err := yaml.Marshal(b)
if err != nil {
return false
}
return string(ba) == string(bb)
}

180
.github/ci/variantproposals/ledger.go vendored Normal file
View File

@@ -0,0 +1,180 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// Ledger records the grouping decisions a human has already made against the
// proposer, so a declined candidate stays declined instead of coming back every
// night until reviewers stop reading the job's pull requests.
//
// It is checked in next to the gallery and is meant to be edited inside the
// proposal pull request itself: declining a family is adding one flow-mapping
// line under pairs or groups and closing the PR.
type Ledger struct {
// Tokens are name segments that mark a distinct model rather than another
// build of the same one: finetune names, language codes, product suffixes.
// A candidate whose two names differ by any of these is never proposed.
Tokens []LedgerToken `yaml:"tokens"`
// Pairs are individual candidates a human considered and declined. Order
// does not matter: the pair is matched both ways round.
Pairs []LedgerPair `yaml:"pairs"`
// Groups decline every pair drawn from a set at once, for families like a
// per-language release where listing each pair would be unreadable.
Groups []LedgerGroup `yaml:"groups"`
}
type LedgerToken struct {
Token string `yaml:"token"`
Reason string `yaml:"reason"`
}
type LedgerPair struct {
Parent string `yaml:"parent"`
Variant string `yaml:"variant"`
Reason string `yaml:"reason"`
}
type LedgerGroup struct {
Members []string `yaml:"members"`
Reason string `yaml:"reason"`
}
// LoadLedger reads a ledger file. A missing file is not an error: a gallery
// that has declined nothing yet is a legitimate state, and failing the job over
// it would only teach people to keep an empty file around.
func LoadLedger(path string) (*Ledger, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &Ledger{}, nil
}
if err != nil {
return nil, err
}
return ParseLedger(data)
}
func ParseLedger(data []byte) (*Ledger, error) {
l := &Ledger{}
if err := yaml.Unmarshal(data, l); err != nil {
return nil, fmt.Errorf("parsing ledger: %w", err)
}
for i, t := range l.Tokens {
if strings.TrimSpace(t.Token) == "" {
return nil, fmt.Errorf("ledger tokens[%d] has an empty token", i)
}
}
for i, p := range l.Pairs {
if strings.TrimSpace(p.Parent) == "" || strings.TrimSpace(p.Variant) == "" {
return nil, fmt.Errorf("ledger pairs[%d] needs both parent and variant", i)
}
}
return l, nil
}
// Suppression is a ledger hit: why a candidate was not proposed, in words a
// reviewer can check against the ledger file.
type Suppression struct {
A string
B string
Reason string
}
func (s Suppression) String() string {
return fmt.Sprintf("%s + %s: %s", s.A, s.B, s.Reason)
}
// Suppresses reports whether the ledger has already declined pairing these two
// entries, and why.
//
// The token rule is applied to the segments the two names do not share. Two
// builds of the same weights differ only in quantization markers, so any
// ledgered token showing up in that difference is by construction a claim that
// the entries are different models.
func (l *Ledger) Suppresses(a, b string) (Suppression, bool) {
la, lb := strings.ToLower(a), strings.ToLower(b)
for _, p := range l.Pairs {
lp, lv := strings.ToLower(p.Parent), strings.ToLower(p.Variant)
if (lp == la && lv == lb) || (lp == lb && lv == la) {
return Suppression{A: a, B: b, Reason: p.Reason}, true
}
}
for _, g := range l.Groups {
var seenA, seenB bool
for _, m := range g.Members {
lm := strings.ToLower(m)
if lm == la {
seenA = true
}
if lm == lb {
seenB = true
}
}
if seenA && seenB {
return Suppression{A: a, B: b, Reason: g.Reason}, true
}
}
diff := differingSegments(la, lb)
for _, t := range l.Tokens {
token := strings.ToLower(strings.TrimSpace(t.Token))
if _, ok := diff[token]; ok {
reason := t.Reason
if reason == "" {
reason = fmt.Sprintf("names differ by %q", token)
}
return Suppression{A: a, B: b, Reason: fmt.Sprintf("%s (token %q)", reason, token)}, true
}
}
return Suppression{}, false
}
// segments splits a name into the atoms the token rules are written against.
func segments(name string) []string {
fields := strings.FieldsFunc(strings.ToLower(name), func(r rune) bool {
return r == '-' || r == '_' || r == '.' || r == ':' || r == '/'
})
return fields
}
// differingSegments returns the set of segments present in exactly one of the
// two names.
func differingSegments(a, b string) map[string]struct{} {
setA := map[string]int{}
for _, s := range segments(a) {
setA[s]++
}
setB := map[string]int{}
for _, s := range segments(b) {
setB[s]++
}
diff := map[string]struct{}{}
for s := range setA {
if setB[s] == 0 {
diff[s] = struct{}{}
}
}
for s := range setB {
if setA[s] == 0 {
diff[s] = struct{}{}
}
}
return diff
}
// SortedSuppressions gives the ledger's effect on one run in a stable order, so
// the pull request body reads the same way for the same gallery.
func SortedSuppressions(in []Suppression) []Suppression {
out := append([]Suppression(nil), in...)
sort.Slice(out, func(i, j int) bool {
if out[i].A != out[j].A {
return out[i].A < out[j].A
}
return out[i].B < out[j].B
})
return out
}

65
.github/ci/variantproposals/main.go vendored Normal file
View File

@@ -0,0 +1,65 @@
// Command variant-proposals looks for gallery entries that are alternative
// builds of the same weights but are not grouped under one another, and writes
// a proposal for a human to accept or reject.
//
// It never decides. Grouping has gone wrong repeatedly in both directions, so
// the job's value is catching drift and surfacing candidates with their
// evidence, not automating the call. The scheduled workflow feeds its output to
// a pull request in the same shape as .github/checksum_checker.sh.
package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
index := flag.String("index", "gallery/index.yaml", "path to the gallery index")
ledger := flag.String("ledger", "gallery/variant-exclusions.yaml", "path to the rejection ledger")
bodyOut := flag.String("body-out", "", "write the pull request body here")
apply := flag.Bool("apply", false, "write the proposed groupings back into the index")
flag.Parse()
if err := run(*index, *ledger, *bodyOut, *apply); err != nil {
fmt.Fprintln(os.Stderr, "variant-proposals:", err)
os.Exit(1)
}
}
func run(indexPath, ledgerPath, bodyOut string, apply bool) error {
ix, err := LoadIndex(indexPath)
if err != nil {
return err
}
ledger, err := LoadLedger(ledgerPath)
if err != nil {
return err
}
result := Propose(ix, ledger)
fmt.Print(RenderSummary(result))
if !result.HasProposals() {
// An empty pull request every night is how a proposal job gets muted.
fmt.Println("nothing to propose")
return nil
}
if bodyOut != "" {
if err := os.WriteFile(bodyOut, []byte(RenderBody(result, ledgerPath)), 0o644); err != nil {
return err
}
}
if !apply {
return nil
}
lines, err := ApplyFamilies(ix, result.Families)
if err != nil {
return err
}
return os.WriteFile(indexPath, []byte(strings.Join(lines, "\n")), 0o644)
}

615
.github/ci/variantproposals/propose.go vendored Normal file
View File

@@ -0,0 +1,615 @@
package main
import (
"fmt"
"regexp"
"sort"
"strings"
)
// Signal names the grouping heuristic that linked two entries.
type Signal string
const (
// SignalName is "same name once quantization markers are stripped".
SignalName Signal = "name-modulo-quant"
// SignalConfigSuffix is the ":" convention, foo:q8_0 as a build of foo.
SignalConfigSuffix Signal = "config-suffix"
// SignalWeightFile is "same primary weight filename once quantization
// markers are stripped", auxiliary files excluded.
SignalWeightFile Signal = "weight-filename"
)
// Evidence is what a reviewer needs in order to agree or disagree without
// opening HuggingFace: what the two entries share, and what differs.
type Evidence struct {
Signals []Signal
SharedStem string
SharedFile string
SharedRepo string
QuantTokens []string
}
// Proposal is one variant target offered to one parent.
type Proposal struct {
Variant string
Evidence Evidence
}
// Family is a complete proposal: one parent gaining one or more variants.
type Family struct {
Parent string
Proposals []Proposal
}
// Refusal is a family the heuristics found but the rules would not let through.
// Refusals are reported rather than dropped: a candidate the job keeps refusing
// is either a rule worth revisiting or a gallery bug worth fixing.
type Refusal struct {
Members []string
Reason string
}
// Result is one run of the proposer.
type Result struct {
Families []Family
Refusals []Refusal
Suppressed []Suppression
AliasSkipped []Suppression
}
// HasProposals reports whether the run found anything to open a pull request
// about. A job that opens an empty pull request every night is a job people
// filter out of their inbox.
func (r *Result) HasProposals() bool {
return len(r.Families) > 0
}
// sizeToken matches a parameter-count marker: 8b, 1.7b, a3b for an active
// expert count, e2b for the Gemma effective sizes, 8x7b for a mixture.
//
// This is a structural rule rather than a ledger entry because it is about the
// shape of the token, not about any one model. Different parameter sizes were
// mis-grouped by an earlier sweep and the failure is systematic.
var sizeToken = regexp.MustCompile(`^(?:[0-9]+(?:\.[0-9]+)?[bm]|[ae][0-9]+(?:\.[0-9]+)?b|[0-9]+x[0-9]+(?:\.[0-9]+)?b)$`)
func differsByParameterSize(a, b string) (string, bool) {
for seg := range differingSegments(a, b) {
if sizeToken.MatchString(seg) {
return seg, true
}
}
return "", false
}
// genericFileStem lists weight filenames too generic to be evidence of
// anything. Two entries both shipping "model.safetensors" share a convention,
// not a set of weights.
var genericFileStem = map[string]struct{}{
"model": {}, "weights": {}, "pytorch_model": {}, "diffusion_pytorch_model": {},
"consolidated": {}, "ggml-model": {}, "model-00001-of-00002": {},
}
// minFileStemLength keeps short, collision-prone filename stems from linking
// unrelated entries.
const minFileStemLength = 6
type pair struct {
a, b int
evidence Evidence
}
// Propose runs the grouping heuristics over a gallery index and returns what it
// would offer a human, what it refused, and what the ledger silenced.
//
// Nothing here touches the network or git, and the index is not modified.
func Propose(ix *Index, ledger *Ledger) *Result {
if ledger == nil {
ledger = &Ledger{}
}
result := &Result{}
byName, dupes := ix.ByName()
// Existing relationships. A target already claimed must not be claimed
// again, and two entries already in one family need no proposal.
claimedBy := map[string]string{}
familyOf := map[string]string{}
for _, e := range ix.Entries {
if !e.HasVariants() {
continue
}
familyOf[strings.ToLower(e.Name)] = strings.ToLower(e.Name)
for _, v := range e.Variants {
target := strings.ToLower(v.Model)
if _, taken := claimedBy[target]; !taken {
claimedBy[target] = strings.ToLower(e.Name)
}
familyOf[target] = strings.ToLower(e.Name)
}
}
candidates := map[[2]int]*Evidence{}
addPair := func(i, j int, sig Signal, apply func(*Evidence)) {
if i == j {
return
}
if i > j {
i, j = j, i
}
key := [2]int{i, j}
ev, ok := candidates[key]
if !ok {
ev = &Evidence{}
candidates[key] = ev
}
for _, s := range ev.Signals {
if s == sig {
apply(ev)
return
}
}
ev.Signals = append(ev.Signals, sig)
apply(ev)
}
// Signal 1 and 2: entries sharing a name stem.
byStem := map[string][]int{}
for _, e := range ix.Entries {
if e.Name == "" {
continue
}
byStem[NameStem(e.Name)] = append(byStem[NameStem(e.Name)], e.Index)
}
for stem, members := range byStem {
if len(members) < 2 {
continue
}
for i := 0; i < len(members); i++ {
for j := i + 1; j < len(members); j++ {
a, b := ix.Entries[members[i]], ix.Entries[members[j]]
sig := SignalName
if HasConfigSuffix(a.Name) || HasConfigSuffix(b.Name) {
sig = SignalConfigSuffix
}
// The bare parent carries no marker in its name, so the
// evidence would read "differs by q8_0" and say nothing about
// what the parent is. The weight filenames fill that in.
fa, _ := a.PrimaryWeightFile()
fb, _ := b.PrimaryWeightFile()
addPair(members[i], members[j], sig, func(ev *Evidence) {
ev.SharedStem = stem
ev.QuantTokens = quantDifference(a.Name, b.Name, fa, fb)
})
}
}
}
// Signal 3: entries whose own weight file is the same file at a different
// quantization. Auxiliary files never take part.
byFile := map[string][]int{}
for _, e := range ix.Entries {
primary, ok := e.PrimaryWeightFile()
if !ok {
continue
}
stem := FileStem(primary)
if len(stem) < minFileStemLength {
continue
}
if _, generic := genericFileStem[stem]; generic {
continue
}
byFile[stem] = append(byFile[stem], e.Index)
}
for stem, members := range byFile {
if len(members) < 2 {
continue
}
for i := 0; i < len(members); i++ {
for j := i + 1; j < len(members); j++ {
a, b := ix.Entries[members[i]], ix.Entries[members[j]]
// The filename alone is not evidence. Publishers reuse the
// upstream filename for finetunes and for models that merely
// embed the base weights: bert-embeddings, an ultravox audio
// model and a roleplay finetune all ship a file called
// llama-3.2-1b-instruct-q4_k_m.gguf. Requiring the same
// upstream repository turns the signal back into what it
// claims to be, one repo publishing one file at two
// quantizations. Two repos holding the same weights is a fact
// no filename proves, so it stays a human call.
repo := a.SourceRepo()
if repo == "" || repo != b.SourceRepo() {
continue
}
fa, _ := a.PrimaryWeightFile()
fb, _ := b.PrimaryWeightFile()
addPair(members[i], members[j], SignalWeightFile, func(ev *Evidence) {
ev.SharedFile = stem
ev.SharedRepo = repo
if len(ev.QuantTokens) == 0 {
ev.QuantTokens = quantDifference(fa, fb)
}
})
}
}
}
// Filter candidates. Everything dropped here is dropped for a reason a
// reviewer can read back off the ledger or the rules.
var kept []pair
for key, ev := range candidates {
a, b := ix.Entries[key[0]], ix.Entries[key[1]]
la, lb := strings.ToLower(a.Name), strings.ToLower(b.Name)
if la == lb {
continue
}
if dupes[la] > 0 || dupes[lb] > 0 {
result.Refusals = append(result.Refusals, Refusal{
Members: []string{a.Name, b.Name},
Reason: "one of these names appears more than once in the gallery, so a variant reference to it is ambiguous",
})
continue
}
if fa, fb := familyOf[la], familyOf[lb]; fa != "" && fa == fb {
continue
}
if seg, differs := differsByParameterSize(la, lb); differs {
result.Suppressed = append(result.Suppressed, Suppression{
A: a.Name, B: b.Name, Reason: fmt.Sprintf("different parameter sizes (segment %q)", seg),
})
continue
}
if s, ok := ledger.Suppresses(a.Name, b.Name); ok {
result.Suppressed = append(result.Suppressed, s)
continue
}
if SameInstallPayload(a, b) {
result.AliasSkipped = append(result.AliasSkipped, Suppression{
A: a.Name, B: b.Name,
Reason: "identical install payload; these are aliases of one build, not alternative builds",
})
continue
}
kept = append(kept, pair{a: key[0], b: key[1], evidence: *ev})
}
sort.Slice(kept, func(i, j int) bool {
if kept[i].a != kept[j].a {
return kept[i].a < kept[j].a
}
return kept[i].b < kept[j].b
})
// Components. A pair from either signal joins the same family, so a chain
// of alternative builds discovered by different signals stays one family
// rather than two overlapping ones that would double claim a target.
parent := map[int]int{}
var find func(int) int
find = func(x int) int {
if p, ok := parent[x]; ok && p != x {
parent[x] = find(p)
return parent[x]
}
if _, ok := parent[x]; !ok {
parent[x] = x
}
return parent[x]
}
union := func(x, y int) {
rx, ry := find(x), find(y)
if rx != ry {
parent[ry] = rx
}
}
evidenceFor := map[[2]int]Evidence{}
for _, p := range kept {
union(p.a, p.b)
evidenceFor[[2]int{p.a, p.b}] = p.evidence
}
components := map[int][]int{}
for _, p := range kept {
for _, m := range []int{p.a, p.b} {
root := find(m)
if !contains(components[root], m) {
components[root] = append(components[root], m)
}
}
}
roots := make([]int, 0, len(components))
for r := range components {
roots = append(roots, r)
}
sort.Ints(roots)
proposedTargets := map[string]string{}
for _, root := range roots {
members := components[root]
sort.Ints(members)
family, refusal := buildFamily(ix, members, evidenceFor, claimedBy, proposedTargets, byName)
if refusal != nil {
result.Refusals = append(result.Refusals, *refusal)
continue
}
if family == nil {
continue
}
for _, p := range family.Proposals {
proposedTargets[strings.ToLower(p.Variant)] = family.Parent
}
result.Families = append(result.Families, *family)
}
sort.Slice(result.Families, func(i, j int) bool { return result.Families[i].Parent < result.Families[j].Parent })
result.Suppressed = SortedSuppressions(result.Suppressed)
result.AliasSkipped = SortedSuppressions(result.AliasSkipped)
result.Refusals = dedupeRefusals(result.Refusals)
return result
}
// dedupeRefusals collapses the same refusal reached from both orderings of a
// pair, and sorts what is left. A reviewer reading the same complaint twice
// learns to skim the section.
func dedupeRefusals(in []Refusal) []Refusal {
seen := map[string]struct{}{}
var out []Refusal
for _, r := range in {
members := append([]string(nil), r.Members...)
sort.Strings(members)
key := strings.Join(members, "\x00") + "\x00" + r.Reason
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool {
if a, b := strings.Join(out[i].Members, ","), strings.Join(out[j].Members, ","); a != b {
return a < b
}
return out[i].Reason < out[j].Reason
})
return out
}
func contains(xs []int, x int) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
// buildFamily turns a connected component into a proposal, or refuses it.
func buildFamily(ix *Index, members []int, evidenceFor map[[2]int]Evidence, claimedBy map[string]string, proposedTargets map[string]string, byName map[string]*GalleryEntry) (*Family, *Refusal) {
names := make([]string, 0, len(members))
for _, m := range members {
names = append(names, ix.Entries[m].Name)
}
parentIdx, err := selectParent(ix, members)
if err != nil {
return nil, &Refusal{Members: names, Reason: err.Error()}
}
parentEntry := ix.Entries[parentIdx]
parentName := strings.ToLower(parentEntry.Name)
// A parent that is itself somebody's variant would create a chain, which
// the gallery's own resolution refuses to install.
if owner, claimed := claimedBy[parentName]; claimed {
return nil, &Refusal{Members: names, Reason: fmt.Sprintf("the natural parent %q is already a variant of %q; proposing it as a parent would nest variants", parentEntry.Name, owner)}
}
if owner, claimed := proposedTargets[parentName]; claimed {
return nil, &Refusal{Members: names, Reason: fmt.Sprintf("the natural parent %q is already proposed as a variant of %q; proposing it as a parent would nest variants", parentEntry.Name, owner)}
}
// Adding a variants key to an anchor is inherited by every entry that
// merges it, silently grouping models nobody proposed. Handling that means
// editing each merging child too, which is a larger change than this job
// should make unsupervised, so it refuses and hands the reviewer the list.
if parentEntry.AnchorName != "" {
children := ix.MergeChildren(parentEntry.AnchorName)
if len(children) > 0 {
childNames := make([]string, 0, len(children))
for _, c := range children {
childNames = append(childNames, c.Name)
}
return nil, &Refusal{
Members: names,
Reason: fmt.Sprintf("the parent %q defines YAML anchor &%s, and a variants key added there is inherited by the %d entries that merge it (%s). Grouping this family by hand also means adding an explicit `variants: []` to each of those entries",
parentEntry.Name, parentEntry.AnchorName, len(children), strings.Join(childNames, ", ")),
}
}
}
existing := map[string]struct{}{}
for _, v := range parentEntry.Variants {
existing[strings.ToLower(v.Model)] = struct{}{}
}
family := &Family{Parent: parentEntry.Name}
for _, m := range members {
if m == parentIdx {
continue
}
target := ix.Entries[m]
lower := strings.ToLower(target.Name)
if _, already := existing[lower]; already {
continue
}
if target.HasVariants() {
return nil, &Refusal{Members: names, Reason: fmt.Sprintf("%q already offers variants of its own, so it cannot itself be a variant target", target.Name)}
}
if !target.Installable() {
return nil, &Refusal{Members: names, Reason: fmt.Sprintf("%q has no url, config_file, overrides or files, so it is not independently installable", target.Name)}
}
if owner, claimed := claimedBy[lower]; claimed && owner != parentName {
return nil, &Refusal{Members: names, Reason: fmt.Sprintf("%q is already a variant of %q; a target claimed by two parents is not something the gallery resolves predictably", target.Name, owner)}
}
if owner, claimed := proposedTargets[lower]; claimed && owner != parentEntry.Name {
return nil, &Refusal{Members: names, Reason: fmt.Sprintf("%q is already proposed as a variant of %q in this same run", target.Name, owner)}
}
family.Proposals = append(family.Proposals, Proposal{
Variant: target.Name,
Evidence: lookupEvidence(evidenceFor, parentIdx, m),
})
}
if len(family.Proposals) == 0 {
return nil, nil
}
sort.Slice(family.Proposals, func(i, j int) bool { return family.Proposals[i].Variant < family.Proposals[j].Variant })
return family, nil
}
func lookupEvidence(evidenceFor map[[2]int]Evidence, a, b int) Evidence {
if a > b {
a, b = b, a
}
if ev, ok := evidenceFor[[2]int{a, b}]; ok {
return ev
}
// The two entries reached the same family through a third one. Say so
// rather than inventing evidence that was never observed for this pair.
return Evidence{Signals: []Signal{SignalName}}
}
// selectParent picks the entry the others should hang off.
//
// The bare name wins when there is one: it is the name a user types and the one
// documentation links to. Otherwise the smallest build wins, judged by the
// quantization token in the entry's own weight filename, so the default install
// is the one most hosts can actually run.
func selectParent(ix *Index, members []int) (int, error) {
// The family's own stem: the one the most members reduce to, shortest name
// breaking a tie. An entry named exactly that is the bare entry.
stemCount := map[string]int{}
for _, m := range members {
stemCount[NameStem(ix.Entries[m].Name)]++
}
// Only a stem two or more members reduce to is the family's own stem. A
// stem reached by exactly one member is just that member's name, and
// treating it as the family stem would crown whichever name happens to be
// shortest rather than whichever build is the base one.
familyStem := ""
for stem, n := range stemCount {
if n < 2 {
continue
}
if familyStem == "" || n > stemCount[familyStem] ||
(n == stemCount[familyStem] && len(stem) < len(familyStem)) ||
(n == stemCount[familyStem] && len(stem) == len(familyStem) && stem < familyStem) {
familyStem = stem
}
}
var bare []int
for _, m := range members {
e := ix.Entries[m]
if HasConfigSuffix(e.Name) {
continue
}
if strings.ToLower(e.Name) == familyStem {
bare = append(bare, m)
}
}
if len(bare) == 1 {
return bare[0], nil
}
if len(bare) > 1 {
names := make([]string, 0, len(bare))
for _, m := range bare {
names = append(names, ix.Entries[m].Name)
}
return 0, fmt.Errorf("more than one entry is named exactly %q (%s), so which one is the base build is a judgement this job will not make", familyStem, strings.Join(names, ", "))
}
// No shared stem to be named after. An entry whose name every other member
// extends is still recognisably the base one, and this is the only handle
// left for families whose weights carry no readable quantization token at
// all, such as the ONNX builds.
if prefix, ok := uniquePrefixMember(ix, members); ok {
return prefix, nil
}
best := -1
bestWidth := 1 << 20
for _, m := range members {
e := ix.Entries[m]
width := unknownWidth
if primary, ok := e.PrimaryWeightFile(); ok {
width = BuildWidth(primary)
}
// Members are visited in gallery order, so a strict comparison leaves
// the earliest entry holding a tie and the choice is deterministic.
if width < bestWidth {
best, bestWidth = m, width
}
}
if best < 0 {
return 0, fmt.Errorf("no member could be identified as the smallest build")
}
if bestWidth == unknownWidth {
names := make([]string, 0, len(members))
for _, m := range members {
names = append(names, ix.Entries[m].Name)
}
return 0, fmt.Errorf("no member declares a weight file whose quantization can be read (%s), so the smallest build cannot be identified", strings.Join(names, ", "))
}
return best, nil
}
// uniquePrefixMember reports the single member whose name every other member's
// name starts with, if there is exactly one.
func uniquePrefixMember(ix *Index, members []int) (int, bool) {
found := -1
for _, m := range members {
name := strings.ToLower(ix.Entries[m].Name)
isPrefix := true
for _, other := range members {
if other == m {
continue
}
if !strings.HasPrefix(strings.ToLower(ix.Entries[other].Name), name) {
isPrefix = false
break
}
}
if !isPrefix {
continue
}
if found >= 0 {
return 0, false
}
found = m
}
return found, found >= 0
}
// quantDifference lists the quantization tokens that tell two names apart. It
// is the compact form of the evidence: "these differ only by q4_k_m vs q8_0".
func quantDifference(names ...string) []string {
var out []string
seen := map[string]struct{}{}
for _, name := range names {
// Filenames arrive here too, so the extension goes first and "/" counts
// as a separator. "_" deliberately does not: it holds "q4_k_m" together.
trimmed := weightExtension.ReplaceAllString(name, "")
for _, seg := range strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool { return r == '-' || r == '/' }) {
if !IsQuantToken(seg) {
continue
}
if _, ok := seen[seg]; ok {
continue
}
seen[seg] = struct{}{}
out = append(out, seg)
}
}
sort.Strings(out)
return out
}

View File

@@ -0,0 +1,429 @@
package main
import (
"fmt"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// entryYAML writes one gallery entry with a single weight file, which is the
// shape almost every real entry has. Specs that need something else write the
// YAML out by hand.
func entryYAML(name, repo, filename, sha string) string {
return fmt.Sprintf(`- name: %s
url: github:mudler/LocalAI/gallery/virtual.yaml@master
overrides:
parameters:
model: %s
files:
- filename: %s
uri: huggingface://%s/%s
sha256: %s
`, name, filename, filename, repo, filename, sha)
}
func indexOf(entries ...string) *Index {
ix, err := ParseIndex(strings.Join(entries, ""))
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return ix
}
// familyNames flattens a result into "parent <- variant, variant" strings, the
// form the specs assert against.
func familyNames(r *Result) []string {
out := make([]string, 0, len(r.Families))
for _, f := range r.Families {
names := make([]string, 0, len(f.Proposals))
for _, p := range f.Proposals {
names = append(names, p.Variant)
}
out = append(out, f.Parent+" <- "+strings.Join(names, ", "))
}
return out
}
func refusalReasons(r *Result) string {
var b strings.Builder
for _, ref := range r.Refusals {
b.WriteString(strings.Join(ref.Members, " + ") + ": " + ref.Reason + "\n")
}
return b.String()
}
func suppressionReasons(r *Result) string {
var b strings.Builder
for _, s := range r.Suppressed {
b.WriteString(s.String() + "\n")
}
return b.String()
}
var _ = Describe("Propose", func() {
Describe("the grouping signals", func() {
It("groups entries whose names differ only by a quantization marker", func() {
ix := indexOf(
entryYAML("foo-model", "acme/foo-GGUF", "foo-model-Q4_K_M.gguf", "aa"),
entryYAML("foo-model-q8_0", "acme/foo-GGUF", "foo-model-Q8_0.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(ConsistOf("foo-model <- foo-model-q8_0"))
Expect(r.Families[0].Proposals[0].Evidence.Signals).To(ContainElement(SignalName))
Expect(r.Families[0].Proposals[0].Evidence.SharedStem).To(Equal("foo-model"))
Expect(r.Families[0].Proposals[0].Evidence.QuantTokens).To(ContainElements("q4_k_m", "q8_0"))
})
It("groups entries that use the colon config-suffix convention", func() {
ix := indexOf(
entryYAML("bar-model", "acme/bar-GGUF", "bar-model-Q4_K_M.gguf", "aa"),
entryYAML("bar-model:grammar-functioncall", "acme/bar-GGUF", "bar-model-Q4_K_M-grammar.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(ConsistOf("bar-model <- bar-model:grammar-functioncall"))
Expect(r.Families[0].Proposals[0].Evidence.Signals).To(ContainElement(SignalConfigSuffix))
})
It("groups entries whose own weight file is the same file at another quantization", func() {
// The names share no stem, so only the filename signal can link
// these two.
ix := indexOf(
entryYAML("omni-cpp", "Serveurperso/Omni-GGUF", "omnivoice-base-Q8_0.gguf", "aa"),
entryYAML("omni-cpp-hq", "Serveurperso/Omni-GGUF", "omnivoice-base-BF16.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(ConsistOf("omni-cpp <- omni-cpp-hq"))
ev := r.Families[0].Proposals[0].Evidence
Expect(ev.Signals).To(ConsistOf(SignalWeightFile))
Expect(ev.SharedFile).To(Equal("omnivoice-base"))
Expect(ev.SharedRepo).To(Equal("serveurperso/omni-gguf"))
})
It("does not let a shared auxiliary file link unrelated models", func() {
// Both entries ship the same text encoder. That is a packaging
// convention, not evidence of shared weights: this is how an
// earlier sweep linked four wan-2.1 entries to each other.
ix := indexOf(`- name: wan-2.1-t2v
url: u
files:
- filename: wan-2.1-t2v-Q4_K_M.gguf
uri: huggingface://acme/wan/wan-2.1-t2v-Q4_K_M.gguf
sha256: aa
- filename: umt5-xxl-encoder-Q8_0.gguf
uri: huggingface://acme/wan/umt5-xxl-encoder-Q8_0.gguf
sha256: cc
`, `- name: z-image-turbo
url: u
files:
- filename: z-image-turbo-Q4_K_M.gguf
uri: huggingface://acme/wan/z-image-turbo-Q4_K_M.gguf
sha256: bb
- filename: umt5-xxl-encoder-Q8_0.gguf
uri: huggingface://acme/wan/umt5-xxl-encoder-Q8_0.gguf
sha256: cc
`)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
})
It("does not treat a shared filename in two different repos as evidence", func() {
// A finetune republished under the base model's filename is the
// most common way this signal misfires.
ix := indexOf(
entryYAML("llama-3.2-3b-instruct", "hugging-quants/Llama-3.2-3B-Instruct-GGUF", "llama-3.2-3b-instruct-q4_k_m.gguf", "aa"),
entryYAML("llama-3.2-3b-shiro-roleplay", "someone/Shiro-GGUF", "Llama-3.2-3B-Instruct.Q8_0.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
})
})
Describe("what must never be proposed", func() {
It("does not group different parameter sizes that share a prefix", func() {
ix := indexOf(
entryYAML("qwen3-tts-cpp-0.6b-base", "Serveurperso/Qwen3-TTS-GGUF", "qwen3-tts-talker-Q4_K_M.gguf", "aa"),
entryYAML("qwen3-tts-cpp-1.7b-base", "Serveurperso/Qwen3-TTS-GGUF", "qwen3-tts-talker-Q8_0.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(suppressionReasons(r)).To(ContainSubstring("different parameter sizes"))
})
It("does not group the Gemma effective sizes", func() {
ix := indexOf(
entryYAML("gemma-4-e2b-it", "google/gemma-GGUF", "gemma-4-it-Q4_K_M.gguf", "aa"),
entryYAML("gemma-4-e4b-it", "google/gemma-GGUF", "gemma-4-it-Q8_0.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(suppressionReasons(r)).To(ContainSubstring("different parameter sizes"))
})
It("does not group entries with a byte-identical install payload", func() {
// whisper-1 exists so OpenAI-compatible clients can send that name.
// Folding it under whisper-base would hide the name they send.
payload := ` url: github:mudler/LocalAI/gallery/whisper-base.yaml@master
overrides:
parameters:
model: ggml-whisper-base.bin
files:
- filename: ggml-whisper-base.bin
uri: huggingface://ggerganov/whisper.cpp/ggml-base.bin
sha256: aa
`
ix := indexOf("- name: whisper-base\n"+payload, "- name: whisper-1\n"+payload)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(r.AliasSkipped).To(HaveLen(1))
Expect(r.AliasSkipped[0].Reason).To(ContainSubstring("aliases"))
})
DescribeTable("declines the categories the ledger records",
func(nameA, nameB string, ledgerYAML string) {
ix := indexOf(
entryYAML(nameA, "acme/repo", "shared-weights-Q4_K_M.gguf", "aa"),
entryYAML(nameB, "acme/repo", "shared-weights-Q8_0.gguf", "bb"),
)
ledger, err := ParseLedger([]byte(ledgerYAML))
Expect(err).ToNot(HaveOccurred())
// Without the ledger these would be proposed, which is what
// makes the ledger load bearing rather than decorative.
Expect(familyNames(Propose(ix, nil))).ToNot(BeEmpty())
r := Propose(ix, ledger)
Expect(familyNames(r)).To(BeEmpty())
Expect(r.Suppressed).To(HaveLen(1))
},
Entry("a finetune", "base-model", "base-model-abliterated",
"tokens:\n - {token: abliterated, reason: finetune}\n"),
Entry("a distill", "base-model", "base-model-distilled",
"tokens:\n - {token: distilled, reason: distilled}\n"),
Entry("English-only versus multilingual ASR", "whisper-small", "whisper-small-en",
"pairs:\n - {parent: whisper-small, variant: whisper-small-en, reason: English-only versus multilingual}\n"),
Entry("two products sharing a prefix", "vibevoice-cpp", "vibevoice-cpp-asr",
"pairs:\n - {parent: vibevoice-cpp, variant: vibevoice-cpp-asr, reason: different products}\n"),
Entry("a per-language release", "kokoros-de", "kokoros-ja",
"groups:\n - {members: [kokoros, kokoros-de, kokoros-ja], reason: different languages}\n"),
)
It("reports the ledger's reason so its effect stays visible", func() {
ix := indexOf(
entryYAML("base-model", "acme/repo", "shared-weights-Q4_K_M.gguf", "aa"),
entryYAML("base-model-heretic", "acme/repo", "shared-weights-Q8_0.gguf", "bb"),
)
ledger, err := ParseLedger([]byte("tokens:\n - {token: heretic, reason: \"finetune, not a re-quantization\"}\n"))
Expect(err).ToNot(HaveOccurred())
r := Propose(ix, ledger)
Expect(suppressionReasons(r)).To(ContainSubstring("finetune, not a re-quantization"))
Expect(suppressionReasons(r)).To(ContainSubstring(`token "heretic"`))
})
})
Describe("parent selection", func() {
It("picks the bare-named entry when one exists", func() {
ix := indexOf(
entryYAML("base-model-q8_0", "acme/repo", "base-model-Q8_0.gguf", "aa"),
entryYAML("base-model", "acme/repo", "base-model-Q4_K_M.gguf", "bb"),
entryYAML("base-model-f16", "acme/repo", "base-model-f16.gguf", "cc"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(ConsistOf("base-model <- base-model-f16, base-model-q8_0"))
})
It("picks the smallest build when no entry is bare-named", func() {
ix := indexOf(
entryYAML("ced-base-f16", "acme/repo", "ced-base-f16.gguf", "aa"),
entryYAML("ced-base-q8", "acme/repo", "ced-base-Q8_0.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(ConsistOf("ced-base-q8 <- ced-base-f16"))
})
It("judges the smallest build by the quantization in the model filename, not the name", func() {
// The names carry no marker at all; only the filenames say which
// build is which.
ix := indexOf(
entryYAML("thing-hq", "acme/repo", "thing-weights-BF16.gguf", "aa"),
entryYAML("thing-lite", "acme/repo", "thing-weights-Q4_K_M.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(ConsistOf("thing-lite <- thing-hq"))
})
})
Describe("the rules a proposal has to respect", func() {
It("refuses to nest: a target that already offers variants of its own", func() {
ix := indexOf(
entryYAML("nest-model", "acme/repo", "nest-model-Q4_K_M.gguf", "aa"),
`- name: nest-model-q8_0
url: u
variants:
- model: nest-model-q8_0-mtp
overrides:
parameters:
model: nest-model-Q8_0.gguf
files:
- filename: nest-model-Q8_0.gguf
uri: huggingface://acme/repo/nest-model-Q8_0.gguf
sha256: bb
`,
entryYAML("nest-model-q8_0-mtp", "other/repo", "nest-model-mtp.gguf", "cc"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(refusalReasons(r)).To(ContainSubstring("already offers variants of its own"))
})
It("refuses to nest: a parent that is already somebody else's variant", func() {
ix := indexOf(
`- name: outer
url: u
variants:
- model: middle
overrides:
parameters:
model: outer-Q4_K_M.gguf
files:
- filename: outer-Q4_K_M.gguf
uri: huggingface://acme/repo/outer-Q4_K_M.gguf
sha256: aa
`,
entryYAML("middle", "acme/other", "middle-Q4_K_M.gguf", "bb"),
entryYAML("middle-q8_0", "acme/other", "middle-Q8_0.gguf", "cc"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(refusalReasons(r)).To(ContainSubstring("would nest variants"))
})
It("refuses to let two parents claim one target", func() {
ix := indexOf(
`- name: claimant
url: u
variants:
- model: contested-q8_0
overrides:
parameters:
model: claimant-Q4_K_M.gguf
files:
- filename: claimant-Q4_K_M.gguf
uri: huggingface://acme/repo/claimant-Q4_K_M.gguf
sha256: aa
`,
entryYAML("contested", "acme/other", "contested-Q4_K_M.gguf", "bb"),
entryYAML("contested-q8_0", "acme/other", "contested-Q8_0.gguf", "cc"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(refusalReasons(r)).To(ContainSubstring("already a variant of"))
})
It("refuses a target that is not independently installable", func() {
ix := indexOf(
entryYAML("stub-model", "acme/repo", "stub-model-Q4_K_M.gguf", "aa"),
"- name: stub-model-q8_0\n description: a stanza nobody finished\n",
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(refusalReasons(r)).To(ContainSubstring("not independently installable"))
})
It("refuses a family whose parent defines a merge anchor, naming the entries that would inherit", func() {
ix := indexOf(
`- &anchored
name: anchored-model
url: u
overrides:
parameters:
model: anchored-Q4_K_M.gguf
files:
- filename: anchored-Q4_K_M.gguf
uri: huggingface://acme/repo/anchored-Q4_K_M.gguf
sha256: aa
`,
`- !!merge <<: *anchored
name: anchored-child
variants: []
overrides:
parameters:
model: unrelated-child-Q4_K_M.gguf
files:
- filename: unrelated-child-Q4_K_M.gguf
uri: huggingface://other/repo/unrelated-child-Q4_K_M.gguf
sha256: cc
`,
entryYAML("anchored-model-q8_0", "acme/repo", "anchored-Q8_0.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(refusalReasons(r)).To(ContainSubstring("defines YAML anchor &anchored"))
Expect(refusalReasons(r)).To(ContainSubstring("anchored-child"))
Expect(refusalReasons(r)).To(ContainSubstring("variants: []"))
})
It("refuses an entry whose name is not unique in the gallery", func() {
ix := indexOf(
entryYAML("twin", "acme/repo", "twin-Q4_K_M.gguf", "aa"),
entryYAML("twin", "acme/repo", "twin-Q4_K_M.gguf", "aa"),
entryYAML("twin-q8_0", "acme/repo", "twin-Q8_0.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(BeEmpty())
Expect(refusalReasons(r)).To(ContainSubstring("appears more than once"))
})
It("says nothing about a pair that is already grouped", func() {
ix := indexOf(
`- name: settled
url: u
variants:
- model: settled-q8_0
overrides:
parameters:
model: settled-Q4_K_M.gguf
files:
- filename: settled-Q4_K_M.gguf
uri: huggingface://acme/repo/settled-Q4_K_M.gguf
sha256: aa
`,
entryYAML("settled-q8_0", "acme/repo", "settled-Q8_0.gguf", "bb"),
)
r := Propose(ix, nil)
Expect(r.HasProposals()).To(BeFalse())
Expect(r.Refusals).To(BeEmpty())
Expect(r.Suppressed).To(BeEmpty())
})
It("adds only the missing members to a family that already exists", func() {
ix := indexOf(
`- name: partial
url: u
variants:
- model: partial-q8_0
overrides:
parameters:
model: partial-Q4_K_M.gguf
files:
- filename: partial-Q4_K_M.gguf
uri: huggingface://acme/repo/partial-Q4_K_M.gguf
sha256: aa
`,
entryYAML("partial-q8_0", "acme/repo", "partial-Q8_0.gguf", "bb"),
entryYAML("partial-f16", "acme/repo", "partial-f16.gguf", "cc"),
)
r := Propose(ix, nil)
Expect(familyNames(r)).To(ConsistOf("partial <- partial-f16"))
})
})
It("does not modify the index it was given", func() {
text := entryYAML("foo-model", "acme/foo-GGUF", "foo-model-Q4_K_M.gguf", "aa") +
entryYAML("foo-model-q8_0", "acme/foo-GGUF", "foo-model-Q8_0.gguf", "bb")
ix, err := ParseIndex(text)
Expect(err).ToNot(HaveOccurred())
before := strings.Join(ix.Lines, "\n")
Propose(ix, nil)
Expect(strings.Join(ix.Lines, "\n")).To(Equal(before))
})
})

151
.github/ci/variantproposals/quant.go vendored Normal file
View File

@@ -0,0 +1,151 @@
package main
import (
"regexp"
"strconv"
"strings"
)
// Quantization and precision markers that distinguish one build of a set of
// weights from another build of the same weights. Stripping them from a name
// is what lets the proposer notice that two entries are the same model.
//
// qat and apex are in this list on a maintainer ruling: they are quantization
// techniques applied to published weights, not separate weights. Names that use
// "apex" to mean a finetune are handled by the rejection ledger instead, because
// no amount of pattern matching can tell the two uses apart.
const quantAlternation = `q[2-8](?:_[0-9a-z]+)*|pq[2-8](?:_[0-9a-z]+)*|iq[1-9][0-9a-z]*(?:_[0-9a-z]+)*|i1|` +
`f16|f32|bf16|fp16|fp32|fp8|fp4|nvfp4|mxfp4(?:_moe)*|awq|gptq|qat|apex|gguf|ggml|[0-9]+bit|g[0-9]+`
// quantSegment matches a whole hyphen-delimited segment of an entry name.
// Names separate their parts with "-" and keep quantization tokens internally
// joined with "_", so a segment is the right unit here: "q4_k_m" arrives whole.
var quantSegment = regexp.MustCompile(`^(?:` + quantAlternation + `)$`)
// quantFileSuffix matches a trailing quantization token in a weight filename.
// Filenames mix "-", "_" and "." as separators, so unlike entry names they
// cannot be split into segments up front without tearing "Q4_K_M" apart.
var quantFileSuffix = regexp.MustCompile(`(?i)[-_.](?:` + quantAlternation + `)$`)
var weightExtension = regexp.MustCompile(`(?i)\.(gguf|ggml|safetensors|bin|pt|pth|onnx)$`)
// IsQuantToken reports whether a single name segment is a quantization or
// precision marker rather than part of the model's identity.
func IsQuantToken(segment string) bool {
return quantSegment.MatchString(strings.ToLower(segment))
}
// NameStem reduces an entry name to the identity it shares with its alternative
// builds: the config suffix after ":" is dropped, then trailing quantization
// segments are stripped.
//
// It implements the first two grouping signals together because they answer the
// same question. "foo:q8_0" and "foo-q8_0" are both alternative builds of "foo",
// and the caller that needs to report which convention was used can compare the
// name against the stem itself.
//
// At least one segment always survives, so a name made entirely of quantization
// tokens does not collapse to the empty stem and swallow every other such name.
func NameStem(name string) string {
base := strings.ToLower(strings.TrimSpace(name))
if i := strings.Index(base, ":"); i >= 0 {
base = base[:i]
}
segments := strings.Split(base, "-")
for len(segments) > 1 && quantSegment.MatchString(segments[len(segments)-1]) {
segments = segments[:len(segments)-1]
}
return strings.Join(segments, "-")
}
// HasConfigSuffix reports whether a name uses the ":" convention for naming a
// config variant of another entry.
func HasConfigSuffix(name string) bool {
return strings.Contains(name, ":")
}
// FileStem reduces a weight filename to the identity shared by its other
// quantizations: directories, extension and trailing quantization tokens go.
//
// This is the third grouping signal. It is the one that has misfired before, so
// callers must filter auxiliary files out before handing a filename here: a
// shared text encoder is not evidence of shared weights.
func FileStem(filename string) string {
base := filename
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
base = weightExtension.ReplaceAllString(base, "")
for {
stripped := quantFileSuffix.ReplaceAllString(base, "")
if stripped == base {
break
}
base = stripped
}
return strings.ToLower(base)
}
// bitsPerWeight ranks quantization tokens so the smallest build of a family can
// be identified when no bare-named entry exists to be the parent.
//
// The figures are nominal bits per weight, not measured file sizes. Ranking is
// all that is asked of them, and a nominal figure is available from the name
// alone without downloading anything.
func bitsPerWeight(token string) (int, bool) {
t := strings.ToLower(token)
switch {
case t == "i1":
return 1, true
case strings.HasPrefix(t, "nvfp4"), strings.HasPrefix(t, "mxfp4"), t == "fp4":
return 4, true
case t == "fp8":
return 8, true
case t == "f16", t == "bf16", t == "fp16":
return 16, true
case t == "f32", t == "fp32":
return 32, true
case t == "awq", t == "gptq":
return 4, true
}
if m := regexp.MustCompile(`^p?q([1-9])`).FindStringSubmatch(t); m != nil {
n, _ := strconv.Atoi(m[1])
return n, true
}
if m := regexp.MustCompile(`^iq([1-9])`).FindStringSubmatch(t); m != nil {
n, _ := strconv.Atoi(m[1])
return n, true
}
if m := regexp.MustCompile(`^([0-9]+)bit$`).FindStringSubmatch(t); m != nil {
n, _ := strconv.Atoi(m[1])
return n, true
}
return 0, false
}
// unknownWidth sorts after every recognised quantization so an entry whose
// build cannot be read from its filename never wins the "smallest build" tie
// break by accident.
const unknownWidth = 1 << 10
// BuildWidth reports the nominal bits per weight of the build a filename holds.
// An unreadable filename gets unknownWidth.
func BuildWidth(filename string) int {
base := filename
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
base = weightExtension.ReplaceAllString(base, "")
best := unknownWidth
for {
m := quantFileSuffix.FindString(base)
if m == "" {
break
}
if bits, ok := bitsPerWeight(m[1:]); ok && bits < best {
best = bits
}
base = base[:len(base)-len(m)]
}
return best
}

View File

@@ -0,0 +1,92 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("quantization markers", func() {
DescribeTable("NameStem strips the markers that distinguish builds, not models",
func(name, expected string) {
Expect(NameStem(name)).To(Equal(expected))
},
Entry("plain q4", "foo-model-q4_k_m", "foo-model"),
Entry("q8_0", "foo-model-q8_0", "foo-model"),
Entry("q5_1", "foo-model-q5_1", "foo-model"),
Entry("q2 with group size", "ternary-bonsai-8b-q2-g64", "ternary-bonsai-8b"),
Entry("iq variant", "ideogram-4-iq4nl-ggml", "ideogram-4"),
Entry("i1 imatrix", "orca-agent-v0.1-i1", "orca-agent-v0.1"),
Entry("f16", "ced-base-f16", "ced-base"),
Entry("bf16", "some-model-bf16", "some-model"),
Entry("fp8", "some-model-fp8", "some-model"),
Entry("nvfp4", "qwen3.6-27b-nvfp4", "qwen3.6-27b"),
Entry("mxfp4_moe", "huihui-qwen3-vl-30b-a3b-instruct-abliterated-mxfp4_moe", "huihui-qwen3-vl-30b-a3b-instruct-abliterated"),
Entry("pq2", "ternary-bonsai-8b-pq2", "ternary-bonsai-8b"),
Entry("awq", "some-model-awq", "some-model"),
Entry("gptq", "some-model-gptq", "some-model"),
Entry("Nbit", "qwen3-8b-mlx-4bit", "qwen3-8b-mlx"),
Entry("gguf", "some-model-gguf", "some-model"),
Entry("ggml", "flux.1-dev-ggml", "flux.1-dev"),
Entry("qat is a quantization technique", "gemma-3-27b-it-qat", "gemma-3-27b-it"),
Entry("apex is a quantization technique", "qwen3.6-35b-a3b-apex", "qwen3.6-35b-a3b"),
Entry("stacked markers", "gemma-4-e2b-it-qat-q4_0", "gemma-4-e2b-it"),
Entry("the config suffix is dropped", "phi-2-chat:Q8_0", "phi-2-chat"),
Entry("a non-quant config suffix is dropped too", "meta-llama-3.1-8b-instruct:grammar-functioncall", "meta-llama-3.1-8b-instruct"),
)
DescribeTable("NameStem leaves alone what identifies a different model",
func(name, expected string) {
Expect(NameStem(name)).To(Equal(expected))
},
Entry("parameter size", "qwen3-tts-cpp-0.6b-base", "qwen3-tts-cpp-0.6b-base"),
Entry("language suffix", "kokoros-de", "kokoros-de"),
Entry("English-only ASR", "whisper-small-en", "whisper-small-en"),
Entry("finetune", "qwen3-30b-a3b-abliterated", "qwen3-30b-a3b-abliterated"),
Entry("product suffix", "vibevoice-cpp-asr", "vibevoice-cpp-asr"),
)
It("never strips a name down to nothing", func() {
Expect(NameStem("q4_k_m")).To(Equal("q4_k_m"))
Expect(NameStem("f16-q8_0")).To(Equal("f16"))
})
DescribeTable("FileStem reduces a weight filename to the weights it holds",
func(filename, expected string) {
Expect(FileStem(filename)).To(Equal(expected))
},
Entry("directory and extension go", "bonsai/models/Ternary-Bonsai-8B-gguf/Ternary-Bonsai-8B-Q2_0.gguf", "ternary-bonsai-8b"),
Entry("underscored quant token stays whole", "Llama-3.2-1B-Instruct-Q4_K_M.gguf", "llama-3.2-1b-instruct"),
Entry("dot separated quant token", "Llama-3.2-3B-Instruct.Q4_K_M.gguf", "llama-3.2-3b-instruct"),
Entry("group size suffix", "Ternary-Bonsai-8B-Q2_0_g64.gguf", "ternary-bonsai-8b"),
Entry("bf16", "omnivoice-cpp-hq/omnivoice-base-BF16.gguf", "omnivoice-base"),
Entry("safetensors", "some/dir/Model-Name-fp8.safetensors", "model-name"),
)
DescribeTable("BuildWidth reads the nominal width out of a filename",
func(filename string, expected int) {
Expect(BuildWidth(filename)).To(Equal(expected))
},
Entry("q4", "foo-Q4_K_M.gguf", 4),
Entry("q8", "foo-Q8_0.gguf", 8),
Entry("q2", "foo-Q2_0.gguf", 2),
Entry("f16", "foo-f16.gguf", 16),
Entry("bf16", "foo-BF16.gguf", 16),
Entry("iq3", "foo-iq3_xxs.gguf", 3),
Entry("nothing readable sorts last", "foo.gguf", unknownWidth),
)
It("treats an auxiliary file as never being the model's own weights", func() {
for _, f := range []string{
"mmproj-model-f16.gguf",
"dir/vae-BF16.gguf",
"clip_l.safetensors",
"umt5-xxl-encoder-Q8_0.gguf",
"t5xxl_fp16.safetensors",
"ae.safetensors",
"omnivoice-tokenizer-Q8_0.gguf",
} {
Expect(IsAuxiliaryFile(f)).To(BeTrue(), "expected %q to be auxiliary", f)
}
Expect(IsAuxiliaryFile("gemma-3-27b-it-Q4_K_M.gguf")).To(BeFalse())
})
})

View File

@@ -0,0 +1,13 @@
package main
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestVariantProposals(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "gallery variant proposals")
}

View File

@@ -45,6 +45,16 @@ updates:
directory: "/backend/python/diffusers"
schedule:
interval: "weekly"
# torch and transformers are deliberately pinned in this backend (see
# backend/python/diffusers/requirements-*.txt and issue #9979), and the
# l4t12 variant resolves them from the Jetson pip index
# (https://pypi.jetson-ai-lab.io/jp6/cu129/). dependabot cannot authenticate
# against that index and fails the whole weekly update with a
# private_source_authentication_failure. Ignore the two pinned deps we don't
# want bumped anyway so the job stays green.
ignore:
- dependency-name: "torch"
- dependency-name: "transformers"
- package-ecosystem: "pip"
directory: "/backend/python/exllama"
schedule:

View File

@@ -32,16 +32,30 @@ jobs:
if: github.repository == 'mudler/LocalAI'
runs-on: ubuntu-latest
outputs:
matrix-singlearch: ${{ steps.set-matrix.outputs['matrix-singlearch'] }}
matrix-multiarch: ${{ steps.set-matrix.outputs['matrix-multiarch'] }}
matrix-darwin: ${{ steps.set-matrix.outputs['matrix-darwin'] }}
merge-matrix-multiarch: ${{ steps.set-matrix.outputs['merge-matrix-multiarch'] }}
merge-matrix-singlearch: ${{ steps.set-matrix.outputs['merge-matrix-singlearch'] }}
has-backends-singlearch: ${{ steps.set-matrix.outputs['has-backends-singlearch'] }}
has-backends-multiarch: ${{ steps.set-matrix.outputs['has-backends-multiarch'] }}
has-backends-darwin: ${{ steps.set-matrix.outputs['has-backends-darwin'] }}
has-merges-multiarch: ${{ steps.set-matrix.outputs['has-merges-multiarch'] }}
has-merges-singlearch: ${{ steps.set-matrix.outputs['has-merges-singlearch'] }}
# Single-arch backends are sharded across SINGLEARCH_SHARDS matrix jobs to
# stay under GitHub's 256-jobs-per-matrix limit (see changed-backends.js).
matrix-singlearch-1: ${{ steps.set-matrix.outputs['matrix-singlearch-1'] }}
merge-matrix-singlearch-1: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-1'] }}
has-backends-singlearch-1: ${{ steps.set-matrix.outputs['has-backends-singlearch-1'] }}
has-merges-singlearch-1: ${{ steps.set-matrix.outputs['has-merges-singlearch-1'] }}
matrix-singlearch-2: ${{ steps.set-matrix.outputs['matrix-singlearch-2'] }}
merge-matrix-singlearch-2: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-2'] }}
has-backends-singlearch-2: ${{ steps.set-matrix.outputs['has-backends-singlearch-2'] }}
has-merges-singlearch-2: ${{ steps.set-matrix.outputs['has-merges-singlearch-2'] }}
matrix-singlearch-3: ${{ steps.set-matrix.outputs['matrix-singlearch-3'] }}
merge-matrix-singlearch-3: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-3'] }}
has-backends-singlearch-3: ${{ steps.set-matrix.outputs['has-backends-singlearch-3'] }}
has-merges-singlearch-3: ${{ steps.set-matrix.outputs['has-merges-singlearch-3'] }}
matrix-singlearch-4: ${{ steps.set-matrix.outputs['matrix-singlearch-4'] }}
merge-matrix-singlearch-4: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-4'] }}
has-backends-singlearch-4: ${{ steps.set-matrix.outputs['has-backends-singlearch-4'] }}
has-merges-singlearch-4: ${{ steps.set-matrix.outputs['has-merges-singlearch-4'] }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -109,9 +123,9 @@ jobs:
# take their full ~6h cold without blocking manifest assembly for the
# multi-arch backends whose per-arch digests would otherwise sit untagged
# on quay long enough to be GC'd.
backend-jobs-singlearch:
backend-jobs-singlearch-1:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch'] == 'true'
if: needs.generate-matrix.outputs['has-backends-singlearch-1'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
@@ -138,7 +152,100 @@ jobs:
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch']) }}
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-1']) }}
backend-jobs-singlearch-2:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-2'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-2']) }}
backend-jobs-singlearch-3:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-3'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-3']) }}
backend-jobs-singlearch-4:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-4'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-4']) }}
# Apply tags to per-arch digests via `imagetools create`. Split into two
# jobs that mirror the build split so each merge waits ONLY on its
@@ -174,10 +281,12 @@ jobs:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-multiarch']) }}
backend-merge-jobs-singlearch:
needs: [generate-matrix, backend-jobs-singlearch]
# See note on backend-merge-jobs-multiarch above for !cancelled().
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch'] == 'true' }}
# One merge shard per build shard: backend-merge-jobs-singlearch-<n> needs only
# backend-jobs-singlearch-<n>, preserving the "merge waits only on its own
# build" property while staying under the 256-jobs-per-matrix limit.
backend-merge-jobs-singlearch-1:
needs: [generate-matrix, backend-jobs-singlearch-1]
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch-1'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
@@ -189,7 +298,55 @@ jobs:
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch']) }}
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-1']) }}
backend-merge-jobs-singlearch-2:
needs: [generate-matrix, backend-jobs-singlearch-2]
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch-2'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-2']) }}
backend-merge-jobs-singlearch-3:
needs: [generate-matrix, backend-jobs-singlearch-3]
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch-3'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-3']) }}
backend-merge-jobs-singlearch-4:
needs: [generate-matrix, backend-jobs-singlearch-4]
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch-4'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-4']) }}
backend-jobs-darwin:
needs: generate-matrix

View File

@@ -11,16 +11,30 @@ jobs:
generate-matrix:
runs-on: ubuntu-latest
outputs:
matrix-singlearch: ${{ steps.set-matrix.outputs['matrix-singlearch'] }}
matrix-multiarch: ${{ steps.set-matrix.outputs['matrix-multiarch'] }}
matrix-darwin: ${{ steps.set-matrix.outputs['matrix-darwin'] }}
merge-matrix-multiarch: ${{ steps.set-matrix.outputs['merge-matrix-multiarch'] }}
merge-matrix-singlearch: ${{ steps.set-matrix.outputs['merge-matrix-singlearch'] }}
has-backends-singlearch: ${{ steps.set-matrix.outputs['has-backends-singlearch'] }}
has-backends-multiarch: ${{ steps.set-matrix.outputs['has-backends-multiarch'] }}
has-backends-darwin: ${{ steps.set-matrix.outputs['has-backends-darwin'] }}
has-merges-multiarch: ${{ steps.set-matrix.outputs['has-merges-multiarch'] }}
has-merges-singlearch: ${{ steps.set-matrix.outputs['has-merges-singlearch'] }}
# Single-arch backends are sharded across SINGLEARCH_SHARDS matrix jobs to
# stay under GitHub's 256-jobs-per-matrix limit (see changed-backends.js).
matrix-singlearch-1: ${{ steps.set-matrix.outputs['matrix-singlearch-1'] }}
merge-matrix-singlearch-1: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-1'] }}
has-backends-singlearch-1: ${{ steps.set-matrix.outputs['has-backends-singlearch-1'] }}
has-merges-singlearch-1: ${{ steps.set-matrix.outputs['has-merges-singlearch-1'] }}
matrix-singlearch-2: ${{ steps.set-matrix.outputs['matrix-singlearch-2'] }}
merge-matrix-singlearch-2: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-2'] }}
has-backends-singlearch-2: ${{ steps.set-matrix.outputs['has-backends-singlearch-2'] }}
has-merges-singlearch-2: ${{ steps.set-matrix.outputs['has-merges-singlearch-2'] }}
matrix-singlearch-3: ${{ steps.set-matrix.outputs['matrix-singlearch-3'] }}
merge-matrix-singlearch-3: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-3'] }}
has-backends-singlearch-3: ${{ steps.set-matrix.outputs['has-backends-singlearch-3'] }}
has-merges-singlearch-3: ${{ steps.set-matrix.outputs['has-merges-singlearch-3'] }}
matrix-singlearch-4: ${{ steps.set-matrix.outputs['matrix-singlearch-4'] }}
merge-matrix-singlearch-4: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-4'] }}
has-backends-singlearch-4: ${{ steps.set-matrix.outputs['has-backends-singlearch-4'] }}
has-merges-singlearch-4: ${{ steps.set-matrix.outputs['has-merges-singlearch-4'] }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -71,10 +85,10 @@ jobs:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-multiarch']) }}
backend-jobs-singlearch:
backend-jobs-singlearch-1:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-1'] == 'true'
uses: ./.github/workflows/backend_build.yml
if: needs.generate-matrix.outputs['has-backends-singlearch'] == 'true'
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
@@ -98,7 +112,94 @@ jobs:
strategy:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch']) }}
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-1']) }}
backend-jobs-singlearch-2:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-2'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-2']) }}
backend-jobs-singlearch-3:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-3'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-3']) }}
backend-jobs-singlearch-4:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-4'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-4']) }}
backend-merge-jobs-multiarch:
needs: [generate-matrix, backend-jobs-multiarch]
# backend_merge.yml's push-side steps are all gated on
@@ -118,9 +219,9 @@ jobs:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-multiarch']) }}
backend-merge-jobs-singlearch:
needs: [generate-matrix, backend-jobs-singlearch]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch'] == 'true' }}
backend-merge-jobs-singlearch-1:
needs: [generate-matrix, backend-jobs-singlearch-1]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch-1'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
@@ -130,7 +231,49 @@ jobs:
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch']) }}
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-1']) }}
backend-merge-jobs-singlearch-2:
needs: [generate-matrix, backend-jobs-singlearch-2]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch-2'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-2']) }}
backend-merge-jobs-singlearch-3:
needs: [generate-matrix, backend-jobs-singlearch-3]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch-3'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-3']) }}
backend-merge-jobs-singlearch-4:
needs: [generate-matrix, backend-jobs-singlearch-4]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch-4'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-4']) }}
backend-jobs-darwin:
needs: generate-matrix
uses: ./.github/workflows/backend_build_darwin.yml

View File

@@ -22,10 +22,18 @@ jobs:
variable: "TURBOQUANT_VERSION"
branch: "feature/turboquant-kv-cache"
file: "backend/cpp/turboquant/Makefile"
- repository: "PrismML-Eng/llama.cpp"
variable: "BONSAI_VERSION"
branch: "prism"
file: "backend/cpp/bonsai/Makefile"
- repository: "antirez/ds4"
variable: "DS4_VERSION"
branch: "main"
file: "backend/cpp/ds4/Makefile"
- repository: "meituan-longcat/LongCat-Video"
variable: "LONGCAT_VIDEO_VERSION"
branch: "main"
file: "backend/python/longcat-video/Makefile"
- repository: "localai-org/privacy-filter.cpp"
variable: "PRIVACY_FILTER_VERSION"
branch: "master"
@@ -42,11 +50,15 @@ jobs:
variable: "PARAKEET_VERSION"
branch: "master"
file: "backend/go/parakeet-cpp/Makefile"
- repository: "mudler/ced.cpp"
variable: "CED_VERSION"
- repository: "localai-org/moss-transcribe.cpp"
variable: "MOSS_VERSION"
branch: "master"
file: "backend/go/moss-transcribe-cpp/Makefile"
- repository: "localai-org/ced.cpp"
variable: "CED_VERSION"
branch: "main"
file: "backend/go/ced/Makefile"
- repository: "mudler/voice-detect.cpp"
- repository: "localai-org/voice-detect.cpp"
variable: "VOICEDETECT_VERSION"
branch: "master"
file: "backend/go/voice-detect/Makefile"
@@ -78,7 +90,7 @@ jobs:
variable: "SAM3_VERSION"
branch: "main"
file: "backend/go/sam3-cpp/Makefile"
- repository: "mudler/rf-detr.cpp"
- repository: "localai-org/rf-detr.cpp"
variable: "RFDETR_VERSION"
branch: "main"
file: "backend/go/rfdetr-cpp/Makefile"

View File

@@ -0,0 +1,54 @@
name: Propose gallery variant groupings
on:
schedule:
- cron: 0 4 * * 1
workflow_dispatch:
jobs:
variant_proposals:
if: github.repository == 'mudler/LocalAI'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
# The heuristics are the risky part of this job. A regression in them
# produces confident, wrong proposals, which is worse than no job at all.
- name: Test the proposer
run: go test ./.github/ci/variantproposals/
- name: Propose groupings 🔧
id: propose
run: |
rm -f /tmp/variant-proposals-body.md
go run ./.github/ci/variantproposals \
-index gallery/index.yaml \
-ledger gallery/variant-exclusions.yaml \
-body-out /tmp/variant-proposals-body.md \
-apply
if [ -s /tmp/variant-proposals-body.md ]; then
echo "have_proposals=true" >> "$GITHUB_OUTPUT"
{
echo 'body<<VARIANT_PROPOSAL_BODY_EOF'
cat /tmp/variant-proposals-body.md
echo VARIANT_PROPOSAL_BODY_EOF
} >> "$GITHUB_OUTPUT"
else
echo "have_proposals=false" >> "$GITHUB_OUTPUT"
fi
# No body file means the proposer found nothing. Opening an empty pull
# request every run is how a proposal job gets muted by its reviewers.
- name: Create Pull Request
if: steps.propose.outputs.have_proposals == 'true'
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.UPDATE_BOT_TOKEN }}
push-to-fork: ci-forks/LocalAI
commit-message: 'chore(model-gallery): propose variant groupings'
title: 'chore(model-gallery): propose variant groupings for review'
branch: "propose/variant-groupings"
body: ${{ steps.propose.outputs.body }}
signoff: true

View File

@@ -46,3 +46,23 @@ jobs:
touch core/http/react-ui/dist/index.html
- name: lint
run: make lint
build-scripts:
# The image packaging scripts encode invariants that only surface inside a
# container build (a missing transitive dep, a partial cuDNN family). Their
# shell tests need nothing but bash + gcc + ldd, so run them on every PR
# rather than waiting on a multi-GB cross-arch backend image build.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: run packaging script tests
run: make test-build-scripts
# The backend matrix path filter fails silently: a miss emits an empty
# matrix, every job goes green, and the change reaches no image (#10946).
# Its tests need only node, so they ride along with this job.
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: run CI script tests
run: make test-ci-scripts

View File

@@ -54,7 +54,7 @@ jobs:
go-version: ${{ matrix.go-version }}
cache: false
- name: Cache FizzBee
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: .tools/fizzbee
key: fizzbee-v0.5.2-${{ runner.os }}-${{ hashFiles('formal-verification/fizzbee.sha256') }}

View File

@@ -11,7 +11,7 @@ jobs:
if: github.repository == 'mudler/LocalAI'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v9
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v9
with:
stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
stale-pr-message: 'This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 10 days.'

View File

@@ -587,7 +587,7 @@ jobs:
with:
go-version: '1.25.4'
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Build sherpa-onnx backend image and run realtime e2e tests

View File

@@ -48,7 +48,7 @@ jobs:
sudo apt-get update
sudo apt-get install curl ffmpeg libopus-dev
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Build React UI
@@ -100,7 +100,7 @@ jobs:
brew install protobuf grpc make protoc-gen-go protoc-gen-go-grpc libomp llvm opus ffmpeg
pip install --user --no-cache-dir grpcio-tools grpcio
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Build React UI

View File

@@ -47,7 +47,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y build-essential libopus-dev
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Build React UI

View File

@@ -34,7 +34,7 @@ jobs:
go-version: ${{ matrix.go-version }}
cache: false
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Setup Bun

7
.gitignore vendored
View File

@@ -41,7 +41,12 @@ models/*
test-models/
test-dir/
tests/e2e-aio/backends
mock-backend
# The mock backend binary built by `make build-mock-backend`. Anchored to its
# full path: a bare `mock-backend` also matched the *directory* holding the
# source, so git would not descend into it and adding a file there needed -f.
# tests/e2e/mock-backend/.gitignore covers the same binary; kept here too so
# the artifact stays ignored if that scoped file is ever removed.
/tests/e2e/mock-backend/mock-backend
release/

View File

@@ -0,0 +1,6 @@
{
"files": ["core/http/react-ui/index.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}

View File

@@ -39,9 +39,10 @@ LocalAI follows the Linux kernel project's [guidelines for AI coding assistants]
- **Logging**: Use `github.com/mudler/xlog` (same API as slog)
- **Go style**: Prefer `any` over `interface{}`
- **Comments**: Explain *why*, not *what*
- **Docs**: Update `docs/content/` when adding features or changing config
- **Docs (docs-with-code rule)**: When you change user-facing behavior (API endpoints, CLI flags, config keys, or features), update the corresponding page under `docs/content/` in the SAME change, not as a follow-up. A user-facing change without a matching docs update is incomplete. See also the documentation conventions in [.agents/coding-style.md](.agents/coding-style.md).
- **New API endpoints**: LocalAI advertises its capability surface in several independent places — swagger `@Tags`, `/api/instructions` registry, auth `RouteFeatureRegistry`, React UI `capabilities.js`, docs. Read [.agents/api-endpoints-and-auth.md](.agents/api-endpoints-and-auth.md) and follow its checklist — missing any surface means clients, admins, and the UI won't know the endpoint exists.
- **Admin endpoints → MCP tool**: every admin endpoint that an admin would manage conversationally (install/list/edit/toggle/upgrade) MUST also be exposed as an MCP tool in `pkg/mcp/localaitools/`. The LocalAI Assistant chat modality and the standalone `local-ai mcp-server` consume that package; drift between REST and MCP is a real risk. Read [.agents/localai-assistant-mcp.md](.agents/localai-assistant-mcp.md) — the `TestToolHTTPRouteMappingComplete` test fails until you wire the new tool and update the route map.
- **Build**: Inspect `Makefile` and `.github/workflows/` — ask the user before running long builds
- **Backend OS coverage**: a new backend must target every OS it can build for, not just Linux. `.github/backend-matrix.yml` has two matrices — `include:` (Linux) and `includeDarwin:` (macOS / Apple Silicon). Most C/C++/GGML and many Python backends build on Darwin too — wire the `includeDarwin` entry + `backend/index.yaml` `metal:` entries, or say in the PR why an OS is unsupported. See the darwin checklist in [.agents/adding-backends.md](.agents/adding-backends.md).
- **Gallery variant ranking**: a gallery entry can declare `variants` (alternative builds of the same weights), and LocalAI ranks the ones a host can run by engine preference first, size second. A new backend that should be preferred on some hardware must be listed in `engineNamePreferenceRules` in `pkg/system/capabilities.go`; the sibling `backendBuildTagPreferenceRules` speaks build tags rather than engine names, and using the wrong table matches nothing without erroring. See [.agents/adding-backends.md](.agents/adding-backends.md).
- **UI**: The active UI is the React app in `core/http/react-ui/`. The older Alpine.js/HTML UI in `core/http/static/` is pending deprecation — all new UI work goes in the React UI

View File

@@ -12,12 +12,16 @@ ARG APT_MIRROR
ARG APT_PORTS_MIRROR
ENV DEBIAN_FRONTEND=noninteractive
# hwdata ships /usr/share/hwdata/pci.ids. Without it, the ghw library we use
# for hardware detection cannot resolve PCI vendor IDs and fails to enumerate
# GPUs at all, so the image reports "No GPU detected" (see issue #10941).
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
APT_MIRROR="${APT_MIRROR}" APT_PORTS_MIRROR="${APT_PORTS_MIRROR}" sh /usr/local/sbin/apt-mirror && \
apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl wget espeak-ng libgomp1 \
ffmpeg libopenblas0 libopenblas-dev libopus0 sox && \
ffmpeg libopenblas0 libopenblas-dev libopus0 sox \
hwdata && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -389,7 +393,12 @@ RUN go install github.com/mikefarah/yq/v4@latest
# If you cannot find a more suitable place for an addition, this layer is a suitable place for it.
FROM requirements-drivers
ENV HEALTHCHECK_ENDPOINT=http://localhost:8080/readyz
# Optional override for the HEALTHCHECK target. Left empty so healthcheck.sh
# derives the endpoint from the mode the container is actually running — the
# same image runs `local-ai run` (HTTP on 8080) and `local-ai worker` (HTTP on
# the gRPC base port minus one), and a hardcoded default marked every worker
# permanently unhealthy (#10987). Set it to pin an explicit URL.
ENV HEALTHCHECK_ENDPOINT=""
ARG CUDA_MAJOR_VERSION=12
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
@@ -399,6 +408,7 @@ ENV NVIDIA_VISIBLE_DEVICES=all
WORKDIR /
COPY ./entrypoint.sh .
COPY ./scripts/build/healthcheck.sh .
# Copy the binary
COPY --from=builder /build/local-ai ./
@@ -409,9 +419,22 @@ RUN --mount=from=builder,src=/build/,dst=/mnt/build \
# Make sure the models directory exists
RUN mkdir -p /models /backends /data
# Define the health check command
HEALTHCHECK --interval=1m --timeout=10m --retries=10 \
CMD curl -f ${HEALTHCHECK_ENDPOINT} || exit 1
# Define the health check command.
#
# --start-period is the knob for slow starts, not --timeout/--retries. Since
# #10949 a frontend's startup preload materializes HuggingFace artifacts before
# the HTTP server binds (31 GB observed on a live cluster), so a healthy replica
# can legitimately fail probes for a long time. Failures inside the start period
# leave the container `starting` instead of burning retries, and the period ends
# early on the first success — so a generous value costs a fast-starting
# container nothing. A process that actually died is handled by the restart
# policy, not by health.
#
# --timeout is a per-probe deadline: 10m meant a wedged probe could hang for ten
# minutes and stretch detection without bound. A localhost curl that has not
# answered in 10s is itself the fault being detected.
HEALTHCHECK --start-period=60m --interval=1m --timeout=10s --retries=3 \
CMD /healthcheck.sh
VOLUME /models /backends /configuration /data
EXPOSE 8080

View File

@@ -1,5 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/crispasr backends/parakeet-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin
GOCMD=go
GOTEST=$(GOCMD) test
@@ -103,7 +103,7 @@ COVERAGE_E2E_LABELS?=!real-models
COVERAGE_EXCLUDE_RE?=grpc/proto/.*[.]pb[.]go
.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-backend-cpp test-ui test-ui-coverage-baseline test-ui-coverage-check install-hooks build vendor lint lint-all
.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-backend-cpp test-build-scripts test-ui test-ui-coverage-baseline test-ui-coverage-check install-hooks build vendor lint lint-all
all: help
@@ -208,6 +208,20 @@ test: prepare-test
test-backend-cpp:
bash backend/cpp/run-unit-tests.sh
## Runs the shell-level regression tests for the image packaging scripts
## (scripts/build/*_test.sh). These guard invariants that only ever break
## inside a container build - a missing transitive dep, a partial cuDNN
## family - and that no Go test can observe. Needs only bash + gcc + ldd.
test-build-scripts:
@set -e; for t in scripts/build/*_test.sh; do echo "== $$t"; bash "$$t"; done
## Runs the unit tests for the CI helper scripts under scripts/lib/. Currently
## the backend matrix path filter, whose failure mode is invisible in CI: it
## emits an empty matrix, every job goes green, and the change ships to no
## image at all (see PR #10946). Plain `node --test`, no dependencies.
test-ci-scripts:
@set -e; for t in scripts/lib/*_test.mjs; do echo "== $$t"; node --test "$$t"; done
## Runs the core suite ($(TEST_PATHS)) with statement-coverage instrumentation
## and writes a merged profile to $(COVERAGE_PROFILE). Deliberately omits
## --fail-fast so a single failure doesn't truncate the coverage number, and
@@ -412,8 +426,13 @@ test-realtime: build-mock-backend
test-realtime-conformance:
GOCMD=$(GOCMD) ./scripts/realtime-conformance.sh
# Verify the shared model-loader shutdown behavior independently of any API
# modality (focused loader/gRPC/distributed/worker tests under -race + FizzBee).
test-model-lifecycle-conformance:
GOCMD=$(GOCMD) ./scripts/model-lifecycle-conformance.sh
# Install the pinned, checksum-verified FizzBee model checker (into .tools/,
# gitignored) used by test-realtime-conformance. Idempotent; no-op if present.
# gitignored) used by the conformance targets. Idempotent; no-op if present.
install-fizzbee:
./scripts/install-fizzbee.sh
@@ -565,6 +584,7 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/python/chatterbox
$(MAKE) -C backend/python/vllm
$(MAKE) -C backend/python/vllm-omni
$(MAKE) -C backend/python/longcat-video
$(MAKE) -C backend/python/sglang
$(MAKE) -C backend/python/vibevoice
$(MAKE) -C backend/python/liquid-audio
@@ -594,6 +614,7 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/python/chatterbox test
$(MAKE) -C backend/python/vllm test
$(MAKE) -C backend/python/vllm-omni test
$(MAKE) -C backend/python/longcat-video test
$(MAKE) -C backend/python/vibevoice test
$(MAKE) -C backend/python/liquid-audio test
$(MAKE) -C backend/python/moonshine test
@@ -645,6 +666,9 @@ test-extra: prepare-test-extra
## suite against it.
##
BACKEND_TEST_MODEL_URL?=https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q8_0.gguf
## Suite timeout for `go test`. Wrappers whose model download alone can eat
## most of the default (multi-GB models on a slow HF CDN day) override this.
BACKEND_TEST_TIMEOUT?=30m
## Generic target — runs the suite against whatever BACKEND_IMAGE points at.
## Depends on protogen-go so pkg/grpc/proto is generated before `go test`.
@@ -672,7 +696,7 @@ test-extra-backend: protogen-go
BACKEND_TEST_FACE_IMAGE_3_URL="$$BACKEND_TEST_FACE_IMAGE_3_URL" \
BACKEND_TEST_FACE_IMAGE_3_FILE="$$BACKEND_TEST_FACE_IMAGE_3_FILE" \
BACKEND_TEST_VERIFY_DISTANCE_CEILING="$$BACKEND_TEST_VERIFY_DISTANCE_CEILING" \
go test -v -timeout 30m ./tests/e2e-backends/...
go test -v -timeout $(BACKEND_TEST_TIMEOUT) ./tests/e2e-backends/...
## Convenience wrappers: build the image, then exercise it.
test-extra-backend-llama-cpp: docker-build-llama-cpp
@@ -695,6 +719,16 @@ test-extra-backend-turboquant: docker-build-turboquant
BACKEND_TEST_CACHE_TYPE_V=turbo3 \
$(MAKE) test-extra-backend
## bonsai: exercises the llama.cpp-fork backend with a real Q1_0 (1-bit) model —
## the PrismML Bonsai-8B GGUF, whose weight quant is *only* decodable by the fork's
## Q1_0 kernels. Loading it is what makes this backend distinct from stock llama-cpp;
## a standard-quant model would only test the upstream code path the llama-cpp backend
## already covers.
test-extra-backend-bonsai: docker-build-bonsai
BACKEND_IMAGE=local-ai-backend:bonsai \
BACKEND_TEST_MODEL_URL=https://huggingface.co/prism-ml/Bonsai-8B-gguf/resolve/main/Bonsai-8B-Q1_0.gguf \
$(MAKE) test-extra-backend
## Audio transcription wrapper for the llama-cpp backend.
## Drives the new AudioTranscription / AudioTranscriptionStream RPCs against
## ggml-org/Qwen3-ASR-0.6B-GGUF (a small ASR model that requires its mmproj
@@ -1012,6 +1046,7 @@ test-extra-backend-vibevoice-cpp-tts: docker-build-vibevoice-cpp
## post-image disk budget.
test-extra-backend-vibevoice-cpp-transcription: docker-build-vibevoice-cpp
BACKEND_IMAGE=local-ai-backend:vibevoice-cpp \
BACKEND_TEST_TIMEOUT=120m \
BACKEND_TEST_MODEL_URL='https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/vibevoice-asr-q4_k.gguf#vibevoice-asr-q4_k.gguf' \
BACKEND_TEST_EXTRA_FILES='https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/tokenizer.gguf#tokenizer.gguf' \
BACKEND_TEST_AUDIO_URL=https://github.com/ggml-org/whisper.cpp/raw/master/samples/jfk.wav \
@@ -1044,6 +1079,18 @@ test-extra-backend-parakeet-cpp-transcription: docker-build-parakeet-cpp
BACKEND_TEST_CAPS=health,load,transcription \
$(MAKE) test-extra-backend
## Audio transcription wrapper for the moss-transcribe-cpp (moss-transcribe.cpp
## ggml port) backend. Mirrors test-extra-backend-parakeet-cpp-transcription:
## drives the AudioTranscription RPC against a published MOSS GGUF using the JFK
## 11s clip from whisper.cpp's CI samples. Not part of the default test suite -
## run explicitly once the pinned model URL is reachable.
test-extra-backend-moss-transcribe-cpp-transcription: docker-build-moss-transcribe-cpp
BACKEND_IMAGE=local-ai-backend:moss-transcribe-cpp \
BACKEND_TEST_MODEL_URL=https://huggingface.co/mudler/moss-transcribe.cpp-gguf/resolve/main/moss-transcribe-q5_k.gguf \
BACKEND_TEST_AUDIO_URL=https://github.com/ggml-org/whisper.cpp/raw/master/samples/jfk.wav \
BACKEND_TEST_CAPS=health,load,transcription \
$(MAKE) test-extra-backend
## LocalVQE audio transform (joint AEC + noise suppression + dereverb).
## Exercises the audio_transform capability end-to-end: batch transform
## of a real WAV fixture and bidi streaming of synthetic silent frames.
@@ -1193,6 +1240,10 @@ BACKEND_IK_LLAMA_CPP = ik-llama-cpp|ik-llama-cpp|.|false|false
# turboquant is a llama.cpp fork with TurboQuant KV-cache quantization.
# Reuses backend/cpp/llama-cpp grpc-server sources via a thin wrapper Makefile.
BACKEND_TURBOQUANT = turboquant|turboquant|.|false|false
# bonsai is a llama.cpp fork (PrismML) adding the Q1_0 (1-bit) and Q2_0 (ternary)
# weight-quant kernels the Bonsai / Ternary-Bonsai models ship in. Reuses
# backend/cpp/llama-cpp grpc-server sources via a thin wrapper Makefile.
BACKEND_BONSAI = bonsai|bonsai|.|false|false
# ds4 is antirez/ds4, a DeepSeek V4 Flash-specific inference engine.
# Single-model; hardware-only validation lives at tests/e2e-backends/
# (BACKEND_BINARY mode); see docs/superpowers/plans/2026-05-11-ds4-backend.md.
@@ -1212,10 +1263,12 @@ BACKEND_STABLEDIFFUSION_GGML = stablediffusion-ggml|golang|.|--progress=plain|tr
BACKEND_WHISPER = whisper|golang|.|false|true
BACKEND_CRISPASR = crispasr|golang|.|false|true
BACKEND_PARAKEET_CPP = parakeet-cpp|golang|.|false|true
BACKEND_MOSS_TRANSCRIBE_CPP = moss-transcribe-cpp|golang|.|false|true
BACKEND_DEPTH_ANYTHING_CPP = depth-anything-cpp|golang|.|false|true
BACKEND_VOXTRAL = voxtral|golang|.|false|true
BACKEND_ACESTEP_CPP = acestep-cpp|golang|.|false|true
BACKEND_QWEN3_TTS_CPP = qwen3-tts-cpp|golang|.|false|true
BACKEND_MOSS_TTS_CPP = moss-tts-cpp|golang|.|false|true
BACKEND_OMNIVOICE_CPP = omnivoice-cpp|golang|.|false|true
BACKEND_VIBEVOICE_CPP = vibevoice-cpp|golang|.|false|true
BACKEND_LOCALVQE = localvqe|golang|.|false|true
@@ -1237,6 +1290,7 @@ BACKEND_NEUTTS = neutts|python|.|false|true
BACKEND_KOKORO = kokoro|python|.|false|true
BACKEND_VLLM = vllm|python|.|false|true
BACKEND_VLLM_OMNI = vllm-omni|python|.|false|true
BACKEND_LONGCAT_VIDEO = longcat-video|python|.|--progress=plain|true
BACKEND_SGLANG = sglang|python|.|false|true
BACKEND_DIFFUSERS = diffusers|python|.|--progress=plain|true
BACKEND_CHATTERBOX = chatterbox|python|.|false|true
@@ -1294,6 +1348,7 @@ endef
$(eval $(call generate-docker-build-target,$(BACKEND_LLAMA_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_IK_LLAMA_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_TURBOQUANT)))
$(eval $(call generate-docker-build-target,$(BACKEND_BONSAI)))
$(eval $(call generate-docker-build-target,$(BACKEND_DS4)))
$(eval $(call generate-docker-build-target,$(BACKEND_PRIVACY_FILTER)))
$(eval $(call generate-docker-build-target,$(BACKEND_PIPER)))
@@ -1305,6 +1360,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_STABLEDIFFUSION_GGML)))
$(eval $(call generate-docker-build-target,$(BACKEND_WHISPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_CRISPASR)))
$(eval $(call generate-docker-build-target,$(BACKEND_PARAKEET_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_MOSS_TRANSCRIBE_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_DEPTH_ANYTHING_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_VOXTRAL)))
$(eval $(call generate-docker-build-target,$(BACKEND_OPUS)))
@@ -1321,6 +1377,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_NEUTTS)))
$(eval $(call generate-docker-build-target,$(BACKEND_KOKORO)))
$(eval $(call generate-docker-build-target,$(BACKEND_VLLM)))
$(eval $(call generate-docker-build-target,$(BACKEND_VLLM_OMNI)))
$(eval $(call generate-docker-build-target,$(BACKEND_LONGCAT_VIDEO)))
$(eval $(call generate-docker-build-target,$(BACKEND_SGLANG)))
$(eval $(call generate-docker-build-target,$(BACKEND_DIFFUSERS)))
$(eval $(call generate-docker-build-target,$(BACKEND_CHATTERBOX)))
@@ -1338,6 +1395,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_WHISPERX)))
$(eval $(call generate-docker-build-target,$(BACKEND_ACE_STEP)))
$(eval $(call generate-docker-build-target,$(BACKEND_ACESTEP_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_QWEN3_TTS_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_MOSS_TTS_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_OMNIVOICE_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_VIBEVOICE_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_LOCALVQE)))
@@ -1357,7 +1415,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_SUPERTONIC)))
docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-privacy-filter
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-privacy-filter
########################################################
### Mock Backend for E2E Tests

View File

@@ -177,7 +177,7 @@ For more details, see the [Getting Started guide](https://localai.io/basics/gett
## Latest News
- **June 2026**: New native biometric backends from the LocalAI team: [voice-detect.cpp](https://github.com/mudler/voice-detect.cpp) for speaker recognition and voice analysis (ECAPA-TDNN, WeSpeaker, ERes2Net, CAM++, wav2vec2 age/gender/emotion) and [face-detect.cpp](https://github.com/mudler/face-detect.cpp) for face detection, recognition, demographics and anti-spoofing (SCRFD/ArcFace, YuNet/SFace). Both are from-scratch C++/ggml engines with no Python or onnxruntime at inference, self-contained GGUF weights, bit-exact parity with the reference, and GPU cuDNN parity, replacing the heavier Python `insightface` and `speaker-recognition` backends ([PR #10441](https://github.com/mudler/LocalAI/pull/10441)).
- **June 2026**: New native biometric backends from the LocalAI team: [voice-detect.cpp](https://github.com/localai-org/voice-detect.cpp) for speaker recognition and voice analysis (ECAPA-TDNN, WeSpeaker, ERes2Net, CAM++, wav2vec2 age/gender/emotion) and [face-detect.cpp](https://github.com/mudler/face-detect.cpp) for face detection, recognition, demographics and anti-spoofing (SCRFD/ArcFace, YuNet/SFace). Both are from-scratch C++/ggml engines with no Python or onnxruntime at inference, self-contained GGUF weights, bit-exact parity with the reference, and GPU cuDNN parity, replacing the heavier Python `insightface` and `speaker-recognition` backends ([PR #10441](https://github.com/mudler/LocalAI/pull/10441)).
- **June 2026**: New [realtime voice assistant demo](https://github.com/localai-org/localai-realtime-demo) (a tiny Go client for the Realtime API with a full talk-back voice loop and tool calling), plus [streaming of the realtime LLM / TTS / transcription pipeline stages](https://github.com/mudler/LocalAI/pull/10176) and [configurable WebRTC ICE candidates](https://github.com/mudler/LocalAI/pull/10231).
- **June 2026**: Big speech push: the [parakeet.cpp](https://github.com/mudler/parakeet.cpp) ASR engine gains [NeMo-faithful segment timestamps](https://github.com/mudler/LocalAI/pull/10207), a [multilingual streaming Nemotron-3.5 model](https://github.com/mudler/LocalAI/pull/10199), [dynamic batching for concurrent transcription](https://github.com/mudler/LocalAI/pull/10112) and [CUDA graphs](https://github.com/mudler/LocalAI/pull/10273); the new [CrispASR backend](https://github.com/mudler/LocalAI/pull/10099) adds multi-architecture ASR + TTS, and [60 Piper TTS voices across 42 languages](https://github.com/mudler/LocalAI/pull/10296) land in the gallery (plus [per-request TTS instructions and params](https://github.com/mudler/LocalAI/pull/10172)).
- **June 2026**: New backends and models: [locate-anything.cpp](https://github.com/mudler/LocalAI/pull/10264) for open-vocabulary object detection via ggml, [Ideogram4 image generation](https://github.com/mudler/LocalAI/pull/10201) in stablediffusion-ggml, [llama.cpp video input](https://github.com/mudler/LocalAI/pull/10216), and the [Gemma 4 QAT family with MTP speculative-decoding pairs](https://github.com/mudler/LocalAI/pull/10215). Plus an [interactive CLI chat mode](https://github.com/mudler/LocalAI/pull/10226) and [RAG source citations in agent responses](https://github.com/mudler/LocalAI/pull/10228).
@@ -232,12 +232,17 @@ Most backends wrap a best-in-class upstream engine. A handful of them are native
| Backend | What it does |
|---------|-------------|
| [parakeet.cpp](https://github.com/mudler/parakeet.cpp) | C++/GGML port of NVIDIA NeMo Parakeet ASR (tdt/ctc/rnnt/hybrid), with cache-aware streaming transcription |
| [ced.cpp](https://github.com/mudler/ced.cpp) | C++/GGML port of the CED audio-tagging models: sound-event classification (527-class AudioSet) over REST and the realtime API for live recognition |
| [voxtral.c](https://github.com/mudler/voxtral.c) | Voxtral Realtime 4B speech-to-text in pure C |
| [moss-transcribe.cpp](https://github.com/localai-org/moss-transcribe.cpp) | C++/GGML port of OpenMOSS MOSS-Transcribe-Diarize: joint long-form transcription, speaker diarization and timestamping in a single pass |
| [moss-tts.cpp](https://github.com/mudler/moss-tts.cpp) | C++/GGML port of the OpenMOSS MOSS-TTS family: text-to-speech (MOSS-TTS-Local v1.5, 48 kHz stereo) with reference-audio voice cloning, through the MOSS-Audio-Tokenizer neural codec |
| [ced.cpp](https://github.com/localai-org/ced.cpp) | C++/GGML port of the CED audio-tagging models: sound-event classification (527-class AudioSet) over REST and the realtime API for live recognition |
| [voice-detect.cpp](https://github.com/localai-org/voice-detect.cpp) | Speaker recognition and voice analysis (ECAPA-TDNN, WeSpeaker, ERes2Net, CAM++, wav2vec2 age/gender/emotion), replacing the Python speaker-recognition backend |
| [voxtral-tts.c](https://github.com/mudler/voxtral-tts.c) | Voxtral Realtime 4B speech-to-text in pure C |
| [vibevoice.cpp](https://github.com/mudler/vibevoice.cpp) | Native port of Microsoft VibeVoice for TTS (voice cloning) and long-form ASR with speaker diarization |
| [rf-detr.cpp](https://github.com/mudler/rf-detr.cpp) | Native RF-DETR object detection and instance segmentation |
| [rf-detr.cpp](https://github.com/localai-org/rf-detr.cpp) | Native RF-DETR object detection and instance segmentation |
| [locate-anything.cpp](https://github.com/mudler/locate-anything.cpp) | Open-vocabulary object detection and visual grounding (LocateAnything-3B) |
| [depth-anything.cpp](https://github.com/mudler/depth-anything.cpp) | Depth Anything 3 monocular metric depth + camera pose estimation |
| [face-detect.cpp](https://github.com/mudler/face-detect.cpp) | Face detection, recognition, demographics and anti-spoofing (SCRFD/ArcFace, YuNet/SFace), replacing the Python insightface backend |
| [free-splatter.cpp](https://github.com/localai-org/free-splatter.cpp) | Pose-free 3D reconstruction (FreeSplatter): turns a handful of plain photos into 3D Gaussians, no camera poses or GPU required |
| [privacy-filter.cpp](https://github.com/localai-org/privacy-filter.cpp) | Standalone GGML PII/NER token-classification engine powering LocalAI's PII redaction tier |
| [LocalVQE](https://github.com/localai-org/LocalVQE) | Joint acoustic echo cancellation, noise suppression, and dereverberation |
| [local-store](https://github.com/mudler/LocalAI) | Local-first vector database for embeddings (shipped in-tree) |

160
backend/Dockerfile.bonsai Normal file
View File

@@ -0,0 +1,160 @@
ARG BASE_IMAGE=ubuntu:24.04
# BUILDER_BASE_IMAGE defaults to BASE_IMAGE so the Dockerfile parses even
# when no prebuilt base is supplied. The builder-prebuilt stage is only
# entered when BUILDER_TARGET=builder-prebuilt, so a "wrong" fallback
# content here is harmless — BuildKit prunes the unreferenced builder.
ARG BUILDER_BASE_IMAGE=${BASE_IMAGE}
# BUILDER_TARGET selects which builder stage the final scratch image copies
# package output from. Declared at global scope (before any FROM) so it's
# usable in `FROM ${BUILDER_TARGET}` below. Default keeps local
# `make backends/bonsai` on the from-source path.
ARG BUILDER_TARGET=builder-fromsource
ARG APT_MIRROR=""
ARG APT_PORTS_MIRROR=""
# ============================================================================
# Stage: builder-fromsource — self-contained build path.
# Runs .docker/install-base-deps.sh (apt deps + cmake + protoc + gRPC +
# conditional CUDA/ROCm/Vulkan), copies /opt/grpc to /usr/local, then
# compiles the variant. Used when BUILDER_TARGET=builder-fromsource (the
# default; local `make backends/bonsai`).
#
# The install script is the same one that backend/Dockerfile.base-grpc-builder
# runs, so the result is bit-equivalent to the prebuilt-base path
# (builder-prebuilt below).
# ============================================================================
FROM ${BASE_IMAGE} AS builder-fromsource
ARG BUILD_TYPE
ARG CUDA_MAJOR_VERSION
ARG CUDA_MINOR_VERSION
ARG CMAKE_FROM_SOURCE=false
# CUDA Toolkit 13.x compatibility: CMake 3.31.9+ fixes toolchain detection/arch table issues
ARG CMAKE_VERSION=3.31.10
ARG GRPC_VERSION=v1.65.0
ARG GRPC_MAKEFLAGS="-j4 -Otarget"
ARG SKIP_DRIVERS=false
ARG TARGETARCH
ARG TARGETVARIANT
ARG GO_VERSION=1.25.4
ARG UBUNTU_VERSION=2404
ARG APT_MIRROR
ARG APT_PORTS_MIRROR
ARG AMDGPU_TARGETS=""
ARG BACKEND=rerankers
# CUDA target archs, e.g. --build-arg CUDA_DOCKER_ARCH='75;86;89;120'
ARG CUDA_DOCKER_ARCH
ARG CMAKE_ARGS
ENV BUILD_TYPE=${BUILD_TYPE} \
CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION} \
CUDA_MINOR_VERSION=${CUDA_MINOR_VERSION} \
CMAKE_FROM_SOURCE=${CMAKE_FROM_SOURCE} \
CMAKE_VERSION=${CMAKE_VERSION} \
GRPC_VERSION=${GRPC_VERSION} \
GRPC_MAKEFLAGS=${GRPC_MAKEFLAGS} \
SKIP_DRIVERS=${SKIP_DRIVERS} \
TARGETARCH=${TARGETARCH} \
UBUNTU_VERSION=${UBUNTU_VERSION} \
APT_MIRROR=${APT_MIRROR} \
APT_PORTS_MIRROR=${APT_PORTS_MIRROR} \
AMDGPU_TARGETS=${AMDGPU_TARGETS} \
CUDA_DOCKER_ARCH=${CUDA_DOCKER_ARCH} \
CMAKE_ARGS=${CMAKE_ARGS} \
DEBIAN_FRONTEND=noninteractive
# CUDA on PATH (no-op when CUDA isn't installed)
ENV PATH=/usr/local/cuda/bin:${PATH}
# HipBLAS / ROCm on PATH (no-op when ROCm isn't installed)
ENV PATH=/opt/rocm/bin:${PATH}
WORKDIR /build
# Install everything via the shared script — the same one that
# backend/Dockerfile.base-grpc-builder runs, so the prebuilt CI base and
# this from-source path are bit-equivalent.
RUN --mount=type=bind,source=.docker/install-base-deps.sh,target=/usr/local/sbin/install-base-deps \
--mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
bash /usr/local/sbin/install-base-deps
# Mirror builder-prebuilt: copy gRPC from /opt/grpc to /usr/local so
# CMake's find_package finds it at the canonical prefix the Makefile expects.
RUN cp -a /opt/grpc/. /usr/local/
COPY . /LocalAI
# BuildKit cache mount for ccache. See Dockerfile.llama-cpp (commit 9228e5b4)
# for rationale. bonsai is a llama.cpp fork that reuses
# backend/cpp/llama-cpp source via a thin wrapper Makefile, so MOST TUs
# are content-identical to the upstream llama-cpp build. Sharing a cache
# id with llama-cpp could give cross-fork hits — but for now keep them
# separate so a regression in one doesn't poison the other. Revisit
# sharing after measuring the actual hit rate.
#
# The compile body is shared with builder-prebuilt via .docker/bonsai-compile.sh.
RUN --mount=type=bind,source=.docker/bonsai-compile.sh,target=/usr/local/sbin/compile.sh \
--mount=type=cache,target=/root/.ccache,id=bonsai-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
bash /usr/local/sbin/compile.sh
# Copy libraries using a script to handle architecture differences
RUN make -BC /LocalAI/backend/cpp/bonsai package
# ============================================================================
# Stage: builder-prebuilt — uses the pre-built base from
# quay.io/go-skynet/ci-cache:base-grpc-* (built by .github/workflows/base-images.yml).
# That image already has gRPC at /opt/grpc + apt deps + CUDA/ROCm/Vulkan
# pre-installed, so we just copy gRPC to /usr/local and compile. Used when
# BUILDER_TARGET=builder-prebuilt (CI when the matrix entry sets
# builder-base-image).
# ============================================================================
FROM ${BUILDER_BASE_IMAGE} AS builder-prebuilt
ARG BUILD_TYPE
ENV BUILD_TYPE=${BUILD_TYPE}
ARG CUDA_DOCKER_ARCH
ENV CUDA_DOCKER_ARCH=${CUDA_DOCKER_ARCH}
ARG CMAKE_ARGS
ENV CMAKE_ARGS=${CMAKE_ARGS}
# AMDGPU_TARGETS must be forwarded into the env here too — backend/cpp/llama-cpp/Makefile
# (which the bonsai Makefile reuses via a sibling build dir) errors out when the var
# is empty on a hipblas build, and the prebuilt path is what CI exercises most of the
# time. The builder-fromsource stage above already does this; mirror it here.
ARG AMDGPU_TARGETS
ENV AMDGPU_TARGETS=${AMDGPU_TARGETS}
ARG TARGETARCH
ARG TARGETVARIANT
# The base-grpc-* image installs gRPC to /opt/grpc but doesn't copy it to
# /usr/local. Mirror what the from-source path does so the compile step
# can find gRPC at the canonical prefix the Makefile expects.
RUN cp -a /opt/grpc/. /usr/local/
COPY . /LocalAI
RUN --mount=type=bind,source=.docker/bonsai-compile.sh,target=/usr/local/sbin/compile.sh \
--mount=type=cache,target=/root/.ccache,id=bonsai-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
bash /usr/local/sbin/compile.sh
RUN make -BC /LocalAI/backend/cpp/bonsai package
# ============================================================================
# Final stage — copies package output from one of the two builders.
# BUILDER_TARGET selects which one. BuildKit prunes the unreferenced builder.
#
# BuildKit doesn't support variable expansion in `COPY --from=` directly,
# so we resolve the ARG by aliasing the chosen builder to a fixed stage
# name via `FROM ${BUILDER_TARGET} AS builder` and then COPY --from=builder.
# BUILDER_TARGET itself is declared as a global ARG at the top of this
# file (required for use in FROM), so we just re-import it into this
# stage's scope before the FROM directive.
# ============================================================================
FROM ${BUILDER_TARGET} AS builder
FROM scratch
# Copy all available binaries (the build process only creates the appropriate ones for the target architecture)
COPY --from=builder /LocalAI/backend/cpp/bonsai/package/. ./

View File

@@ -224,7 +224,11 @@ ARG DEPS_REFRESH=initial
RUN cd /${BACKEND} && PORTABLE_PYTHON=true make
# Package GPU libraries into the backend's lib directory
# Package GPU libraries into the backend's lib directory.
#
# Must stay after the venv is built above: package-gpu-libs.sh inspects
# /${BACKEND}/venv to decide whether this backend already carries a complete
# cuDNN from pip, and bundles one only when it does not (issue #10905).
RUN mkdir -p /${BACKEND}/lib && \
TARGET_LIB_DIR="/${BACKEND}/lib" BUILD_TYPE="${BUILD_TYPE}" CUDA_MAJOR_VERSION="${CUDA_MAJOR_VERSION}" \
bash /package-gpu-libs.sh "/${BACKEND}/lib"

View File

@@ -46,6 +46,7 @@ The backend system provides language-specific Dockerfiles that handle the build
- **vllm**: High-performance LLM inference
- **mlx**: Apple Silicon optimization
- **diffusers**: Stable Diffusion models
- **longcat-video**: CUDA text/image-to-video and speech-driven avatar generation
- **Audio**: coqui, faster-whisper, kitten-tts
- **Vision**: mlx-vlm, rfdetr
- **Specialized**: rerankers, chatterbox, kokoro

View File

@@ -136,6 +136,10 @@ message MetricsResponse {
message TokenClassifyRequest {
string text = 1;
float threshold = 2;
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 3;
}
// TokenClassifyEntity is one detected entity span. Byte offsets are
@@ -173,6 +177,10 @@ message ScoreRequest {
// candidates differ in length and the consumer wants a per-token
// measure comparable across them (PMI-style scoring).
bool length_normalize = 4;
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 5;
}
// CandidateScore is one row in the ScoreResponse, matching by index
@@ -204,6 +212,10 @@ message RerankRequest {
string query = 1;
repeated string documents = 2;
int32 top_n = 3;
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 4;
}
message RerankResult {
@@ -315,6 +327,39 @@ message PredictOptions {
int32 TopLogprobs = 51; // Number of top logprobs to return per token (maps to OpenAI top_logprobs parameter)
map<string, string> Metadata = 52; // Generic per-request metadata (e.g., enable_thinking)
float MinP = 53; // Minimum probability sampling threshold (0.0 = disabled)
// ModelIdentity names the model this request is for, so a backend can reject
// a request that reached it by mistake instead of answering from whatever
// model it happens to hold. In distributed mode a worker can recycle a
// stopped backend's gRPC port for a different model's backend, and a
// liveness-only health probe cannot tell that apart from a valid cached
// route (#10952).
//
// The value is the controller's ModelConfig.Model, the SAME expression that
// produces ModelOptions.Model at LoadModel time, so the two are equal by
// construction rather than by convention.
//
// Empty means "no identity supplied": backends MUST skip the check. That
// keeps an old controller talking to a new backend working, and covers
// callers that legitimately synthesize a PredictOptions internally.
//
// Do NOT reuse TTSRequest.model or SoundGenerationRequest.model for this
// purpose. FileStagingClient already rewrites those to worker-local absolute
// paths (core/services/nodes/file_staging_client.go), so in distributed mode
// they already differ from the load-time value and comparing them would
// reject valid requests. Extending identity to those RPCs needs a separate
// field carrying the untranslated value - which is exactly what
// TTSRequest.ModelIdentity and SoundGenerationRequest.ModelIdentity are.
//
// Every other request message that reaches a backend through the distributed
// router now carries the same ModelIdentity field, populated from the same
// ModelConfig.Model. FileStagingClient rewrites Src/Dst/Voice/Model/
// StartImage/EndImage/Audio and never ModelIdentity, so what the backend
// compares is always what the controller sent.
string ModelIdentity = 54;
// 24 was never assigned; reserve it so it is not silently reused.
reserved 24;
}
// ToolCallDelta represents an incremental tool call update from the C++ parser.
@@ -484,6 +529,10 @@ message TranscriptRequest {
float temperature = 8;
repeated string timestamp_granularities = 9;
bool stream = 10;
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 11;
}
message TranscriptResult {
@@ -562,6 +611,10 @@ message GenerateImageRequest {
// Reference images for models that support them (e.g., Flux Kontext)
repeated string ref_images = 12;
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 13;
}
message GenerateVideoRequest {
@@ -577,6 +630,14 @@ message GenerateVideoRequest {
float cfg_scale = 10; // Classifier-free guidance scale
int32 step = 11; // Number of inference steps
string dst = 12; // Output path for the generated video
string audio = 13; // Path to staged audio for audio-conditioned video
// Backend-specific per-request generation parameters. Values are strings
// and are validated/coerced by the selected backend.
map<string, string> params = 14;
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 15;
}
message TTSRequest {
@@ -594,10 +655,26 @@ message TTSRequest {
// (e.g. Chatterbox exaggeration/cfg_weight/temperature). Values are strings and
// coerced by the backend; unset leaves the backend's configured defaults.
map<string, string> params = 7;
// ModelIdentity is a SEPARATE field from `model` above and carries the
// UNTRANSLATED controller-side ModelConfig.Model, so a backend can reject a
// request that reached it through a stale distributed route (#10952).
//
// `model` cannot be reused for this: FileStagingClient.TTS/.TTSStream and the
// SoundGeneration path rewrite it into a worker-local absolute path
// (core/services/nodes/file_staging_client.go), while the load-time value is
// untranslated. In distributed mode - exactly the configuration this guards -
// the two already differ, so comparing them would reject valid requests.
//
// Empty means "no identity supplied" and backends MUST skip the check.
string ModelIdentity = 8;
}
message VADRequest {
repeated float audio = 1;
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 2;
}
message VADSegment {
@@ -629,6 +706,10 @@ message DiarizeRequest {
float min_duration_on = 8; // discard segments shorter than this (seconds); 0 = backend default
float min_duration_off = 9; // merge gaps shorter than this (seconds); 0 = backend default
bool include_text = 10; // when the backend can emit per-segment transcript for free, ask it to populate `text`
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 11;
}
message DiarizeSegment {
@@ -663,6 +744,18 @@ message SoundGenerationRequest {
optional string language = 14;
optional string timesignature = 15;
optional bool instrumental = 17;
// ModelIdentity is a SEPARATE field from `model` above and carries the
// UNTRANSLATED controller-side ModelConfig.Model, so a backend can reject a
// request that reached it through a stale distributed route (#10952).
//
// `model` cannot be reused for this: FileStagingClient.TTS/.TTSStream and the
// SoundGeneration path rewrite it into a worker-local absolute path
// (core/services/nodes/file_staging_client.go), while the load-time value is
// untranslated. In distributed mode - exactly the configuration this guards -
// the two already differ, so comparing them would reject valid requests.
//
// Empty means "no identity supplied" and backends MUST skip the check.
string ModelIdentity = 18;
}
message TokenizationResponse {
@@ -702,6 +795,10 @@ message DetectOptions {
repeated float points = 3; // Point coordinates as [x1, y1, label1, x2, y2, label2, ...] (label: 1=pos, 0=neg)
repeated float boxes = 4; // Box coordinates as [x1, y1, x2, y2, ...]
float threshold = 5; // Detection confidence threshold
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 6;
}
message Detection {
@@ -724,6 +821,10 @@ message SoundDetectionRequest {
string src = 1; // audio file path (LocalAI writes the upload to disk)
int32 top_k = 2; // number of top tags to return (0 = all classes)
float threshold = 3; // optional: drop tags scoring below this
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 4;
}
message SoundClass {
@@ -748,6 +849,10 @@ message DepthRequest {
bool include_points = 7; // back-project to a 3D point cloud (DualDPT)
float points_conf_thresh = 8; // keep points with confidence >= this threshold
repeated string exports = 9; // requested exports: "glb", "colmap"
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 10;
}
message DepthResponse {
@@ -779,6 +884,10 @@ message FaceVerifyRequest {
string img2 = 2; // base64-encoded image
float threshold = 3; // cosine-distance threshold; 0 = use backend default
bool anti_spoofing = 4; // run MiniFASNet liveness on each image; failed liveness forces verified=false
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 5;
}
message FaceVerifyResponse {
@@ -800,6 +909,10 @@ message FaceAnalyzeRequest {
string img = 1; // base64-encoded image
repeated string actions = 2; // subset of ["age","gender","emotion","race"]; empty = all-supported
bool anti_spoofing = 3;
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 4;
}
message FaceAnalysis {
@@ -832,6 +945,10 @@ message VoiceVerifyRequest {
string audio2 = 2; // path to second audio clip
float threshold = 3; // cosine-distance threshold; 0 = use backend default
bool anti_spoofing = 4; // reserved for future AASIST bolt-on
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 5;
}
message VoiceVerifyResponse {
@@ -846,6 +963,10 @@ message VoiceVerifyResponse {
message VoiceAnalyzeRequest {
string audio = 1; // path to audio clip
repeated string actions = 2; // subset of ["age","gender","emotion"]; empty = all-supported
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 3;
}
message VoiceAnalysis {
@@ -864,6 +985,10 @@ message VoiceAnalyzeResponse {
message VoiceEmbedRequest {
string audio = 1; // path to audio clip
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 2;
}
message VoiceEmbedResponse {
@@ -958,6 +1083,10 @@ message AudioTransformRequest {
string reference_path = 2; // optional auxiliary; empty => zero-fill
string dst = 3; // required, output file path
map<string, string> params = 4; // backend-specific tuning
// ModelIdentity names the model this request is for; see
// PredictOptions.ModelIdentity for the full rationale. Empty means "no
// identity supplied" and backends MUST skip the check.
string ModelIdentity = 5;
}
message AudioTransformResult {
@@ -1256,4 +1385,3 @@ message ForwardReply {
repeated ForwardHeader headers = 2;
bytes body_chunk = 3;
}

105
backend/cpp/bonsai/Makefile Normal file
View File

@@ -0,0 +1,105 @@
# 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?=9fcaed763ccda38ea81068ad9d7f991aaddca451
LLAMA_REPO?=https://github.com/PrismML-Eng/llama.cpp
CMAKE_ARGS?=
BUILD_TYPE?=
NATIVE?=false
ONEAPI_VARS?=/opt/intel/oneapi/setvars.sh
TARGET?=--target grpc-server
JOBS?=$(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
ARCH?=$(shell uname -m)
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
LLAMA_CPP_DIR := $(CURRENT_MAKEFILE_DIR)/../llama-cpp
GREEN := \033[0;32m
RESET := \033[0m
# bonsai is a llama.cpp fork (PrismML) adding the Q1_0 (1-bit) and Q2_0 (ternary)
# weight-quantization kernels that the Bonsai / Ternary-Bonsai models ship in. Rather
# than duplicating grpc-server.cpp / CMakeLists.txt / prepare.sh we reuse the ones in
# backend/cpp/llama-cpp, and only swap which repo+sha the fetch step pulls. Each flavor
# target copies ../llama-cpp into a sibling ../bonsai-<flavor>-build directory, then
# invokes llama-cpp's own build with LLAMA_REPO/LLAMA_VERSION overridden to point at the
# fork.
#
# The Q1_0/Q2_0 additions are model *weight* types decoded inside libllama, transparent
# to the reused gRPC server, so (unlike turboquant's KV-cache types) no grpc-server.cpp
# allow-list patch is needed. The fork branched from upstream before a few API changes
# the shared grpc-server.cpp depends on; those are carried as patch files under
# backend/cpp/bonsai/patches/ and applied to the cloned fork by apply-patches.sh.
PATCHES_DIR := $(CURRENT_MAKEFILE_DIR)/patches
define bonsai-build
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build
cp -rf $(LLAMA_CPP_DIR) $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build
# Drop patches vendored for upstream llama.cpp: the fork tree diverges, so
# they reject there. Fork-specific patches live in backend/cpp/bonsai/patches/
# and are applied by apply-patches.sh below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build purge
$(info $(GREEN)I bonsai build info:$(1)$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build llama.cpp
bash $(CURRENT_MAKEFILE_DIR)/apply-patches.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/llama.cpp $(PATCHES_DIR)
CMAKE_ARGS="$(CMAKE_ARGS) $(2)" TARGET="$(3)" \
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build grpc-server
cp -rfv $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server bonsai-$(1)
endef
bonsai-avx2:
$(call bonsai-build,avx2,-DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on,--target grpc-server)
bonsai-avx512:
$(call bonsai-build,avx512,-DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on,--target grpc-server)
bonsai-avx:
$(call bonsai-build,avx,-DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server)
bonsai-fallback:
$(call bonsai-build,fallback,-DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server)
# Single-build CPU backend via ggml CPU_ALL_VARIANTS (mirrors llama-cpp-cpu-all).
# bonsai reuses backend/cpp/llama-cpp's CMakeLists.txt (hw_grpc_proto STATIC) and
# Makefile (SHARED_LIBS make-var + EXTRA_CMAKE_ARGS), so this passes the same overrides
# through to the copied build: SHARED_LIBS=ON, the DL flags, and --target ggml (which
# pulls in the per-microarch libggml-cpu-*.so via ggml's add_dependencies). The .so set
# is collected for package.sh to bundle into package/lib.
bonsai-cpu-all:
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build
cp -rf $(LLAMA_CPP_DIR) $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build
# Drop patches vendored for upstream llama.cpp: the fork tree diverges, so
# they reject there. Fork-specific patches live in backend/cpp/bonsai/patches/
# and are applied by apply-patches.sh below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build purge
$(info $(GREEN)I bonsai build info:cpu-all-variants$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build llama.cpp
bash $(CURRENT_MAKEFILE_DIR)/apply-patches.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/llama.cpp $(PATCHES_DIR)
SHARED_LIBS=ON EXTRA_CMAKE_ARGS="-DGGML_BACKEND_DL=ON -DGGML_CPU_ALL_VARIANTS=ON" TARGET="--target grpc-server --target ggml" \
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build grpc-server
cp -rfv $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server bonsai-cpu-all
rm -rf ggml-shared-libs && mkdir -p ggml-shared-libs
find $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/llama.cpp/build \( -name '*.so*' -o -name '*.dylib' \) -exec cp -av {} ggml-shared-libs/ \;
@echo "Collected ggml shared backends:" && ls -la ggml-shared-libs/
bonsai-grpc:
$(call bonsai-build,grpc,-DGGML_RPC=ON -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server --target rpc-server)
bonsai-rpc-server: bonsai-grpc
cp -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-grpc-build/llama.cpp/build/bin/rpc-server bonsai-rpc-server
package:
bash package.sh
purge:
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-*-build
rm -rf bonsai-* package
clean: purge

View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Apply the bonsai patch series to a cloned PrismML llama.cpp (prism branch) checkout.
#
# The prism fork branched from upstream llama.cpp before a number of API changes that the
# shared backend/cpp/llama-cpp/grpc-server.cpp depends on. We carry those upstream commits
# as patch files under backend/cpp/bonsai/patches/ and apply them here so the reused
# grpc-server source compiles against the fork unmodified.
#
# Drop the corresponding patch from patches/ whenever the fork catches up with upstream —
# the build will fail fast if a patch stops applying, which is the signal to retire it.
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "usage: $0 <llama.cpp-src-dir> <patches-dir>" >&2
exit 2
fi
SRC_DIR=$1
PATCHES_DIR=$2
if [[ ! -d "$SRC_DIR" ]]; then
echo "source dir does not exist: $SRC_DIR" >&2
exit 2
fi
if [[ ! -d "$PATCHES_DIR" ]]; then
echo "no patches dir at $PATCHES_DIR, nothing to apply"
exit 0
fi
shopt -s nullglob
patches=("$PATCHES_DIR"/*.patch)
shopt -u nullglob
if [[ ${#patches[@]} -eq 0 ]]; then
echo "no .patch files in $PATCHES_DIR, nothing to apply"
exit 0
fi
cd "$SRC_DIR"
for patch in "${patches[@]}"; do
echo "==> applying $patch"
git apply --verbose "$patch"
done
echo "all bonsai patches applied successfully"

66
backend/cpp/bonsai/package.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# Script to copy the appropriate libraries based on architecture
# This script is used in the final stage of the Dockerfile
set -e
CURDIR=$(dirname "$(realpath $0)")
REPO_ROOT="${CURDIR}/../../.."
# Create lib directory
mkdir -p $CURDIR/package/lib
cp -avrf $CURDIR/bonsai-* $CURDIR/package/
cp -rfv $CURDIR/run.sh $CURDIR/package/
# Bundle the ggml shared backends from the CPU_ALL_VARIANTS build into package/lib. ggml
# discovers the per-microarch libggml-cpu-*.so by scanning the executable directory, which
# (via the bundled lib/ld.so that run.sh launches through) resolves to lib/. See the
# matching comment in backend/cpp/llama-cpp/package.sh. No-op on the fallback/ROCm builds.
if [ -d "$CURDIR/ggml-shared-libs" ]; then
echo "Bundling ggml shared backends (CPU_ALL_VARIANTS)..."
cp -avf $CURDIR/ggml-shared-libs/*.so* $CURDIR/package/lib/
fi
# Detect architecture and copy appropriate libraries
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
# x86_64 architecture
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 $CURDIR/package/lib/ld.so
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
# ARM64 architecture
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 $CURDIR/package/lib/ld.so
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries based on BUILD_TYPE
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah $CURDIR/package/
ls -liah $CURDIR/package/lib/

View File

@@ -0,0 +1,19 @@
# bonsai fork skew patches
The `bonsai` backend reuses `backend/cpp/llama-cpp/grpc-server.cpp` (written against
LocalAI's pinned *upstream* llama.cpp) but compiles it against the PrismML `prism` fork,
which branched from upstream some commits earlier. Any upstream API change that the shared
gRPC server depends on, but that the fork does not yet carry, is back-ported here as a
`*.patch` file and applied to the cloned fork checkout by `../apply-patches.sh`.
CI treats both this directory and `backend/cpp/llama-cpp/` as Bonsai inputs, since
the wrapper copies and builds the shared llama.cpp backend sources.
Rules:
- One upstream commit (or minimal hunk) per patch, named `NNNN-short-description.patch`.
- Patches are applied with `git apply` from the fork's checkout root.
- `apply-patches.sh` fails fast if a patch stops applying cleanly — that is the signal the
fork has caught up (or diverged), so re-cut or drop the patch.
- Keep this set as small as possible; the long-term fix is the fork rebasing onto a newer
upstream (or Q1_0/Q2_0 landing in mainline llama.cpp, retiring this backend entirely).

56
backend/cpp/bonsai/run.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
set -ex
# Get the absolute current dir where the script is located
CURDIR=$(dirname "$(realpath "$0")")
cd /
echo "CPU info:"
grep -e "model\sname" /proc/cpuinfo | head -1
grep -e "flags" /proc/cpuinfo | head -1
BINARY=bonsai-fallback
# x86/arm64 ship a single bonsai-cpu-all built with ggml CPU_ALL_VARIANTS: ggml's
# backend registry dlopens the best libggml-cpu-*.so for this host, so no shell-side
# probing. ROCm ships only bonsai-fallback, so fall back to it when cpu-all is absent.
if [ -e "$CURDIR"/bonsai-cpu-all ]; then
BINARY=bonsai-cpu-all
fi
if [ -n "$LLAMACPP_GRPC_SERVERS" ]; then
if [ -e "$CURDIR"/bonsai-grpc ]; then
BINARY=bonsai-grpc
fi
fi
# Extend ld library path with the dir where this script is located/lib
if [ "$(uname)" == "Darwin" ]; then
export DYLD_LIBRARY_PATH="$CURDIR"/lib:$DYLD_LIBRARY_PATH
else
export LD_LIBRARY_PATH="$CURDIR"/lib:$LD_LIBRARY_PATH
# Tell rocBLAS where to find TensileLibrary data (GPU kernel tuning files)
if [ -d "$CURDIR/lib/rocblas/library" ]; then
export ROCBLAS_TENSILE_LIBPATH="$CURDIR"/lib/rocblas/library
fi
# Same for hipBLASLt (rocblaslt): the bundled libhipblaslt.so resolves its
# TensileLibrary_lazy_gfx*.dat kernel data relative to itself, so point it at
# the bundled data or it falls back to slow generic kernels (issue #10660).
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
fi
# If there is a lib/ld.so, use it
if [ -f "$CURDIR"/lib/ld.so ]; then
echo "Using lib/ld.so"
echo "Using binary: $BINARY"
exec "$CURDIR"/lib/ld.so "$CURDIR"/$BINARY "$@"
fi
echo "Using binary: $BINARY"
exec "$CURDIR"/$BINARY "$@"
# We should never reach this point, however just in case we do, run fallback
exec "$CURDIR"/bonsai-fallback "$@"

View File

@@ -51,6 +51,11 @@ namespace {
// Global state - ds4 is single-engine-per-process by design.
std::mutex g_engine_mu;
// The ModelOptions.Model this process loaded, compared against
// PredictOptions.ModelIdentity so a request that arrived through a stale
// distributed route is rejected rather than answered from the wrong model
// (#10952). Guarded by g_engine_mu like the rest of the engine state.
std::string g_loaded_model_identity;
ds4_engine *g_engine = nullptr;
ds4_session *g_session = nullptr;
int g_ctx_size = 32768;
@@ -562,6 +567,24 @@ static void build_prompt(ds4_engine *engine, const backend::PredictOptions *requ
ds4_chat_append_assistant_prefix(engine, out, think);
}
// check_model_identity mirrors pkg/grpc/server.go and
// backend/python/common/model_identity.py. Either side empty means "skip": the
// request side is empty for a controller that predates the field, the loaded
// side when such a controller performed the load. A false rejection is worse
// than the miss it prevents. Callers must already hold g_engine_mu.
static GStatus check_model_identity(const backend::PredictOptions *request) {
if (request == nullptr || request->modelidentity().empty()) return GStatus::OK;
if (g_loaded_model_identity.empty() ||
g_loaded_model_identity == request->modelidentity()) {
return GStatus::OK;
}
// NOT_FOUND plus this exact sentinel is the cross-language contract the
// router matches on (grpcerrors.ModelMismatchSentinel).
return GStatus(StatusCode::NOT_FOUND,
"ds4: model identity mismatch: loaded \"" + g_loaded_model_identity +
"\", requested \"" + request->modelidentity() + "\"");
}
class DS4Backend final : public backend::Backend::Service {
public:
GStatus Health(ServerContext *, const backend::HealthMessage *,
@@ -716,6 +739,7 @@ public:
}
result->set_success(true);
g_loaded_model_identity = request->model();
result->set_message("loaded " + model_path);
return GStatus::OK;
}
@@ -724,6 +748,7 @@ public:
backend::TokenizationResponse *response) override {
std::lock_guard<std::mutex> lock(g_engine_mu);
if (!g_engine) return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
if (GStatus id = check_model_identity(request); !id.ok()) return id;
ds4_tokens out = {};
ds4_tokenize_text(g_engine, request->prompt().c_str(), &out);
for (int i = 0; i < out.len; ++i) response->add_tokens(out.v[i]);
@@ -738,6 +763,7 @@ public:
if (!g_engine || !g_session) {
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
}
if (GStatus id = check_model_identity(request); !id.ok()) return id;
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
return GStatus(StatusCode::UNAVAILABLE, route_err);
}
@@ -837,6 +863,7 @@ public:
if (!g_engine || !g_session) {
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
}
if (GStatus id = check_model_identity(request); !id.ok()) return id;
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
return GStatus(StatusCode::UNAVAILABLE, route_err);
}

View File

@@ -1,12 +1,14 @@
#!/bin/bash
set -e
set -euo pipefail
CURDIR=$(dirname "$(realpath "$0")")
REPO_ROOT="${CURDIR}/../../.."
PACKAGE_DIR="$CURDIR/package"
mkdir -p "$CURDIR/package/lib"
cp -avf "$CURDIR/grpc-server" "$CURDIR/package/"
cp -avf "$CURDIR/ds4-worker" "$CURDIR/package/"
cp -rfv "$CURDIR/run.sh" "$CURDIR/package/"
rm -rf "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR/lib"
cp -avf "$CURDIR/grpc-server" "$PACKAGE_DIR/"
cp -avf "$CURDIR/ds4-worker" "$PACKAGE_DIR/"
cp -rfv "$CURDIR/run.sh" "$PACKAGE_DIR/"
UNAME_S=$(uname -s)
if [ "$UNAME_S" = "Darwin" ]; then
@@ -16,25 +18,54 @@ if [ "$UNAME_S" = "Darwin" ]; then
fi
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$CURDIR/package/lib/ld.so"
LIBDIR=/lib/x86_64-linux-gnu
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$PACKAGE_DIR/lib/ld.so"
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
cp -arfLv /lib/ld-linux-aarch64.so.1 "$CURDIR/package/lib/ld.so"
LIBDIR=/lib/aarch64-linux-gnu
cp -arfLv /lib/ld-linux-aarch64.so.1 "$PACKAGE_DIR/lib/ld.so"
else
echo "package.sh: unknown architecture" >&2; exit 1
fi
for lib in libc.so.6 libgcc_s.so.1 libstdc++.so.6 libm.so.6 libgomp.so.1 \
libdl.so.2 librt.so.1 libpthread.so.0; do
cp -arfLv "$LIBDIR/$lib" "$CURDIR/package/lib/$lib"
# Bundle the complete dependency closure for both executables. In particular,
# grpc-server links the distro gRPC/protobuf/absl stack; copying only the core
# C/C++ runtime libraries leaves the scratch image unable to start.
{
ldd "$CURDIR/grpc-server"
ldd "$CURDIR/ds4-worker"
} | awk '$2 == "=>" && $3 ~ /^\// { print $3 }' | sort -u | \
while read -r so; do
cp -arfLv "$so" "$PACKAGE_DIR/lib/"
done
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
# shellcheck source=/dev/null
source "$GPU_LIB_SCRIPT" "$PACKAGE_DIR/lib"
package_gpu_libs
fi
# Resolve every dependency through the same loader and library path used by
# the from-scratch image. The loader can still search host defaults, so reject
# any absolute dependency path that escapes the package instead of accepting a
# false-positive validation against a library that scratch will not contain.
validate_packaged_binary() {
local binary="$1"
local resolution
resolution=$("$PACKAGE_DIR/lib/ld.so" \
--library-path "$PACKAGE_DIR/lib" \
--list "$PACKAGE_DIR/$binary")
printf '%s\n' "$resolution" | awk -v prefix="$PACKAGE_DIR/lib/" '
$2 == "=>" && $3 ~ /^\// && index($3, prefix) != 1 {
print "package.sh: dependency resolved outside package: " $0 > "/dev/stderr"
invalid = 1
}
END { exit invalid }
'
}
for binary in grpc-server ds4-worker; do
validate_packaged_binary "$binary"
done
echo "ds4 package contents:"
ls -lah "$CURDIR/package/" "$CURDIR/package/lib/"
ls -lah "$PACKAGE_DIR/" "$PACKAGE_DIR/lib/"

View File

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

View File

@@ -2412,7 +2412,33 @@ static void params_parse(const backend::ModelOptions* request,
// GRPC Server start
class BackendServiceImpl final : public backend::Backend::Service {
private:
// The ModelOptions.Model this process was loaded with. Compared against
// PredictOptions.ModelIdentity so a request that reached us through a stale
// distributed route is rejected instead of answered from the wrong model
// (#10952).
std::string loaded_model_identity;
public:
// checkModelIdentity mirrors pkg/grpc/server.go and
// backend/python/common/model_identity.py. Either side being empty means
// "skip": the request side is empty for a controller that predates the field,
// and the loaded side is empty when such a controller performed the load. A
// false rejection is worse than the miss it prevents.
grpc::Status checkModelIdentity(const backend::PredictOptions* request) {
if (request == nullptr || request->modelidentity().empty()) {
return grpc::Status::OK;
}
if (loaded_model_identity.empty() || loaded_model_identity == request->modelidentity()) {
return grpc::Status::OK;
}
// NOT_FOUND plus this exact sentinel is the cross-language contract the
// router matches on (grpcerrors.ModelMismatchSentinel).
return grpc::Status(grpc::StatusCode::NOT_FOUND,
"ik-llama-cpp: model identity mismatch: loaded \"" + loaded_model_identity +
"\", requested \"" + request->modelidentity() + "\"");
}
grpc::Status Health(ServerContext* context, const backend::HealthMessage* request, backend::Reply* reply) {
// Implement Health RPC
reply->set_message("OK");
@@ -2438,9 +2464,12 @@ public:
result->set_message("Loading succeeded");
result->set_success(true);
loaded_model = true;
loaded_model_identity = request->model();
return Status::OK;
}
grpc::Status PredictStream(grpc::ServerContext* context, const backend::PredictOptions* request, grpc::ServerWriter<backend::Reply>* writer) override {
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
json data = parse_options(true, request, llama);
const int task_id = llama.queue_tasks.get_new_id();
llama.queue_results.add_waiting_task_id(task_id);
@@ -2495,6 +2524,8 @@ public:
grpc::Status Predict(ServerContext* context, const backend::PredictOptions* request, backend::Reply* reply) {
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
json data = parse_options(false, request, llama);
const int task_id = llama.queue_tasks.get_new_id();
llama.queue_results.add_waiting_task_id(task_id);
@@ -2532,6 +2563,8 @@ public:
/// https://github.com/ggerganov/llama.cpp/blob/aa2341298924ac89778252015efcb792f2df1e20/examples/server/server.cpp#L2969
grpc::Status Embedding(ServerContext* context, const backend::PredictOptions* request, backend::EmbeddingResult* embeddingResult) {
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
json data = parse_options(false, request, llama);
const int task_id = llama.queue_tasks.get_new_id();
llama.queue_results.add_waiting_task_id(task_id);
@@ -2556,6 +2589,8 @@ public:
}
grpc::Status TokenizeString(ServerContext* context, const backend::PredictOptions* request, backend::TokenizationResponse* response){
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
json data = parse_options(false, request, llama);
std::vector<llama_token> tokens = llama.tokenize(data["prompt"],false);

View File

@@ -1,5 +1,5 @@
LLAMA_VERSION?=d4cff114c0084f1fbc9b4c62717eca8fb2ae494a
LLAMA_VERSION?=571d0d540df04f25298d0e159e520d9fc62ed121
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=

View File

@@ -613,6 +613,24 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
// starts with '-'. Applied once after the loop via common_params_parse.
std::vector<std::string> extra_argv;
auto add_device_options = [&](const std::string & devices) {
const std::regex regex{ R"([,]+)" };
std::sregex_token_iterator it{ devices.begin(), devices.end(), regex, -1 };
std::vector<std::string> split_arg{ it, {} };
for (std::string device : split_arg) {
const auto start = device.find_first_not_of(" \t\n\r");
if (start == std::string::npos) {
continue;
}
const auto end = device.find_last_not_of(" \t\n\r");
device = device.substr(start, end - start + 1);
extra_argv.push_back("--device");
extra_argv.push_back(device);
}
};
// decode options. Options are in form optname:optvale, or if booleans only optname.
for (int i = 0; i < request->options_size(); i++) {
std::string opt = request->options(i);
@@ -744,6 +762,10 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
params.no_op_offload = false;
}
} else if (!strcmp(optname, "device") || !strcmp(optname, "devices")) {
if (optval != NULL) {
add_device_options(optval_str);
}
} else if (!strcmp(optname, "split_mode") || !strcmp(optname, "sm")) {
// Accepts: none | layer | row | tensor (the latter requires a llama.cpp build
// that includes ggml-org/llama.cpp#19378, FlashAttention enabled, and KV-cache
@@ -1379,10 +1401,40 @@ class BackendServiceImpl final : public backend::Backend::Service {
private:
server_context& ctx_server;
common_params params_base; // Store copy of params_base, set after model load
// The ModelOptions.Model this process was loaded with. Compared against
// PredictOptions.ModelIdentity so a request that reached us through a stale
// distributed route is rejected instead of answered from the wrong model
// (#10952). Written under LoadModel, read by the inference RPCs.
std::string loaded_model_identity;
public:
BackendServiceImpl(server_context& ctx) : ctx_server(ctx) {}
// checkModelIdentity mirrors pkg/grpc/server.go and
// backend/python/common/model_identity.py. Either side being empty means
// "skip": the request side is empty for a controller that predates the
// field and for the synthetic PredictOptions this server builds internally
// for ASR, and the loaded side is empty when such a controller performed
// the load. A false rejection is worse than the miss it prevents.
// Templated over the request type: every guarded request message exposes
// modelidentity(), and one body keeps the rule identical across modalities
// rather than repeating it per RPC.
template <typename Request>
grpc::Status checkModelIdentity(const Request* request) {
if (request == nullptr || request->modelidentity().empty()) {
return grpc::Status::OK;
}
if (loaded_model_identity.empty() || loaded_model_identity == request->modelidentity()) {
return grpc::Status::OK;
}
// NOT_FOUND plus this exact sentinel is the cross-language contract the
// router matches on (grpcerrors.ModelMismatchSentinel). The code alone
// is not enough: NOT_FOUND is returned for unrelated reasons elsewhere.
return grpc::Status(grpc::StatusCode::NOT_FOUND,
"llama-cpp: model identity mismatch: loaded \"" + loaded_model_identity +
"\", requested \"" + request->modelidentity() + "\"");
}
grpc::Status Health(ServerContext* context, const backend::HealthMessage* /*request*/, backend::Reply* reply) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
@@ -1513,6 +1565,7 @@ public:
result->set_message("Loading succeeded");
result->set_success(true);
loaded_model = true;
loaded_model_identity = request->model();
// Store copy of params_base for use in parse_options and other methods
params_base = params;
@@ -1594,6 +1647,8 @@ public:
grpc::Status PredictStream(grpc::ServerContext* context, const backend::PredictOptions* request, grpc::ServerWriter<backend::Reply>* writer) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
@@ -2161,6 +2216,8 @@ public:
grpc::Status Predict(ServerContext* context, const backend::PredictOptions* request, backend::Reply* reply) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
@@ -2693,6 +2750,8 @@ public:
grpc::Status Embedding(ServerContext* context, const backend::PredictOptions* request, backend::EmbeddingResult* embeddingResult) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
@@ -2791,6 +2850,8 @@ public:
}
grpc::Status Rerank(ServerContext* context, const backend::RerankRequest* request, backend::RerankResult* rerankResult) override {
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (!params_base.embedding || params_base.pooling_type != LLAMA_POOLING_TYPE_RANK) {
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED, "This server does not support reranking. Start it with `--reranking` and without `--embedding`");
}
@@ -2915,6 +2976,8 @@ public:
grpc::Status Score(ServerContext* context, const backend::ScoreRequest* request, backend::ScoreResponse* response) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
@@ -3086,6 +3149,8 @@ public:
grpc::Status TokenizeString(ServerContext* context, const backend::PredictOptions* request, backend::TokenizationResponse* response) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
@@ -3371,6 +3436,8 @@ public:
backend::TranscriptResult* response) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
backend::Reply reply;
grpc::Status st = runTranscriptionAsCompletion(context, request, &reply);
@@ -3389,6 +3456,8 @@ public:
grpc::ServerWriter<backend::TranscriptStreamResponse>* writer) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
// Buffered streaming: run the transcription as a normal chat
// completion, then emit one delta + one final event. Real

View File

@@ -0,0 +1,814 @@
# Vendored from upstream llama.cpp PR #24523 (Preliminary MiniMax-M3 support).
# Rebased against LLAMA_VERSION 00fa7cb284cbf133fc426733bd64238a3588a33e (also applies cleanly
# to the later pin 505b1ed15ca80e2a19f12ff4ac365e40fb374053). LLAMA_VERSION is auto-bumped
# nightly; if a bump rejects this patch, re-vendor from #24523 — or, once #24523 merges
# upstream, delete this file and bump LLAMA_VERSION normally.
# See https://github.com/mudler/LocalAI/issues/10820 and PR #10837.
diff --git a/common/chat.cpp b/common/chat.cpp
index 22d2ee4..440be9a 100644
--- a/common/chat.cpp
+++ b/common/chat.cpp
@@ -2035,6 +2035,191 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha
return data;
}
+static common_chat_params common_chat_params_init_minimax_m3(const common_chat_template & tmpl,
+ const autoparser::generation_params & inputs) {
+ common_chat_params data;
+
+ data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
+ data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
+ data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
+ data.supports_thinking = true;
+ data.thinking_start_tag = "<mm:think>";
+ data.thinking_end_tag = "</mm:think>";
+
+ // M3 prefixes every tool tag with the namespace token "]<]minimax[>[";
+ // params use the parameter name as the tag (<file_path>...</file_path>).
+ const std::string NS = "]<]minimax[>[";
+ const std::string THINK_START = "<mm:think>";
+ const std::string THINK_END = "</mm:think>";
+ const std::string FC_START = NS + "<tool_call>";
+ const std::string FC_END = NS + "</tool_call>";
+ const std::string INVOKE_END = NS + "</invoke>";
+
+ data.preserved_tokens = {
+ NS,
+ "<tool_call>",
+ "</tool_call>",
+ THINK_START,
+ THINK_END,
+ };
+
+ auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
+ auto has_response_format = !inputs.json_schema.is_null() && inputs.json_schema.is_object();
+ auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
+ auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);
+
+ const std::string GEN_PROMPT = data.generation_prompt;
+
+ if (inputs.has_continuation()) {
+ const auto & msg = inputs.continue_msg;
+
+ data.generation_prompt = GEN_PROMPT + THINK_START + msg.reasoning_content;
+ if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
+ data.generation_prompt += THINK_END + msg.render_content();
+ }
+
+ data.prompt += data.generation_prompt;
+ }
+
+ auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
+ auto generation_prompt = p.literal(GEN_PROMPT);
+ auto end = p.end();
+
+ auto reasoning = p.eps();
+ // M3 can emit a bare </mm:think> (no opener) after tool results; keep the opener optional.
+ if (extract_reasoning && inputs.enable_thinking) {
+ reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.reasoning(p.until(THINK_END)) + THINK_END);
+ } else if (extract_reasoning) {
+ reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.until(THINK_END) + p.literal(THINK_END));
+ }
+
+ if (has_response_format) {
+ auto response_format = p.rule("response-format",
+ p.literal("```json") + p.space() +
+ p.content(p.schema(p.json(), "response-format-schema", inputs.json_schema)) +
+ p.space() + p.literal("```"));
+ return generation_prompt + reasoning + response_format + end;
+ }
+
+ if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
+ return generation_prompt + reasoning + p.content(p.rest()) + end;
+ }
+
+ auto tool_choice = p.choice();
+ foreach_function(inputs.tools, [&](const json & tool) {
+ const auto & function = tool.at("function");
+ std::string name = function.at("name");
+ auto params = function.contains("parameters") ? function.at("parameters") : json::object();
+ const auto & props = params.contains("properties") ? params.at("properties") : json::object();
+
+ std::set<std::string> required;
+ if (params.contains("required")) {
+ params.at("required").get_to(required);
+ }
+
+ auto schema_info = common_schema_info();
+ schema_info.resolve_refs(params);
+
+ std::vector<common_peg_parser> required_parsers;
+ std::vector<common_peg_parser> optional_parsers;
+ for (const auto & [param_name, param_schema] : props.items()) {
+ bool is_required = required.find(param_name) != required.end();
+ bool is_string = schema_info.resolves_to_string(param_schema);
+
+ const std::string p_close = NS + "</" + param_name + ">";
+
+ auto arg = p.tool_arg(
+ p.tool_arg_open(
+ p.literal(NS + "<") +
+ p.tool_arg_name(p.literal(param_name)) +
+ p.literal(">")) +
+ (is_string
+ ? p.ac(p.tool_arg_string_value(p.until(p_close)) +
+ p.tool_arg_close(p.literal(p_close)), p_close)
+ : p.tool_arg_json_value(p.schema(p.json(),
+ "tool-" + name + "-arg-" + param_name + "-schema",
+ param_schema, false)) +
+ p.tool_arg_close(p.literal(p_close))));
+
+ auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg);
+ if (is_required) {
+ required_parsers.push_back(named_arg);
+ } else {
+ optional_parsers.push_back(named_arg);
+ }
+ }
+
+ common_peg_parser args_seq = p.eps();
+ for (size_t i = 0; i < required_parsers.size(); i++) {
+ if (i > 0) {
+ args_seq = args_seq + p.space();
+ }
+ args_seq = args_seq + required_parsers[i];
+ }
+
+ if (!optional_parsers.empty()) {
+ common_peg_parser any_opt = p.choice();
+ for (const auto & opt : optional_parsers) {
+ any_opt |= opt;
+ }
+ args_seq = args_seq + p.repeat(p.space() + any_opt, 0, -1);
+ }
+
+ common_peg_parser invoke_body = args_seq;
+ auto func_parser = p.tool(
+ p.tool_open(p.literal(NS + "<invoke name=\"") +
+ p.tool_name(p.literal(name)) + p.literal("\">")) +
+ p.space() + invoke_body + p.space() +
+ p.tool_close(p.literal(INVOKE_END)));
+
+ tool_choice |= p.rule("tool-" + name, func_parser);
+ });
+
+ auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
+
+ common_peg_parser tool_calls = p.eps();
+ if (inputs.parallel_tool_calls) {
+ tool_calls = p.trigger_rule("tool-call",
+ p.literal(FC_START) + p.space() + tool_choice +
+ p.zero_or_more(p.space() + tool_choice) + p.space() + p.literal(FC_END));
+ } else {
+ tool_calls = p.trigger_rule("tool-call",
+ p.literal(FC_START) + p.space() + tool_choice + p.space() + p.literal(FC_END));
+ }
+
+ if (!require_tools) {
+ tool_calls = p.optional(tool_calls);
+ }
+
+ auto content_before_tools = p.content(p.until(FC_START));
+ return generation_prompt + reasoning + content_before_tools + tool_calls + end;
+ });
+
+ data.parser = parser.save();
+
+ if (include_grammar) {
+ data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
+ data.grammar = build_grammar([&](const common_grammar_builder & builder) {
+ foreach_function(inputs.tools, [&](const json & tool) {
+ const auto & function = tool.at("function");
+ auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
+ builder.resolve_refs(schema);
+ });
+ if (has_response_format) {
+ auto schema = inputs.json_schema;
+ builder.resolve_refs(schema);
+ }
+ parser.build_grammar(builder, data.grammar_lazy);
+ });
+
+ data.grammar_triggers = {
+ { COMMON_GRAMMAR_TRIGGER_TYPE_WORD, FC_START },
+ };
+ }
+
+ return data;
+}
+
// Cohere2 MoE (a.k.a. "North Code") parser.
//
// The assistant turn is fully marker-wrapped:
@@ -2612,6 +2797,15 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
return common_chat_params_init_gigachat_v3(tmpl, params);
}
+ // MiniMax-M3: the namespace token "]<]minimax[>[" collides with the autoparser's
+ // markup delimiters, so detect the template and use a dedicated parser.
+ if (src.find("]<]minimax[>[") != std::string::npos &&
+ src.find("<tool_call>") != std::string::npos &&
+ src.find("<invoke name=") != std::string::npos) {
+ LOG_DBG("Using specialized template: MiniMax-M3\n");
+ return common_chat_params_init_minimax_m3(tmpl, params);
+ }
+
// DeepSeek V3.2 format detection: template defines dsml_token and uses it for tool calls.
// The template source contains the token as a variable assignment, not as a literal in markup.
if (src.find("dsml_token") != std::string::npos &&
diff --git a/conversion/__init__.py b/conversion/__init__.py
index 02ea638..71de528 100644
--- a/conversion/__init__.py
+++ b/conversion/__init__.py
@@ -155,6 +155,8 @@ TEXT_MODEL_MAP: dict[str, str] = {
"MiniCPMForCausalLM": "minicpm",
"MiniCPMV4_6ForConditionalGeneration": "minicpm",
"MiniMaxM2ForCausalLM": "minimax",
+ "MiniMaxM3SparseForCausalLM": "minimax",
+ "MiniMaxM3SparseForConditionalGeneration": "minimax",
"Ministral3ForCausalLM": "mistral3",
"Mistral3ForConditionalGeneration": "mistral3",
"MistralForCausalLM": "llama",
diff --git a/conversion/base.py b/conversion/base.py
index 0421aa4..224481a 100644
--- a/conversion/base.py
+++ b/conversion/base.py
@@ -1154,7 +1154,8 @@ class TextModel(ModelBase):
or "projector." in name or "pre_mm_projector_norm" in name \
or "image_newline" in name or "view_seperator" in name \
or "patch_embed" in name or "patch_embedding" in name \
- or "patch_merger." in name or "model.connector." in name:
+ or "patch_merger." in name or "patch_merge_mlp" in name \
+ or "model.connector." in name:
return None
return super().filter_tensors(item)
@@ -1201,7 +1202,7 @@ class TextModel(ModelBase):
self.gguf_writer.add_embedding_length(n_embd)
logger.info(f"gguf: embedding length = {n_embd}")
- if (n_ff := self.find_hparam(["prefix_dense_intermediate_size", "intermediate_size", "n_inner", "hidden_dim"], optional=True)) is not None:
+ if (n_ff := self.find_hparam(["prefix_dense_intermediate_size", "dense_intermediate_size", "intermediate_size", "n_inner", "hidden_dim"], optional=True)) is not None:
self.gguf_writer.add_feed_forward_length(n_ff)
logger.info(f"gguf: feed forward length = {n_ff}")
diff --git a/conversion/minimax.py b/conversion/minimax.py
index 4857775..4f637f5 100644
--- a/conversion/minimax.py
+++ b/conversion/minimax.py
@@ -52,3 +52,67 @@ class MiniMaxM2Model(TextModel):
return
yield from super().modify_tensors(data_torch, name, bid)
+
+
+@ModelBase.register("MiniMaxM3SparseForCausalLM", "MiniMaxM3SparseForConditionalGeneration")
+class MiniMaxM3Model(TextModel):
+ # Text-only MiniMax-M3: MiniMax-M2 GQA + DeepSeek-V3 shared/leading-dense experts (swigluoai).
+ model_arch = gguf.MODEL_ARCH.MINIMAXM3
+ _experts_cache: dict[int, dict[str, Tensor]] = {}
+
+ def set_gguf_parameters(self):
+ # feed_forward_length comes from dense_intermediate_size (base); experts use intermediate_size.
+ super().set_gguf_parameters()
+
+ self.gguf_writer.add_expert_feed_forward_length(self.find_hparam(["intermediate_size"]))
+ self.gguf_writer.add_rope_dimension_count(self.find_hparam(["rotary_dim"]))
+ self.gguf_writer.add_expert_shared_count(self.find_hparam(["n_shared_experts"]))
+ self.gguf_writer.add_expert_weights_scale(self.find_hparam(["routed_scaling_factor"]))
+ self.gguf_writer.add_expert_weights_norm(True)
+
+ # leading dense layers: moe_layer_freq (ints) or mlp_layer_types (Transformers 5.12, strings)
+ moe_layer_freq = self.find_hparam(["moe_layer_freq", "mlp_layer_types"])
+ n_dense = 0
+ for v in moe_layer_freq:
+ if v == 0 or v == "dense":
+ n_dense += 1
+ else:
+ break
+ self.gguf_writer.add_leading_dense_block_count(n_dense)
+
+ def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None):
+ # index_* (sparse-attn indexer) tensors are preserved but unused; the loader skips them
+ if name.startswith("language_model."):
+ name = name[len("language_model."):]
+
+ # Gemma-style (1+w) RMSNorm: bake +1 in so llama.cpp can use plain RMSNorm
+ if name.endswith("norm.weight"):
+ data_torch = data_torch + 1.0
+
+ # merge routed experts (w1/w2/w3); shared_experts.* passes through to *_shexp
+ if "block_sparse_moe.experts." in name:
+ n_experts = self.find_hparam(["num_local_experts", "num_experts"])
+ assert bid is not None
+
+ expert_cache = self._experts_cache.setdefault(bid, {})
+ expert_cache[name] = data_torch
+ expert_weights = ["w1", "w2", "w3"]
+
+ if len(expert_cache) < n_experts * len(expert_weights):
+ return
+
+ for w_name in expert_weights:
+ datas: list[Tensor] = []
+ for xid in range(n_experts):
+ ename = f"model.layers.{bid}.block_sparse_moe.experts.{xid}.{w_name}.weight"
+ datas.append(expert_cache[ename])
+ del expert_cache[ename]
+
+ data_torch = torch.stack(datas, dim=0)
+ merged_name = f"model.layers.{bid}.block_sparse_moe.experts.{w_name}.weight"
+ yield from super().modify_tensors(data_torch, merged_name, bid)
+
+ del self._experts_cache[bid]
+ return
+
+ yield from super().modify_tensors(data_torch, name, bid)
diff --git a/gguf-py/gguf/constants.py b/gguf-py/gguf/constants.py
index 869e436..760e3dd 100644
--- a/gguf-py/gguf/constants.py
+++ b/gguf-py/gguf/constants.py
@@ -525,6 +525,7 @@ class MODEL_ARCH(IntEnum):
APERTUS = auto()
COGVLM = auto()
MINIMAXM2 = auto()
+ MINIMAXM3 = auto()
RND1 = auto()
PANGU_EMBED = auto()
MISTRAL3 = auto()
@@ -613,6 +614,10 @@ class MODEL_TENSOR(IntEnum):
MOE_LATENT_UP = auto() # nemotron 3 super
ATTN_Q_NORM = auto()
ATTN_K_NORM = auto()
+ ATTN_INDEX_Q = auto() # minimax-m3 sparse-attn indexer (unused)
+ ATTN_INDEX_K = auto()
+ ATTN_INDEX_Q_NORM = auto()
+ ATTN_INDEX_K_NORM = auto()
LAYER_OUT_NORM = auto()
LAYER_OUT_SCALE = auto()
PER_LAYER_TOKEN_EMBD = auto() # gemma3n
@@ -1105,6 +1110,7 @@ MODEL_ARCH_NAMES: dict[MODEL_ARCH, str] = {
MODEL_ARCH.GROVEMOE: "grovemoe",
MODEL_ARCH.APERTUS: "apertus",
MODEL_ARCH.MINIMAXM2: "minimax-m2",
+ MODEL_ARCH.MINIMAXM3: "minimax-m3",
MODEL_ARCH.COGVLM: "cogvlm",
MODEL_ARCH.RND1: "rnd1",
MODEL_ARCH.PANGU_EMBED: "pangu-embedded",
@@ -1163,6 +1169,10 @@ TENSOR_NAMES: dict[MODEL_TENSOR, str] = {
MODEL_TENSOR.ATTN_GATE: "blk.{bid}.attn_gate",
MODEL_TENSOR.ATTN_Q_NORM: "blk.{bid}.attn_q_norm",
MODEL_TENSOR.ATTN_K_NORM: "blk.{bid}.attn_k_norm",
+ MODEL_TENSOR.ATTN_INDEX_Q: "blk.{bid}.attn_index_q",
+ MODEL_TENSOR.ATTN_INDEX_K: "blk.{bid}.attn_index_k",
+ MODEL_TENSOR.ATTN_INDEX_Q_NORM: "blk.{bid}.attn_index_q_norm",
+ MODEL_TENSOR.ATTN_INDEX_K_NORM: "blk.{bid}.attn_index_k_norm",
MODEL_TENSOR.ATTN_OUT_NORM: "blk.{bid}.attn_output_norm",
MODEL_TENSOR.ATTN_POST_NORM: "blk.{bid}.post_attention_norm",
MODEL_TENSOR.FFN_GATE_INP: "blk.{bid}.ffn_gate_inp",
@@ -4102,6 +4112,30 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = {
MODEL_TENSOR.FFN_UP_EXP,
MODEL_TENSOR.FFN_EXP_PROBS_B,
],
+ MODEL_ARCH.MINIMAXM3: [
+ MODEL_TENSOR.TOKEN_EMBD,
+ MODEL_TENSOR.OUTPUT_NORM,
+ MODEL_TENSOR.OUTPUT,
+ MODEL_TENSOR.ATTN_NORM,
+ MODEL_TENSOR.ATTN_Q,
+ MODEL_TENSOR.ATTN_Q_NORM,
+ MODEL_TENSOR.ATTN_K,
+ MODEL_TENSOR.ATTN_K_NORM,
+ MODEL_TENSOR.ATTN_V,
+ MODEL_TENSOR.ATTN_OUT,
+ MODEL_TENSOR.FFN_NORM,
+ MODEL_TENSOR.FFN_GATE_INP,
+ MODEL_TENSOR.FFN_EXP_PROBS_B,
+ MODEL_TENSOR.FFN_GATE_EXP,
+ MODEL_TENSOR.FFN_DOWN_EXP,
+ MODEL_TENSOR.FFN_UP_EXP,
+ MODEL_TENSOR.FFN_GATE_SHEXP,
+ MODEL_TENSOR.FFN_DOWN_SHEXP,
+ MODEL_TENSOR.FFN_UP_SHEXP,
+ MODEL_TENSOR.FFN_GATE,
+ MODEL_TENSOR.FFN_DOWN,
+ MODEL_TENSOR.FFN_UP,
+ ],
MODEL_ARCH.COGVLM: [
MODEL_TENSOR.TOKEN_EMBD,
MODEL_TENSOR.OUTPUT_NORM,
@@ -4128,6 +4162,10 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = {
MODEL_TENSOR.ATTN_Q_NORM,
MODEL_TENSOR.ATTN_K,
MODEL_TENSOR.ATTN_K_NORM,
+ MODEL_TENSOR.ATTN_INDEX_Q,
+ MODEL_TENSOR.ATTN_INDEX_K,
+ MODEL_TENSOR.ATTN_INDEX_Q_NORM,
+ MODEL_TENSOR.ATTN_INDEX_K_NORM,
MODEL_TENSOR.ATTN_V,
MODEL_TENSOR.ATTN_OUT,
MODEL_TENSOR.FFN_NORM,
diff --git a/gguf-py/gguf/tensor_mapping.py b/gguf-py/gguf/tensor_mapping.py
index 9efb36f..a62040b 100644
--- a/gguf-py/gguf/tensor_mapping.py
+++ b/gguf-py/gguf/tensor_mapping.py
@@ -717,6 +717,22 @@ class TensorNameMap:
"model.layers.{bid}.attention.key_layernorm", # apertus
),
+ MODEL_TENSOR.ATTN_INDEX_Q: (
+ "model.layers.{bid}.self_attn.index_q_proj", # minimax-m3 (sparse-attn indexer)
+ ),
+
+ MODEL_TENSOR.ATTN_INDEX_K: (
+ "model.layers.{bid}.self_attn.index_k_proj", # minimax-m3
+ ),
+
+ MODEL_TENSOR.ATTN_INDEX_Q_NORM: (
+ "model.layers.{bid}.self_attn.index_q_norm", # minimax-m3
+ ),
+
+ MODEL_TENSOR.ATTN_INDEX_K_NORM: (
+ "model.layers.{bid}.self_attn.index_k_norm", # minimax-m3
+ ),
+
MODEL_TENSOR.ROPE_FREQS: (
"encoder.layers.{bid}.self_attention.rotary_emb.inv_freq", # persimmon
),
diff --git a/src/llama-arch.cpp b/src/llama-arch.cpp
index b890e66..cb8bfc8 100644
--- a/src/llama-arch.cpp
+++ b/src/llama-arch.cpp
@@ -125,6 +125,7 @@ static const std::map<llm_arch, const char *> LLM_ARCH_NAMES = {
{ LLM_ARCH_GROVEMOE, "grovemoe" },
{ LLM_ARCH_APERTUS, "apertus" },
{ LLM_ARCH_MINIMAX_M2, "minimax-m2" },
+ { LLM_ARCH_MINIMAX_M3, "minimax-m3" },
{ LLM_ARCH_COGVLM, "cogvlm" },
{ LLM_ARCH_RND1, "rnd1" },
{ LLM_ARCH_PANGU_EMBED, "pangu-embedded" },
@@ -395,6 +396,10 @@ static const std::map<llm_tensor, const char *> LLM_TENSOR_NAMES = {
{ LLM_TENSOR_ATTN_POST_NORM, "blk.%d.post_attention_norm" },
{ LLM_TENSOR_ATTN_Q_NORM, "blk.%d.attn_q_norm" },
{ LLM_TENSOR_ATTN_K_NORM, "blk.%d.attn_k_norm" },
+ { LLM_TENSOR_ATTN_INDEX_Q, "blk.%d.attn_index_q" },
+ { LLM_TENSOR_ATTN_INDEX_K, "blk.%d.attn_index_k" },
+ { LLM_TENSOR_ATTN_INDEX_Q_NORM, "blk.%d.attn_index_q_norm" },
+ { LLM_TENSOR_ATTN_INDEX_K_NORM, "blk.%d.attn_index_k_norm" },
{ LLM_TENSOR_ATTN_GATE, "blk.%d.attn_gate" },
{ LLM_TENSOR_FFN_POST_NORM, "blk.%d.post_ffw_norm" },
{ LLM_TENSOR_FFN_POST_NORM_1, "blk.%d.post_ffw_norm_1" },
@@ -761,6 +766,11 @@ static const std::map<llm_tensor, llm_tensor_info> LLM_TENSOR_INFOS = {
{LLM_TENSOR_FFN_NORM_EXPS, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
{LLM_TENSOR_ATTN_Q_NORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
{LLM_TENSOR_ATTN_K_NORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
+ // minimax-m3 sparse-attn indexer: unused (GGML_OP_NONE) so the loader skips it
+ {LLM_TENSOR_ATTN_INDEX_Q, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_NONE}},
+ {LLM_TENSOR_ATTN_INDEX_K, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_NONE}},
+ {LLM_TENSOR_ATTN_INDEX_Q_NORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_NONE}},
+ {LLM_TENSOR_ATTN_INDEX_K_NORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_NONE}},
{LLM_TENSOR_LAYER_OUT_NORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
{LLM_TENSOR_LAYER_OUT_SCALE, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
{LLM_TENSOR_ATTN_Q_A_NORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
@@ -998,6 +1008,7 @@ bool llm_arch_supports_sm_tensor(const llm_arch & arch) {
case LLM_ARCH_LFM2:
case LLM_ARCH_LFM2MOE:
case LLM_ARCH_MINIMAX_M2:
+ case LLM_ARCH_MINIMAX_M3:
case LLM_ARCH_MISTRAL4:
case LLM_ARCH_KIMI_LINEAR:
return false;
diff --git a/src/llama-arch.h b/src/llama-arch.h
index a4f5091..2d50ead 100644
--- a/src/llama-arch.h
+++ b/src/llama-arch.h
@@ -144,6 +144,7 @@ enum llm_arch {
LLM_ARCH_TALKIE,
LLM_ARCH_MELLUM,
LLM_ARCH_EAGLE3,
+ LLM_ARCH_MINIMAX_M3,
LLM_ARCH_DFLASH,
LLM_ARCH_UNKNOWN,
};
@@ -429,6 +430,10 @@ enum llm_tensor {
LLM_TENSOR_FFN_LATENT_UP,
LLM_TENSOR_ATTN_Q_NORM,
LLM_TENSOR_ATTN_K_NORM,
+ LLM_TENSOR_ATTN_INDEX_Q, // minimax-m3 sparse-attn indexer (unused)
+ LLM_TENSOR_ATTN_INDEX_K,
+ LLM_TENSOR_ATTN_INDEX_Q_NORM,
+ LLM_TENSOR_ATTN_INDEX_K_NORM,
LLM_TENSOR_LAYER_OUT_NORM,
LLM_TENSOR_LAYER_OUT_SCALE,
LLM_TENSOR_POST_ATTN_NORM,
diff --git a/src/llama-graph.cpp b/src/llama-graph.cpp
index c8ecb0a..4c2c286 100644
--- a/src/llama-graph.cpp
+++ b/src/llama-graph.cpp
@@ -1719,6 +1719,16 @@ ggml_tensor * llm_graph_context::build_ffn(
cur = ggml_reglu(ctx0, cur);
cb(cur, "ffn_reglu", il);
} break;
+ case LLM_FFN_SWIGLU_OAI:
+ {
+ // clamped SwiGLU: parallel gate path (cur=gate, tmp=up)
+ GGML_ASSERT(gate && type_gate == LLM_FFN_PAR);
+ constexpr float alpha = 1.702f;
+ constexpr float limit = 7.0f;
+ cur = ggml_swiglu_oai(ctx0, cur, tmp, alpha, limit);
+ cb(cur, "ffn_swiglu_oai", il);
+ type_gate = LLM_FFN_SEQ; // gate*up already fused; skip the par multiply
+ } break;
default:
GGML_ABORT("fatal error");
}
diff --git a/src/llama-graph.h b/src/llama-graph.h
index c84cb6a..806ce7b 100644
--- a/src/llama-graph.h
+++ b/src/llama-graph.h
@@ -54,6 +54,7 @@ enum llm_ffn_op_type : int {
LLM_FFN_SWIGLU,
LLM_FFN_GEGLU,
LLM_FFN_REGLU,
+ LLM_FFN_SWIGLU_OAI,
LLM_FFN_SWIGLU_OAI_MOE,
};
diff --git a/src/llama-model.cpp b/src/llama-model.cpp
index d874813..7bb71c0 100644
--- a/src/llama-model.cpp
+++ b/src/llama-model.cpp
@@ -280,6 +280,8 @@ static llama_model * llama_model_mapping(llm_arch arch, const llama_model_params
return new llama_model_apertus(params);
case LLM_ARCH_MINIMAX_M2:
return new llama_model_minimax_m2(params);
+ case LLM_ARCH_MINIMAX_M3:
+ return new llama_model_minimax_m3(params);
case LLM_ARCH_COGVLM:
return new llama_model_cogvlm(params);
case LLM_ARCH_PANGU_EMBED:
@@ -807,6 +809,7 @@ const char * llm_type_name(llm_type type) {
case LLM_TYPE_310B_A15B: return "310B.A15B";
case LLM_TYPE_355B_A32B: return "355B.A32B";
case LLM_TYPE_397B_A17B: return "397B.A17B";
+ case LLM_TYPE_428B_A23B: return "428B.A23B";
case LLM_TYPE_685B_A37B: return "685B.A37B";
case LLM_TYPE_744B_A40B: return "744B.A40B";
case LLM_TYPE_E2B: return "E2B";
@@ -2532,6 +2535,7 @@ llama_rope_type llama_model_rope_type(const llama_model * model) {
case LLM_ARCH_GROVEMOE:
case LLM_ARCH_APERTUS:
case LLM_ARCH_MINIMAX_M2:
+ case LLM_ARCH_MINIMAX_M3:
case LLM_ARCH_COGVLM:
case LLM_ARCH_PANGU_EMBED:
case LLM_ARCH_AFMOE:
diff --git a/src/llama-model.h b/src/llama-model.h
index 45b054c..540e0d2 100644
--- a/src/llama-model.h
+++ b/src/llama-model.h
@@ -139,6 +139,7 @@ enum llm_type {
LLM_TYPE_310B_A15B, // /MiMo-V2-Flash
LLM_TYPE_355B_A32B, // GLM-4.5
LLM_TYPE_397B_A17B, // Qwen3.5
+ LLM_TYPE_428B_A23B, // MiniMax M3
LLM_TYPE_685B_A37B, // DeepSeek V3.2
LLM_TYPE_744B_A40B, // GLM-5
LLM_TYPE_E2B,
diff --git a/src/models/minimax-m3.cpp b/src/models/minimax-m3.cpp
new file mode 100644
index 0000000..137852a
--- /dev/null
+++ b/src/models/minimax-m3.cpp
@@ -0,0 +1,197 @@
+#include "models.h"
+
+// MiniMax-M3, text-only: MiniMax-M2 GQA (per-head QK-norm, partial rotary) + DeepSeek-V3
+// leading-dense/routed/shared experts (swigluoai). Sparse attn -> dense; vision + MTP dropped.
+
+void llama_model_minimax_m3::load_arch_hparams(llama_model_loader & ml) {
+ ml.get_key(LLM_KV_ATTENTION_LAYERNORM_RMS_EPS, hparams.f_norm_rms_eps);
+ ml.get_key(LLM_KV_LEADING_DENSE_BLOCK_COUNT, hparams.n_layer_dense_lead, false);
+ ml.get_key(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp);
+ ml.get_key(LLM_KV_EXPERT_SHARED_COUNT, hparams.n_expert_shared);
+ ml.get_key(LLM_KV_EXPERT_WEIGHTS_SCALE, hparams.expert_weights_scale, false);
+ ml.get_key(LLM_KV_EXPERT_WEIGHTS_NORM, hparams.expert_weights_norm, false);
+ ml.get_key(LLM_KV_EXPERT_GATING_FUNC, hparams.expert_gating_func);
+
+ switch (hparams.n_layer()) {
+ case 60: type = LLM_TYPE_428B_A23B; break;
+ default: type = LLM_TYPE_UNKNOWN;
+ }
+}
+
+void llama_model_minimax_m3::load_arch_tensors(llama_model_loader &) {
+ LLAMA_LOAD_LOCALS;
+ const int64_t n_expert_shared = hparams.n_expert_shared;
+ const int64_t n_ff_exp = hparams.n_ff_exp;
+
+ tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);
+
+ output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0);
+ output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, 0);
+
+ for (int i = 0; i < n_layer; ++i) {
+ auto & layer = layers[i];
+
+ create_tensor_qkv(layer, i, n_embd, n_embd_head_k * n_head, n_embd_gqa, n_embd_gqa, 0);
+ layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), { n_embd_head_k * n_head, n_embd }, 0);
+
+ layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);
+ // per-head QK-norm (one head_dim vector)
+ layer.attn_q_norm = create_tensor(tn(LLM_TENSOR_ATTN_Q_NORM, "weight", i), {n_embd_head_k}, 0);
+ layer.attn_k_norm = create_tensor(tn(LLM_TENSOR_ATTN_K_NORM, "weight", i), {n_embd_head_k}, 0);
+
+ // sparse-attn indexer (unused): GGML_OP_NONE -> loader skips; NOT_REQUIRED -> older GGUFs still load;
+ // SKIP_IF_VIRTUAL -> no-file loader (test-llama-archs) skips them too
+ const int64_t n_index_head = 4; // sparse_num_index_heads
+ const int64_t d_index = 128; // sparse_index_dim
+ const int idx_flags = TENSOR_NOT_REQUIRED | TENSOR_SKIP_IF_VIRTUAL;
+ create_tensor(tn(LLM_TENSOR_ATTN_INDEX_Q, "weight", i), {n_embd, n_index_head * d_index}, idx_flags);
+ create_tensor(tn(LLM_TENSOR_ATTN_INDEX_K, "weight", i), {n_embd, d_index}, idx_flags);
+ create_tensor(tn(LLM_TENSOR_ATTN_INDEX_Q_NORM, "weight", i), {d_index}, idx_flags);
+ create_tensor(tn(LLM_TENSOR_ATTN_INDEX_K_NORM, "weight", i), {d_index}, idx_flags);
+
+ layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0);
+
+ if (i < (int) hparams.n_layer_dense_lead) {
+ layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0);
+ layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), { n_ff, n_embd}, 0);
+ layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff}, 0);
+ } else {
+ layer.ffn_gate_inp = create_tensor(tn(LLM_TENSOR_FFN_GATE_INP, "weight", i), {n_embd, n_expert}, 0);
+ layer.ffn_exp_probs_b = create_tensor(tn(LLM_TENSOR_FFN_EXP_PROBS_B, "bias", i), {n_expert}, 0);
+ layer.ffn_gate_exps = create_tensor(tn(LLM_TENSOR_FFN_GATE_EXPS, "weight", i), {n_embd, n_ff_exp, n_expert}, 0);
+ layer.ffn_down_exps = create_tensor(tn(LLM_TENSOR_FFN_DOWN_EXPS, "weight", i), {n_ff_exp, n_embd, n_expert}, 0);
+ layer.ffn_up_exps = create_tensor(tn(LLM_TENSOR_FFN_UP_EXPS, "weight", i), {n_embd, n_ff_exp, n_expert}, 0);
+
+ layer.ffn_gate_shexp = create_tensor(tn(LLM_TENSOR_FFN_GATE_SHEXP, "weight", i), {n_embd, n_ff_exp * n_expert_shared}, 0);
+ layer.ffn_down_shexp = create_tensor(tn(LLM_TENSOR_FFN_DOWN_SHEXP, "weight", i), { n_ff_exp * n_expert_shared, n_embd}, 0);
+ layer.ffn_up_shexp = create_tensor(tn(LLM_TENSOR_FFN_UP_SHEXP, "weight", i), {n_embd, n_ff_exp * n_expert_shared}, 0);
+ }
+ }
+}
+
+std::unique_ptr<llm_graph_context> llama_model_minimax_m3::build_arch_graph(const llm_graph_params & params) const {
+ return std::make_unique<graph>(*this, params);
+}
+
+llama_model_minimax_m3::graph::graph(const llama_model & model, const llm_graph_params & params) : llm_graph_context(params) {
+ const int64_t n_embd_head = hparams.n_embd_head_v();
+
+ GGML_ASSERT(n_embd_head == hparams.n_embd_head_k());
+ // partial rotary: head_dim != n_rot, so don't assert n_embd_head == n_rot
+
+ ggml_tensor * cur;
+ ggml_tensor * inpL;
+
+ inpL = build_inp_embd(model.tok_embd);
+
+ ggml_tensor * inp_pos = build_inp_pos();
+ auto inp_attn = build_attn_inp_kv();
+ ggml_tensor * inp_out_ids = build_inp_out_ids();
+
+ for (int il = 0; il < n_layer; ++il) {
+ ggml_tensor * inpSA = inpL;
+
+ // self-attention
+ {
+ cur = build_norm(inpL, model.layers[il].attn_norm, NULL, LLM_NORM_RMS, il);
+ cb(cur, "attn_norm", il);
+
+ auto [Qcur, Kcur, Vcur] = build_qkv(model.layers[il], cur,
+ n_embd_head, n_head, n_head_kv, il);
+
+ // per-head QK RMSNorm (weights include Gemma +1)
+ Qcur = build_norm(Qcur, model.layers[il].attn_q_norm, NULL, LLM_NORM_RMS, il);
+ cb(Qcur, "Qcur_normed", il);
+ Kcur = build_norm(Kcur, model.layers[il].attn_k_norm, NULL, LLM_NORM_RMS, il);
+ cb(Kcur, "Kcur_normed", il);
+
+ Qcur = ggml_rope_ext(
+ ctx0, Qcur, inp_pos, nullptr,
+ n_rot, rope_type, n_ctx_orig, freq_base, freq_scale,
+ ext_factor, attn_factor, beta_fast, beta_slow
+ );
+ Kcur = ggml_rope_ext(
+ ctx0, Kcur, inp_pos, nullptr,
+ n_rot, rope_type, n_ctx_orig, freq_base, freq_scale,
+ ext_factor, attn_factor, beta_fast, beta_slow
+ );
+
+ cb(Qcur, "Qcur", il);
+ cb(Kcur, "Kcur", il);
+ cb(Vcur, "Vcur", il);
+
+ cur = build_attn(inp_attn,
+ model.layers[il].wo, NULL, model.layers[il].wo_s,
+ Qcur, Kcur, Vcur, nullptr, nullptr, nullptr, 1.0f/sqrtf(float(n_embd_head)), il);
+ }
+
+ if (il == n_layer - 1 && inp_out_ids) {
+ cur = ggml_get_rows(ctx0, cur, inp_out_ids);
+ inpSA = ggml_get_rows(ctx0, inpSA, inp_out_ids);
+ }
+
+ ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpSA);
+ cb(ffn_inp, "ffn_inp", il);
+
+ cur = build_norm(ffn_inp, model.layers[il].ffn_norm, NULL, LLM_NORM_RMS, il);
+ cb(cur, "ffn_norm", il);
+
+ if ((uint32_t) il < hparams.n_layer_dense_lead) {
+ // leading dense
+ cur = build_ffn(cur,
+ model.layers[il].ffn_up, NULL, NULL,
+ model.layers[il].ffn_gate, NULL, NULL,
+ model.layers[il].ffn_down, NULL, NULL,
+ NULL,
+ LLM_FFN_SWIGLU_OAI, LLM_FFN_PAR, il);
+ cb(cur, "ffn_out", il);
+ } else {
+ // routed experts
+ ggml_tensor * moe_out = build_moe_ffn(cur,
+ model.layers[il].ffn_gate_inp,
+ model.layers[il].ffn_up_exps,
+ model.layers[il].ffn_gate_exps,
+ model.layers[il].ffn_down_exps,
+ model.layers[il].ffn_exp_probs_b,
+ n_expert, n_expert_used,
+ LLM_FFN_SWIGLU_OAI_MOE, hparams.expert_weights_norm,
+ hparams.expert_weights_scale,
+ (llama_expert_gating_func_type) hparams.expert_gating_func,
+ il);
+ cb(moe_out, "ffn_moe_out", il);
+
+ // shared expert
+ ggml_tensor * ffn_shexp = build_ffn(cur,
+ model.layers[il].ffn_up_shexp, NULL, NULL,
+ model.layers[il].ffn_gate_shexp, NULL, NULL,
+ model.layers[il].ffn_down_shexp, NULL, NULL,
+ NULL,
+ LLM_FFN_SWIGLU_OAI, LLM_FFN_PAR, il);
+ cb(ffn_shexp, "ffn_shexp", il);
+
+ cur = ggml_add(ctx0, moe_out, ffn_shexp);
+ cb(cur, "ffn_out", il);
+ }
+
+ cur = ggml_add(ctx0, cur, ffn_inp);
+
+ cur = build_cvec(cur, il);
+ cb(cur, "l_out", il);
+
+ // input for next layer
+ inpL = cur;
+ }
+
+ cur = inpL;
+
+ cur = build_norm(cur, model.output_norm, NULL, LLM_NORM_RMS, -1);
+ cb(cur, "result_norm", -1);
+ res->t_embd = cur;
+
+ // lm_head
+ cur = build_lora_mm(model.output, cur, model.output_s);
+ cb(cur, "result_output", -1);
+ res->t_logits = cur;
+
+ ggml_build_forward_expand(gf, cur);
+}
diff --git a/src/models/models.h b/src/models/models.h
index 7a52e7b..5e2a826 100644
--- a/src/models/models.h
+++ b/src/models/models.h
@@ -1870,6 +1870,17 @@ struct llama_model_minimax_m2 : public llama_model_base {
std::unique_ptr<llm_graph_context> build_arch_graph(const llm_graph_params & params) const override;
};
+struct llama_model_minimax_m3 : public llama_model_base {
+ llama_model_minimax_m3(const struct llama_model_params & params) : llama_model_base(params) {}
+ void load_arch_hparams(llama_model_loader & ml) override;
+ void load_arch_tensors(llama_model_loader & ml) override;
+
+ struct graph : public llm_graph_context {
+ graph(const llama_model & model, const llm_graph_params & params);
+ };
+
+ std::unique_ptr<llm_graph_context> build_arch_graph(const llm_graph_params & params) const override;
+};
struct llama_model_cogvlm : public llama_model_base {
llama_model_cogvlm(const struct llama_model_params & params) : llama_model_base(params) {}
diff --git a/tests/test-llama-archs.cpp b/tests/test-llama-archs.cpp
index f39abe7..2085f43 100644
--- a/tests/test-llama-archs.cpp
+++ b/tests/test-llama-archs.cpp
@@ -352,6 +352,7 @@ static bool moe_mandatory(const llm_arch arch) {
case LLM_ARCH_LLADA_MOE:
case LLM_ARCH_GROVEMOE:
case LLM_ARCH_MINIMAX_M2:
+ case LLM_ARCH_MINIMAX_M3:
case LLM_ARCH_RND1:
case LLM_ARCH_PADDLEOCR:
case LLM_ARCH_MIMO2:

View File

@@ -1,17 +1,19 @@
#!/bin/bash
set -e
## Patches
## Apply patches from the `patches` directory
## Apply patches from the `patches` directory. Runs under set -e so a
## rejected patch aborts the build here, loudly, instead of surfacing later
## as a confusing compile error. A missing or empty patches dir is a no-op.
if [ -d "patches" ]; then
for patch in $(ls patches); do
echo "Applying patch $patch"
patch -d llama.cpp/ -p1 < patches/$patch
done
done
fi
set -e
for file in $(ls llama.cpp/tools/server/); do
cp -rfv llama.cpp/tools/server/$file llama.cpp/tools/grpc-server/
done

View File

@@ -41,6 +41,11 @@ namespace {
// per loaded model. g_mu guards (re)load against in-flight classification.
std::mutex g_mu;
pf_ctx * g_ctx = nullptr;
// The ModelOptions.Model this process loaded, compared against
// TokenClassifyRequest.ModelIdentity so a request that arrived through a stale
// distributed route is rejected rather than answered from the wrong model
// (#10952). Guarded by g_mu like the rest of the engine state.
std::string g_loaded_model_identity;
std::atomic<Server *> g_server{nullptr};
// Resolve the device string the engine expects ("cpu" / "gpu" / "cuda" /
@@ -113,17 +118,48 @@ public:
}
g_ctx = ctx;
// Record what we loaded so TokenClassify can reject a request meant
// for a different model. request->model(), not modelfile(): it is the
// value the controller also sends as ModelIdentity, and the two are
// read from the same ModelConfig.Model (#10952).
g_loaded_model_identity = request->model();
result->set_success(true);
result->set_message("privacy-filter loaded (" + device + ")");
return GStatus::OK;
}
// checkModelIdentity mirrors pkg/grpc/server.go,
// backend/python/common/model_identity.py and the llama-cpp server. In
// distributed mode a worker can recycle a stopped backend's gRPC port for
// another model's backend, and the controller's liveness-only probe cannot
// tell a stale cached route from a valid one, so the backend has to catch
// it. Either side empty means "skip": the request side is empty for a
// controller that predates the field, the loaded side when such a
// controller performed the load. A false rejection is worse than the miss.
// Callers must already hold g_mu.
GStatus checkModelIdentity(const backend::TokenClassifyRequest * request) {
if (request == nullptr || request->modelidentity().empty()) {
return GStatus::OK;
}
if (g_loaded_model_identity.empty() ||
g_loaded_model_identity == request->modelidentity()) {
return GStatus::OK;
}
// NOT_FOUND plus this exact sentinel is the cross-language contract
// the router matches on (grpcerrors.ModelMismatchSentinel).
return GStatus(StatusCode::NOT_FOUND,
"privacy-filter: model identity mismatch: loaded \"" +
g_loaded_model_identity + "\", requested \"" +
request->modelidentity() + "\"");
}
GStatus TokenClassify(ServerContext *, const backend::TokenClassifyRequest * request,
backend::TokenClassifyResponse * response) override {
std::lock_guard<std::mutex> lock(g_mu);
if (!g_ctx) {
return GStatus(StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
if (GStatus id = checkModelIdentity(request); !id.ok()) return id;
const std::string & text = request->text();
if (text.empty()) {

View File

@@ -37,6 +37,10 @@ PATCHES_DIR := $(CURRENT_MAKEFILE_DIR)/patches
define turboquant-build
rm -rf $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build
cp -rf $(LLAMA_CPP_DIR) $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build
# Drop patches vendored for upstream llama.cpp: the fork tree diverges, so
# they reject there. Fork-specific patches live in backend/cpp/turboquant/patches/
# and are applied by apply-patches.sh below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build purge
# Augment the copied grpc-server.cpp's KV-cache allow-list with the
# fork's turbo2/turbo3/turbo4 types. We patch the *copy*, never the
@@ -74,6 +78,10 @@ turboquant-fallback:
turboquant-cpu-all:
rm -rf $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build
cp -rf $(LLAMA_CPP_DIR) $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build
# Drop patches vendored for upstream llama.cpp: the fork tree diverges, so
# they reject there. Fork-specific patches live in backend/cpp/turboquant/patches/
# and are applied by apply-patches.sh below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build purge
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
$(info $(GREEN)I turboquant build info:cpu-all-variants$(RESET))

View File

@@ -10,7 +10,7 @@
# go build -o ced-grpc .
CED_VERSION?=c04ac14b7992d00584d9e812c9bb6268598a6ce7
CED_REPO?=https://github.com/mudler/ced.cpp
CED_REPO?=https://github.com/localai-org/ced.cpp
GOCMD?=go
GO_TAGS?=

View File

@@ -1,5 +1,6 @@
GOCMD=go
# Packaged as a standalone gallery backend by backend/Dockerfile.golang.
cloud-proxy:
CGO_ENABLED=0 $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o cloud-proxy ./

View File

@@ -17,7 +17,7 @@ target_link_libraries(gocrispasr PRIVATE
crispasr-lib
parakeet canary canary_ctc cohere granite_speech granite_nle
voxtral voxtral4b qwen3_asr qwen3_tts orpheus chatterbox indextts
kokoro voxcpm2_tts m2m100 t5_translate wav2vec2-ggml vibevoice
kokoro voxcpm2_tts m2m100 t5_translate wav2vec2-ggml vibevoice f5-tts
silero-lid pyannote-seg funasr paraformer sensevoice
crisp_audio
ggml)

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
CRISPASR_VERSION?=f35185b876fc482fcb2053a81a2697936ed5fcc0
CRISPASR_VERSION?=5fca47ecf05cd68bb0075f8a00fe04da06f208d0
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
@@ -60,14 +60,21 @@ sources/CrispASR:
git remote add origin $(CRISPASR_REPO) && \
git fetch origin && \
git checkout $(CRISPASR_VERSION) && \
git submodule update --init --recursive --depth 1 --single-branch
git submodule update --init --recursive --depth 1 --single-branch -- \
ggml third_party/c2pa-audio
# CrispASR's src/CMakeLists.txt locates its vendored llama.cpp
# (crispasr-llama-core, used by the chat C-ABI) via ${CMAKE_SOURCE_DIR},
# which assumes CrispASR is the top-level CMake project. We add_subdirectory
# it, so ${CMAKE_SOURCE_DIR} is THIS backend dir and the talk-llama sources
# aren't found. Rewrite to ${PROJECT_SOURCE_DIR} (the crispasr project root),
# which is correct both standalone and as a subproject. Idempotent.
sed -i.bak 's#\$${CMAKE_SOURCE_DIR}/examples/talk-llama#\$${PROJECT_SOURCE_DIR}/examples/talk-llama#' sources/CrispASR/src/CMakeLists.txt && rm -f sources/CrispASR/src/CMakeLists.txt.bak
# (crispasr-llama-core, used by the chat C-ABI), c2pa-audio submodule
# (crispasr_c2pa_native, the pure-C++ C2PA signer), and WebRTC VAD via
# ${CMAKE_SOURCE_DIR}, which assumes CrispASR is the top-level CMake
# project. We add_subdirectory it, so ${CMAKE_SOURCE_DIR} is THIS backend
# dir and those sources aren't found. Rewrite to ${PROJECT_SOURCE_DIR}
# (the crispasr project root), which is correct both standalone and as a
# subproject. Idempotent.
sed -i.bak \
-e 's#\$${CMAKE_SOURCE_DIR}/examples/talk-llama#\$${PROJECT_SOURCE_DIR}/examples/talk-llama#' \
-e 's#\$${CMAKE_SOURCE_DIR}/third_party/c2pa-audio#\$${PROJECT_SOURCE_DIR}/third_party/c2pa-audio#' \
-e 's#\$${CMAKE_SOURCE_DIR}/third_party#\$${PROJECT_SOURCE_DIR}/third_party#' \
sources/CrispASR/src/CMakeLists.txt && rm -f sources/CrispASR/src/CMakeLists.txt.bak
# Detect OS
UNAME_S := $(shell uname -s)

View File

@@ -530,11 +530,51 @@ func setVoice(voice string) {
}
}
// applyRequestVoice distinguishes named speakers from per-request reference
// WAVs. The latter are used by F5-TTS and require the exact transcript under
// the cross-backend params.ref_text contract.
func applyRequestVoice(req *pb.TTSRequest) error {
voice := strings.TrimSpace(req.Voice)
if voice == "" {
return nil
}
info, statErr := os.Stat(voice)
looksLikeFile := filepath.IsAbs(voice) || strings.EqualFold(filepath.Ext(voice), ".wav")
if statErr == nil && info.Mode().IsRegular() {
refText := ""
if req.Params != nil {
refText = strings.TrimSpace(req.Params["ref_text"])
if refText == "" {
refText = strings.TrimSpace(req.Params["voice_text"])
}
}
if refText == "" {
return fmt.Errorf("crispasr: params.ref_text is required with a reference voice WAV")
}
if rc := CppTTSSetVoiceFile(voice, refText); rc < 0 {
return fmt.Errorf("crispasr: failed to apply reference voice %q (rc=%d)", voice, rc)
}
return nil
}
if looksLikeFile {
if statErr != nil {
return fmt.Errorf("crispasr: reference voice %q: %w", voice, statErr)
}
return fmt.Errorf("crispasr: reference voice %q is not a regular file", voice)
}
setVoice(voice)
return nil
}
func (w *CrispASR) TTS(req *pb.TTSRequest) error {
if req.Dst == "" {
return fmt.Errorf("crispasr: TTS requires a destination path")
}
setVoice(req.Voice)
if err := applyRequestVoice(req); err != nil {
return err
}
pcm, err := w.synthesize(req.Text)
if err != nil {
return err
@@ -553,7 +593,9 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
if req.Text == "" {
return fmt.Errorf("crispasr: TTSStream requires text")
}
setVoice(req.Voice)
if err := applyRequestVoice(req); err != nil {
return err
}
pcm, err := w.synthesize(req.Text)
if err != nil {
return err

View File

@@ -172,6 +172,32 @@ var _ = Describe("CrispASR", func() {
})
Context("TTS", func() {
It("applies a per-request reference WAV and transcript", func() {
refWAV := filepath.Join(GinkgoT().TempDir(), "reference.wav")
Expect(os.WriteFile(refWAV, []byte("fixture"), 0o600)).To(Succeed())
original := CppTTSSetVoiceFile
DeferCleanup(func() { CppTTSSetVoiceFile = original })
var gotPath, gotText string
CppTTSSetVoiceFile = func(path, refText string) int {
gotPath, gotText = path, refText
return 0
}
Expect(applyRequestVoice(&pb.TTSRequest{
Voice: refWAV,
Params: map[string]string{"ref_text": "The exact words in the clip."},
})).To(Succeed())
Expect(gotPath).To(Equal(refWAV))
Expect(gotText).To(Equal("The exact words in the clip."))
})
It("rejects a reference WAV without a transcript", func() {
refWAV := filepath.Join(GinkgoT().TempDir(), "reference.wav")
Expect(os.WriteFile(refWAV, []byte("fixture"), 0o600)).To(Succeed())
Expect(applyRequestVoice(&pb.TTSRequest{Voice: refWAV})).To(MatchError(ContainSubstring("params.ref_text")))
})
It("synthesizes a non-empty WAV", func() {
ttsModel := ttsModelOrSkip()
ensureLibLoaded()
@@ -189,5 +215,32 @@ var _ = Describe("CrispASR", func() {
Expect(info.Size()).To(BeNumerically(">", 1024),
"expected a non-trivial WAV, got %d bytes", info.Size())
})
It("synthesizes with F5-TTS voice cloning (reference WAV + transcript)", func() {
// F5-TTS has no baked speaker: it clones from a reference WAV and
// its transcript, supplied via the voice/voice_text options. The
// spec skips unless all three fixtures are provided.
model := os.Getenv("CRISPASR_F5_MODEL_PATH")
refWav := os.Getenv("CRISPASR_F5_REF_WAV")
refText := os.Getenv("CRISPASR_F5_REF_TEXT")
if model == "" || refWav == "" || refText == "" {
Skip("set CRISPASR_F5_MODEL_PATH, CRISPASR_F5_REF_WAV and CRISPASR_F5_REF_TEXT to run this spec")
}
ensureLibLoaded()
w := &CrispASR{}
Expect(w.Load(&pb.ModelOptions{
ModelFile: model,
Options: []string{"voice:" + refWav, "voice_text:" + refText},
})).To(Succeed())
dst := filepath.Join(GinkgoT().TempDir(), "f5.wav")
Expect(w.TTS(&pb.TTSRequest{Text: "Hello from LocalAI running F5 text to speech.", Dst: dst})).To(Succeed())
info, err := os.Stat(dst)
Expect(err).ToNot(HaveOccurred(), "synthesized WAV should exist at %q", dst)
Expect(info.Size()).To(BeNumerically(">", 1024),
"expected a non-trivial WAV, got %d bytes", info.Size())
})
})
})

View File

@@ -25,7 +25,7 @@ fi
# Depth estimation needs real content; a synthetic image would be degenerate.
TEST_IMAGE_DIR="$CURDIR/test-data"
TEST_IMAGE_FILE="$TEST_IMAGE_DIR/test.jpg"
TEST_IMAGE_URL="${TEST_IMAGE_URL:-https://raw.githubusercontent.com/mudler/rf-detr.cpp/main/tests/fixtures/ci/test_image.jpg}"
TEST_IMAGE_URL="${TEST_IMAGE_URL:-https://raw.githubusercontent.com/localai-org/rf-detr.cpp/main/tests/fixtures/ci/test_image.jpg}"
mkdir -p "$TEST_IMAGE_DIR"
if [ ! -f "$TEST_IMAGE_FILE" ]; then

View File

@@ -22,6 +22,7 @@ import (
"fmt"
"math"
"slices"
"strings"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
@@ -57,12 +58,18 @@ func NewStore() *Store {
}
}
// Load is a no-op — local-store has no on-disk artefact. opts.Model is
// just a namespace identifier; isolation is already handled upstream
// (ModelLoader spawns a fresh local-store process per (backend,
// model) tuple, so each namespace is its own Store{} instance).
// Load only validates the namespace — local-store has no on-disk
// artefact. opts.Model is a namespace identifier, which core's
// StoreBackend always sends with store.NamespacePrefix; anything else
// is the model loader's greedy autoload probing with a real model name,
// which must be refused or the LLM binds to the vector store. Isolation
// is already handled upstream (ModelLoader spawns a fresh local-store
// process per (backend, model) tuple, so each namespace is its own
// Store{} instance).
func (s *Store) Load(opts *pb.ModelOptions) error {
_ = opts
if !strings.HasPrefix(opts.GetModel(), store.NamespacePrefix) {
return fmt.Errorf("local-store: refusing to load %q: not a store namespace (expected %q prefix)", opts.GetModel(), store.NamespacePrefix)
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"testing"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/store"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -186,8 +187,18 @@ var _ = Describe("StoresFind", func() {
})
var _ = Describe("StoresLoad", func() {
It("is a no-op", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: "any-namespace"})).To(Succeed())
It("accepts prefixed store namespaces", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: store.NamespacePrefix + "any-namespace"})).To(Succeed())
})
It("accepts the prefix alone (default store)", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: store.NamespacePrefix})).To(Succeed())
})
It("refuses model names without the namespace prefix", func() {
err := NewStore().Load(&pb.ModelOptions{Model: "some-llm.gguf"})
Expect(err).To(MatchError(ContainSubstring("not a store namespace")))
Expect(NewStore().Load(&pb.ModelOptions{})).NotTo(Succeed())
})
})

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?=92c1682da792c1e8a5dec91acc2be4b02c742ded
LOCATEANYTHING_VERSION?=ade2634f7f79b56121125e5885628744795a478f
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF

View File

@@ -27,7 +27,7 @@ fi
# synthetic image would trivially yield zero detections.
TEST_IMAGE_DIR="$CURDIR/test-data"
TEST_IMAGE_FILE="$TEST_IMAGE_DIR/test.jpg"
TEST_IMAGE_URL="${TEST_IMAGE_URL:-https://raw.githubusercontent.com/mudler/rf-detr.cpp/main/tests/fixtures/ci/test_image.jpg}"
TEST_IMAGE_URL="${TEST_IMAGE_URL:-https://raw.githubusercontent.com/localai-org/rf-detr.cpp/main/tests/fixtures/ci/test_image.jpg}"
mkdir -p "$TEST_IMAGE_DIR"
if [ ! -f "$TEST_IMAGE_FILE" ]; then

View File

@@ -0,0 +1,12 @@
.cache/
sources/
build/
package/
moss-transcribe-cpp-grpc
# build artifacts staged in-tree by the Makefile (cp from sources/) or
# symlinked for local dev; the real sources live in moss-transcribe.cpp upstream.
*.so
*.so.*
*.dylib
moss_transcribe_capi.h
compile_commands.json

View File

@@ -0,0 +1,98 @@
# moss-transcribe-cpp backend Makefile.
#
# Upstream pin lives below as MOSS_VERSION?=190a569c13b4b247450f2fb3b2a431244e84833e
# (.github/bump_deps.sh) can find and update it - matches the
# whisper.cpp / parakeet-cpp / ds4 convention.
#
# Local dev shortcut: if you already have an out-of-tree moss-transcribe.cpp
# build, you can symlink the .so + header into this directory and skip the
# clone/cmake steps entirely, e.g.:
#
# ln -sf /path/to/moss-transcribe.cpp/build-shared/libmoss-transcribe.so .
# ln -sf /path/to/moss-transcribe.cpp/include/moss_transcribe_capi.h .
# go build -o moss-transcribe-cpp-grpc .
#
# That's what the L0 smoke test uses. The default target below does the proper
# clone-at-pin + cmake build so CI doesn't need a side-checkout.
MOSS_VERSION?=190a569c13b4b247450f2fb3b2a431244e84833e
MOSS_REPO?=https://github.com/localai-org/moss-transcribe.cpp
GOCMD?=go
GO_TAGS?=
JOBS?=$(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
BUILD_TYPE?=
NATIVE?=false
# Build ggml statically into libmoss-transcribe.so (PIC) so the shared lib is
# self-contained: dlopen needs no libggml*.so alongside it, only system libs
# (libstdc++/libgomp/libc) that the runtime image already provides. MT_SHARED
# flips ggml to a static PIC build (see the moss-transcribe.cpp CMakeLists).
CMAKE_ARGS?=-DCMAKE_BUILD_TYPE=Release -DMT_SHARED=ON -DMT_BUILD_CLI=OFF -DBUILD_SHARED_LIBS=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
endif
# moss-transcribe.cpp gates its GGML backends behind MT_GGML_* options and does
# set(GGML_<be> ${MT_GGML_<be>} CACHE BOOL "" FORCE), so a bare -DGGML_CUDA=ON is
# overwritten back to OFF and the build silently falls back to CPU. Forward the
# MT_GGML_* options instead (openblas is not gated, so -DGGML_BLAS passes through).
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS+=-DMT_GGML_CUDA=ON -DGGML_CUDA_GRAPHS=ON
else ifeq ($(BUILD_TYPE),openblas)
CMAKE_ARGS+=-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DMT_GGML_HIP=ON
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DMT_GGML_VULKAN=ON
else ifeq ($(BUILD_TYPE),metal)
CMAKE_ARGS+=-DMT_GGML_METAL=ON
endif
.PHONY: moss-transcribe-cpp-grpc package build clean purge test all
all: moss-transcribe-cpp-grpc
# Clone the upstream moss-transcribe.cpp source at the pinned commit. Directory
# acts as the target so make only re-clones when missing. After a MOSS_VERSION
# bump, run 'make purge && make' to refetch.
sources/moss-transcribe.cpp:
mkdir -p sources/moss-transcribe.cpp
cd sources/moss-transcribe.cpp && \
git init -q && \
git remote add origin $(MOSS_REPO) && \
git fetch --depth 1 origin $(MOSS_VERSION) && \
git checkout FETCH_HEAD && \
git submodule update --init --recursive --depth 1 --single-branch
# Build the shared lib + header out-of-tree, then stage them next to the Go
# sources so purego.Dlopen("libmoss-transcribe.so") and the cgo-less build both
# pick them up.
libmoss-transcribe.so: sources/moss-transcribe.cpp
cmake -B sources/moss-transcribe.cpp/build-shared -S sources/moss-transcribe.cpp $(CMAKE_ARGS)
cmake --build sources/moss-transcribe.cpp/build-shared --config Release -j$(JOBS)
cp -fv sources/moss-transcribe.cpp/build-shared/libmoss-transcribe.so* ./ 2>/dev/null || true
cp -fv sources/moss-transcribe.cpp/build-shared/libmoss-transcribe.dylib ./ 2>/dev/null || true
cp -fv sources/moss-transcribe.cpp/include/moss_transcribe_capi.h ./
moss-transcribe-cpp-grpc: libmoss-transcribe.so main.go gomosstranscribecpp.go segments.go
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o moss-transcribe-cpp-grpc .
package: moss-transcribe-cpp-grpc
bash package.sh
build: package
# Test target. The model-backed smoke test is gated on
# MOSS_BACKEND_TEST_MODEL + MOSS_BACKEND_TEST_WAV; without them that spec
# auto-skips, leaving the pure-Go parser/unit tests.
test:
LD_LIBRARY_PATH=$(CURDIR):$$LD_LIBRARY_PATH $(GOCMD) test ./... -count=1
clean: purge
rm -rf libmoss-transcribe.so* moss_transcribe_capi.h package moss-transcribe-cpp-grpc
purge:
rm -rf sources/moss-transcribe.cpp

View File

@@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/utils"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// purego-bound entry points from libmoss-transcribe.so. Names match
// moss_transcribe_capi.h exactly so a `nm libmoss-transcribe.so | grep
// moss_transcribe_capi` is enough to spot drift.
//
// The transcribe_* functions return char* declared here as uintptr so we can
// call moss_transcribe_capi_free_string on the same pointer after copying: the
// C-API contract is "caller owns and must free the returned buffer".
var (
CppAbiVersion func() int32
CppLoad func(ggufPath string) uintptr
CppFree func(ctx uintptr)
CppTranscribePath func(ctx uintptr, wavPath string, maxNew int32) uintptr
CppTranscribePcm func(ctx uintptr, samples []float32, nSamples int32, sampleRate int32, maxNew int32) uintptr
CppFreeString func(s uintptr)
CppLastError func(ctx uintptr) string
)
// MossTranscribeCpp owns a single loaded moss_transcribe_ctx. MOSS is an
// offline transcription + diarization + timestamping engine: one model, one
// context, no streaming. The C engine holds a single mutable context and is
// not reentrant, so we embed base.SingleThread — LocalAI serialises every RPC
// through the server-level lock, and only one transcription touches the engine
// at a time.
type MossTranscribeCpp struct {
base.SingleThread
ctxPtr uintptr
maxNew int32
}
// Load is the LocalAI gRPC entry point for LoadModel: it calls
// moss_transcribe_capi_load with the GGUF path and stashes the resulting
// opaque context pointer for AudioTranscription.
func (m *MossTranscribeCpp) Load(opts *pb.ModelOptions) error {
if opts.ModelFile == "" {
return errors.New("moss-transcribe-cpp: ModelFile is required")
}
// max_new_tokens caps the generated tokens per transcription; <=0 uses the
// GGUF's default_max_new_tokens (the C-API's own default). Exposed as a
// model YAML option: (key:value form, like the sibling ggml backends).
m.maxNew = int32(optInt(opts, "max_new_tokens", 0))
ctx := CppLoad(opts.ModelFile)
if ctx == 0 {
// No ctx to ask for last_error (the C-API's last-error buffer lives on
// the ctx that was never returned). Surface the path so the operator at
// least knows which load failed.
return fmt.Errorf("moss-transcribe-cpp: moss_transcribe_capi_load failed for %q", opts.ModelFile)
}
m.ctxPtr = ctx
return nil
}
// optInt reads an integer model option (key:value form) from ModelOptions,
// returning def when absent or unparseable. The options array carries the
// model YAML's options: entries (see core/config; siblings such as parakeet-cpp
// parse the same key:value form via strings.Cut on ":").
func optInt(opts *pb.ModelOptions, key string, def int) int {
for _, o := range opts.GetOptions() {
k, v, ok := strings.Cut(o, ":")
if ok && strings.TrimSpace(k) == key {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return n
}
}
}
return def
}
// AudioTranscription converts the audio at opts.Dst to a 16 kHz mono WAV and
// hands the path to moss_transcribe_capi_transcribe_path. The model emits its
// own speaker-labelled, time-aligned transcript in the compact
// "[start][Sxx]text[end]..." format (seconds); we parse it into LocalAI
// TranscriptSegments carrying int64-nanosecond timestamps and the per-segment
// speaker label.
//
// MOSS does joint transcription + diarization + timestamps in one pass, so
// translate/language/prompt/temperature/threads are not applicable and are
// ignored. Streaming is not supported (offline model).
func (m *MossTranscribeCpp) AudioTranscription(ctx context.Context, opts *pb.TranscriptRequest) (pb.TranscriptResult, error) {
if m.ctxPtr == 0 {
return pb.TranscriptResult{}, grpcerrors.ModelNotLoaded("moss-transcribe-cpp")
}
if opts.Dst == "" {
return pb.TranscriptResult{}, errors.New("moss-transcribe-cpp: TranscriptRequest.dst (audio path) is required")
}
if err := ctx.Err(); err != nil {
return pb.TranscriptResult{}, status.Error(codes.Canceled, "transcription cancelled")
}
// The C loader understands WAV; convert any input (MP3, etc.) to 16 kHz
// mono WAV first - the same normalisation every other audio backend
// (whisper, parakeet-cpp) does via utils.AudioToWav before handing the file
// to the engine.
converted, cleanup, err := convertToWavMono16k(opts.Dst)
if err != nil {
return pb.TranscriptResult{}, err
}
defer cleanup()
cstr := CppTranscribePath(m.ctxPtr, converted, m.maxNew)
if cstr == 0 {
return pb.TranscriptResult{}, fmt.Errorf("moss-transcribe-cpp: transcribe_path failed: %s", CppLastError(m.ctxPtr))
}
raw := goStringFromCPtr(cstr)
CppFreeString(cstr)
return transcriptResultFromRaw(raw), nil
}
// Free releases the underlying moss_transcribe_ctx. Called by LocalAI when the
// model is unloaded.
func (m *MossTranscribeCpp) Free() error {
if m.ctxPtr != 0 {
CppFree(m.ctxPtr)
m.ctxPtr = 0
}
return nil
}
// convertToWavMono16k converts an arbitrary audio file to a 16 kHz mono WAV in
// a fresh temp dir and returns the path together with a cleanup func the caller
// must defer. WAV inputs already at 16 kHz/mono/16-bit are passed through by
// utils.AudioToWav (hardlink/copy), everything else is transcoded via ffmpeg.
func convertToWavMono16k(path string) (string, func(), error) {
dir, err := os.MkdirTemp("", "moss-transcribe")
if err != nil {
return "", func() {}, err
}
cleanup := func() { _ = os.RemoveAll(dir) }
converted := filepath.Join(dir, "converted.wav")
if err := utils.AudioToWav(path, converted); err != nil {
cleanup()
return "", func() {}, err
}
return converted, cleanup, nil
}
// goStringFromCPtr copies a NUL-terminated C string into Go memory. cptr is the
// raw pointer returned by purego from the C-API (a malloc'd buffer the caller
// owns); callers must free it via CppFreeString after the copy lands.
//
// The uintptr->unsafe.Pointer conversion below trips go vet's unsafeptr check,
// which can't distinguish a C-owned heap pointer from Go-managed memory. It is
// safe here: the pointer addresses a malloc'd C buffer the Go GC neither tracks
// nor moves, and we dereference it immediately to copy the bytes out (the same
// pattern the whisper / parakeet-cpp backends use).
func goStringFromCPtr(cptr uintptr) string {
if cptr == 0 {
return ""
}
p := unsafe.Pointer(cptr) //nolint:govet // C-owned malloc'd buffer, not Go-GC memory (see doc above)
n := 0
for *(*byte)(unsafe.Add(p, n)) != 0 {
n++
}
return string(unsafe.Slice((*byte)(p), n))
}

View File

@@ -0,0 +1,164 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/ebitengine/purego"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMossTranscribeCpp(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "moss-transcribe-cpp Backend Suite")
}
var (
libLoadOnce sync.Once
libLoadErr error
)
// ensureLibLoaded mirrors main.go's bootstrap so a Go test can drive the C-API
// bridge without spinning up the gRPC server. Skips the current spec when
// libmoss-transcribe.so isn't loadable from cwd ($LD_LIBRARY_PATH or a symlink
// in ./).
func ensureLibLoaded() {
libLoadOnce.Do(func() {
libName := os.Getenv("MOSS_TRANSCRIBE_LIBRARY")
if libName == "" {
libName = "libmoss-transcribe.so"
}
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
libLoadErr = err
return
}
purego.RegisterLibFunc(&CppAbiVersion, lib, "moss_transcribe_capi_abi_version")
purego.RegisterLibFunc(&CppLoad, lib, "moss_transcribe_capi_load")
purego.RegisterLibFunc(&CppFree, lib, "moss_transcribe_capi_free")
purego.RegisterLibFunc(&CppTranscribePath, lib, "moss_transcribe_capi_transcribe_path")
purego.RegisterLibFunc(&CppTranscribePcm, lib, "moss_transcribe_capi_transcribe_pcm")
purego.RegisterLibFunc(&CppFreeString, lib, "moss_transcribe_capi_free_string")
purego.RegisterLibFunc(&CppLastError, lib, "moss_transcribe_capi_last_error")
})
if libLoadErr != nil {
Skip("libmoss-transcribe.so not loadable: " + libLoadErr.Error())
}
}
// fixturesOrSkip returns the model + audio paths or skips the spec if either
// env var is unset. The smoke test never runs in default CI; it needs a real
// MOSS GGUF and a WAV on disk.
func fixturesOrSkip() (string, string) {
modelPath := os.Getenv("MOSS_BACKEND_TEST_MODEL")
audioPath := os.Getenv("MOSS_BACKEND_TEST_WAV")
if modelPath == "" || audioPath == "" {
Skip("set MOSS_BACKEND_TEST_MODEL and MOSS_BACKEND_TEST_WAV to run this spec")
}
return modelPath, audioPath
}
// writeMono16kWav writes `samples` frames of 16 kHz mono 16-bit silence to
// path. The result is already in AudioToWav's target format, so the conversion
// helper copies it through without invoking ffmpeg.
func writeMono16kWav(path string, samples int) {
GinkgoHelper()
f, err := os.Create(path)
Expect(err).ToNot(HaveOccurred())
enc := wav.NewEncoder(f, 16000, 16, 1, 1)
buf := &audio.IntBuffer{
Format: &audio.Format{NumChannels: 1, SampleRate: 16000},
SourceBitDepth: 16,
Data: make([]int, samples),
}
Expect(enc.Write(buf)).To(Succeed())
Expect(enc.Close()).To(Succeed())
Expect(f.Close()).To(Succeed())
}
var _ = Describe("MossTranscribeCpp", func() {
Context("ABI / load smoke (needs libmoss-transcribe.so)", func() {
It("reports a positive ABI version", func() {
ensureLibLoaded()
Expect(CppAbiVersion()).To(BeNumerically(">=", 1))
})
It("transcribes a WAV into speaker-labelled segments", func() {
modelPath, audioPath := fixturesOrSkip()
ensureLibLoaded()
m := &MossTranscribeCpp{}
Expect(m.Load(&pb.ModelOptions{ModelFile: modelPath})).To(Succeed())
defer func() { _ = m.Free() }()
res, err := m.AudioTranscription(context.Background(), &pb.TranscriptRequest{
Dst: audioPath,
})
Expect(err).ToNot(HaveOccurred())
Expect(strings.TrimSpace(res.Text)).ToNot(BeEmpty(),
"expected non-empty transcript for %s", audioPath)
Expect(res.Segments).ToNot(BeEmpty(), "expected at least one segment")
var prevEnd int64
for i, seg := range res.Segments {
Expect(strings.TrimSpace(seg.Text)).ToNot(BeEmpty(),
"segment %d must have text", i)
Expect(seg.End).To(BeNumerically(">=", seg.Start),
"segment %d end must not precede its start", i)
Expect(seg.Start).To(BeNumerically(">=", prevEnd),
"segments must be in time order")
prevEnd = seg.End
}
})
})
Context("Load validation", func() {
It("rejects an empty ModelFile", func() {
m := &MossTranscribeCpp{}
Expect(m.Load(&pb.ModelOptions{})).To(HaveOccurred())
})
})
Context("AudioTranscription guards (no C library required)", func() {
It("returns ModelNotLoaded when no context is loaded", func() {
m := &MossTranscribeCpp{}
_, err := m.AudioTranscription(context.Background(), &pb.TranscriptRequest{Dst: "x.wav"})
Expect(err).To(HaveOccurred())
})
It("errors when the audio path is empty", func() {
m := &MossTranscribeCpp{ctxPtr: 1}
_, err := m.AudioTranscription(context.Background(), &pb.TranscriptRequest{})
Expect(err).To(HaveOccurred())
})
})
Context("convertToWavMono16k", func() {
It("returns a decodable 16kHz mono WAV copy and cleans it up", func() {
dir := GinkgoT().TempDir()
src := filepath.Join(dir, "input.wav")
writeMono16kWav(src, 16000) // 1s of silence at 16 kHz
converted, cleanup, err := convertToWavMono16k(src)
Expect(err).ToNot(HaveOccurred())
Expect(converted).ToNot(Equal(src))
Expect(converted).To(BeAnExistingFile())
cleanup()
Expect(converted).ToNot(BeAnExistingFile(), "cleanup removes the temp dir")
})
It("errors on a non-existent input rather than passing the path through", func() {
_, _, err := convertToWavMono16k(filepath.Join(GinkgoT().TempDir(), "missing.mp3"))
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -0,0 +1,70 @@
package main
// Started internally by LocalAI - one gRPC server per loaded model.
//
// Loads the moss-transcribe shared library via purego and registers the flat
// C-API entry points declared in moss_transcribe_capi.h. The library name can
// be overridden with MOSS_TRANSCRIBE_LIBRARY (mirrors the WHISPER_LIBRARY /
// PARAKEET_LIBRARY convention in the sibling backends); the default looks next
// to this binary for libmoss-transcribe.so on Linux and
// libmoss-transcribe.dylib on macOS.
import (
"flag"
"fmt"
"os"
"runtime"
"github.com/ebitengine/purego"
grpc "github.com/mudler/LocalAI/pkg/grpc"
)
var (
addr = flag.String("addr", "localhost:50051", "the address to connect to")
)
type LibFuncs struct {
FuncPtr any
Name string
}
func main() {
libName := os.Getenv("MOSS_TRANSCRIBE_LIBRARY")
if libName == "" {
if runtime.GOOS == "darwin" {
libName = "libmoss-transcribe.dylib"
} else {
libName = "libmoss-transcribe.so"
}
}
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
panic(fmt.Errorf("moss-transcribe-cpp: dlopen %q: %w", libName, err))
}
// Bound 1:1 to moss_transcribe_capi.h. The transcribe_* entry points return
// a malloc'd char* the caller owns; we register those as uintptr so we get
// the raw pointer back and can call moss_transcribe_capi_free_string on it
// (purego's string return would copy and forget the original pointer,
// leaking it on every call).
libFuncs := []LibFuncs{
{&CppAbiVersion, "moss_transcribe_capi_abi_version"},
{&CppLoad, "moss_transcribe_capi_load"},
{&CppFree, "moss_transcribe_capi_free"},
{&CppTranscribePath, "moss_transcribe_capi_transcribe_path"},
{&CppTranscribePcm, "moss_transcribe_capi_transcribe_pcm"},
{&CppFreeString, "moss_transcribe_capi_free_string"},
{&CppLastError, "moss_transcribe_capi_last_error"},
}
for _, lf := range libFuncs {
purego.RegisterLibFunc(lf.FuncPtr, lib, lf.Name)
}
fmt.Fprintf(os.Stderr, "[moss-transcribe-cpp] ABI=%d\n", CppAbiVersion())
flag.Parse()
if err := grpc.StartServer(*addr, &MossTranscribeCpp{}); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,71 @@
#!/bin/bash
#
# Bundle the moss-transcribe-cpp-grpc binary, libmoss-transcribe.so, the core
# runtime libs (libc/libstdc++/libgomp + ld.so) and the GPU runtime for the
# active BUILD_TYPE so the package is self-contained. Mirrors
# backend/go/parakeet-cpp/package.sh; run.sh routes the (CGO_ENABLED=0) binary
# through lib/ld.so so the packaged libc is used instead of the host's.
set -e
CURDIR=$(dirname "$(realpath "$0")")
REPO_ROOT="${CURDIR}/../../.."
mkdir -p "$CURDIR/package/lib"
cp -avf "$CURDIR/moss-transcribe-cpp-grpc" "$CURDIR/package/"
cp -avf "$CURDIR/run.sh" "$CURDIR/package/"
# libmoss-transcribe shared lib + any soname symlinks. On Linux this is
# libmoss-transcribe.so[.X.Y]; on macOS it is libmoss-transcribe.dylib.
# purego.Dlopen resolves it via the *_LIBRARY_PATH that run.sh points at lib/.
cp -avf "$CURDIR"/libmoss-transcribe.so* "$CURDIR/package/lib/" 2>/dev/null || true
cp -avf "$CURDIR"/libmoss-transcribe.dylib "$CURDIR/package/lib/" 2>/dev/null || true
if ! ls "$CURDIR"/package/lib/libmoss-transcribe.* >/dev/null 2>&1; then
echo "ERROR: libmoss-transcribe shared library not found in $CURDIR, run 'make' first" >&2
exit 1
fi
# Detect architecture and copy the core runtime libs libmoss-transcribe.so links
# against, plus the matching dynamic loader as lib/ld.so.
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$CURDIR/package/lib/ld.so"
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 "$CURDIR/package/lib/libc.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 "$CURDIR/package/lib/libgcc_s.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 "$CURDIR/package/lib/libstdc++.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 "$CURDIR/package/lib/libm.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 "$CURDIR/package/lib/libgomp.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 "$CURDIR/package/lib/libdl.so.2"
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 "$CURDIR/package/lib/librt.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 "$CURDIR/package/lib/libpthread.so.0"
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 "$CURDIR/package/lib/ld.so"
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 "$CURDIR/package/lib/libc.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 "$CURDIR/package/lib/libgcc_s.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 "$CURDIR/package/lib/libstdc++.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 "$CURDIR/package/lib/libm.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 "$CURDIR/package/lib/libgomp.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 "$CURDIR/package/lib/libdl.so.2"
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 "$CURDIR/package/lib/librt.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 "$CURDIR/package/lib/libpthread.so.0"
elif [ "$(uname -s)" = "Darwin" ]; then
echo "Detected Darwin — system frameworks linked dynamically, no bundled libs needed"
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries (CUDA/ROCm/Intel/Vulkan loader + ICDs + drivers) based
# on BUILD_TYPE so the backend can reach the GPU without the runtime base image
# shipping those drivers.
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah "$CURDIR/package/" "$CURDIR/package/lib/"

View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
CURDIR=$(dirname "$(realpath "$0")")
if [ "$(uname)" = "Darwin" ]; then
export DYLD_LIBRARY_PATH="$CURDIR/lib:"$CURDIR":${DYLD_LIBRARY_PATH:-}"
export MOSS_TRANSCRIBE_LIBRARY="$CURDIR/lib/libmoss-transcribe.dylib"
else
export LD_LIBRARY_PATH="$CURDIR/lib:"$CURDIR":${LD_LIBRARY_PATH:-}"
export MOSS_TRANSCRIBE_LIBRARY="$CURDIR/lib/libmoss-transcribe.so"
fi
# If a self-contained ld.so was packaged, route through it so the packaged libc
# / libstdc++ are used instead of the host's (matches the whisper / parakeet-cpp
# backends' runtime layout). Linux only.
if [ -f "$CURDIR/lib/ld.so" ]; then
echo "Using lib/ld.so"
exec "$CURDIR/lib/ld.so" "$CURDIR/moss-transcribe-cpp-grpc" "$@"
fi
exec "$CURDIR/moss-transcribe-cpp-grpc" "$@"

View File

@@ -0,0 +1,156 @@
package main
import (
"regexp"
"strconv"
"strings"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// mossSegment is one parsed unit of the MOSS transcript: a speaker-labelled
// span with fractional-second start/end times, straight from the model's own
// output. Timestamps stay in seconds here; secondsToNanos converts them at the
// TranscriptSegment boundary.
type mossSegment struct {
Start float64
End float64
Speaker string
Text string
}
// bracketRe matches one "[...]" token (timestamp or speaker tag). The MOSS
// transcript is a concatenation of "[start][Sxx]text[end]" segments, e.g.
//
// [0.28][S01] And so, my fellow Americans,[7.71][8.12][S02] ask ...[10.59]
//
// so the bracketed tokens carry all the structure and the free text lives
// between a speaker tag and the following (end) timestamp.
var bracketRe = regexp.MustCompile(`\[([^\]]*)\]`)
// speakerRe matches a speaker tag: an 'S' (any case) followed by one or more
// digits, e.g. "S01", "S12". Anything else in brackets that isn't a speaker tag
// is treated as a timestamp candidate.
var speakerRe = regexp.MustCompile(`^[Ss][0-9]+$`)
// bracketToken is a "[...]" token plus the free text that follows it up to the
// next token (or end of string). For a speaker tag that trailing text is the
// segment's transcript.
type bracketToken struct {
content string
textAfter string
}
// parseTranscript parses the compact "[start][Sxx]text[end]..." MOSS transcript
// into structured segments. It walks the bracket tokens looking for a
// start-timestamp, a speaker tag, and an end-timestamp, taking the text that
// follows the speaker tag as the segment transcript. Tokens that don't fit the
// grammar are skipped rather than aborting the parse, so a slightly malformed
// stream still yields the segments it can.
func parseTranscript(raw string) []mossSegment {
locs := bracketRe.FindAllStringSubmatchIndex(raw, -1)
toks := make([]bracketToken, len(locs))
for i, m := range locs {
// m[2]:m[3] is the capture group (the content inside the brackets);
// m[1] is the byte just past the closing ']'.
nextStart := len(raw)
if i+1 < len(locs) {
nextStart = locs[i+1][0]
}
toks[i] = bracketToken{
content: raw[m[2]:m[3]],
textAfter: raw[m[1]:nextStart],
}
}
var segs []mossSegment
i := 0
for i < len(toks) {
start, ok := parseTimestamp(toks[i].content)
if !ok {
i++
continue
}
// A start timestamp must be followed by a speaker tag; otherwise this
// isn't a segment head, so skip it.
if i+1 >= len(toks) || !isSpeakerTag(toks[i+1].content) {
i++
continue
}
speaker := strings.ToUpper(strings.TrimSpace(toks[i+1].content))
text := strings.TrimSpace(toks[i+1].textAfter)
// The end timestamp is the next token if present and numeric; a segment
// that runs to the end of the stream without a closing timestamp falls
// back to start==end.
end := start
consumed := 2
if i+2 < len(toks) {
if e, ok := parseTimestamp(toks[i+2].content); ok {
end = e
consumed = 3
}
}
segs = append(segs, mossSegment{Start: start, End: end, Speaker: speaker, Text: text})
i += consumed
}
return segs
}
// isSpeakerTag reports whether the bracket content is a MOSS speaker tag ("Sxx").
func isSpeakerTag(content string) bool {
return speakerRe.MatchString(strings.TrimSpace(content))
}
// parseTimestamp parses a bracket content as a fractional-second timestamp. A
// speaker tag ("S01") never parses as a float, so this cleanly distinguishes
// the two token kinds.
func parseTimestamp(content string) (float64, bool) {
f, err := strconv.ParseFloat(strings.TrimSpace(content), 64)
if err != nil {
return 0, false
}
return f, true
}
// secondsToNanos converts the transcript's fractional-second timestamps into
// the int64 nanoseconds LocalAI carries on TranscriptSegment, the same
// nanosecond convention the whisper / parakeet-cpp backends use.
func secondsToNanos(sec float64) int64 {
return int64(sec * 1e9)
}
// transcriptResultFromRaw parses the raw MOSS transcript and shapes it into a
// TranscriptResult. Each parsed segment becomes a TranscriptSegment with
// nanosecond start/end and the model's own speaker label; Text is the segments
// joined with single spaces. When nothing parses (no bracket structure) it
// falls back to a single whole-clip text segment so callers always get a
// transcript.
func transcriptResultFromRaw(raw string) pb.TranscriptResult {
segs := parseTranscript(raw)
if len(segs) == 0 {
text := strings.TrimSpace(raw)
return pb.TranscriptResult{
Text: text,
Segments: []*pb.TranscriptSegment{{Id: 0, Text: text}},
}
}
var full strings.Builder
pbSegs := make([]*pb.TranscriptSegment, 0, len(segs))
for id, s := range segs {
if id > 0 && s.Text != "" {
full.WriteString(" ")
}
full.WriteString(s.Text)
pbSegs = append(pbSegs, &pb.TranscriptSegment{
Id: int32(id),
Start: secondsToNanos(s.Start),
End: secondsToNanos(s.End),
Text: s.Text,
Speaker: s.Speaker,
})
}
return pb.TranscriptResult{Text: strings.TrimSpace(full.String()), Segments: pbSegs}
}

View File

@@ -0,0 +1,104 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parseTranscript ([start][Sxx]text[end] grammar)", func() {
It("parses the README two-speaker example into ordered segments", func() {
raw := "[0.28][S01] And so, my fellow Americans, ask not what your country can do for you,[7.71][8.12][S02] ask what you can do for your country.[10.59]"
segs := parseTranscript(raw)
Expect(segs).To(HaveLen(2))
Expect(segs[0].Speaker).To(Equal("S01"))
Expect(segs[0].Start).To(BeNumerically("~", 0.28, 1e-9))
Expect(segs[0].End).To(BeNumerically("~", 7.71, 1e-9))
Expect(segs[0].Text).To(Equal("And so, my fellow Americans, ask not what your country can do for you,"))
Expect(segs[1].Speaker).To(Equal("S02"))
Expect(segs[1].Start).To(BeNumerically("~", 8.12, 1e-9))
Expect(segs[1].End).To(BeNumerically("~", 10.59, 1e-9))
Expect(segs[1].Text).To(Equal("ask what you can do for your country."))
})
It("parses a single segment", func() {
segs := parseTranscript("[1.50][S01] hello world[3.25]")
Expect(segs).To(HaveLen(1))
Expect(segs[0].Start).To(BeNumerically("~", 1.50, 1e-9))
Expect(segs[0].End).To(BeNumerically("~", 3.25, 1e-9))
Expect(segs[0].Speaker).To(Equal("S01"))
Expect(segs[0].Text).To(Equal("hello world"))
})
It("uppercases a lowercase speaker tag", func() {
segs := parseTranscript("[0.00][s03] hi[0.90]")
Expect(segs).To(HaveLen(1))
Expect(segs[0].Speaker).To(Equal("S03"))
})
It("falls back to start==end when the closing timestamp is missing", func() {
segs := parseTranscript("[2.00][S01] trailing words with no end")
Expect(segs).To(HaveLen(1))
Expect(segs[0].Start).To(BeNumerically("~", 2.00, 1e-9))
Expect(segs[0].End).To(Equal(segs[0].Start))
Expect(segs[0].Text).To(Equal("trailing words with no end"))
})
It("handles multi-digit speaker ids and higher speaker counts", func() {
segs := parseTranscript("[0.0][S01] a[1.0][1.0][S12] b[2.0]")
Expect(segs).To(HaveLen(2))
Expect(segs[0].Speaker).To(Equal("S01"))
Expect(segs[1].Speaker).To(Equal("S12"))
})
It("returns no segments for text without bracket structure", func() {
Expect(parseTranscript("just some plain text")).To(BeEmpty())
Expect(parseTranscript("")).To(BeEmpty())
})
It("skips a start timestamp not followed by a speaker tag", func() {
// A stray timestamp pair without a speaker tag is not a segment head.
segs := parseTranscript("[1.0][2.0][S01] real[3.0]")
Expect(segs).To(HaveLen(1))
Expect(segs[0].Speaker).To(Equal("S01"))
Expect(segs[0].Text).To(Equal("real"))
})
})
var _ = Describe("transcriptResultFromRaw", func() {
It("shapes parsed segments into a TranscriptResult with nanosecond times and speakers", func() {
raw := "[0.28][S01] first part,[7.71][8.12][S02] second part.[10.59]"
res := transcriptResultFromRaw(raw)
Expect(res.Segments).To(HaveLen(2))
Expect(res.Segments[0].Id).To(Equal(int32(0)))
Expect(res.Segments[0].Start).To(Equal(secondsToNanos(0.28)))
Expect(res.Segments[0].End).To(Equal(secondsToNanos(7.71)))
Expect(res.Segments[0].Speaker).To(Equal("S01"))
Expect(res.Segments[0].Text).To(Equal("first part,"))
Expect(res.Segments[1].Id).To(Equal(int32(1)))
Expect(res.Segments[1].Speaker).To(Equal("S02"))
Expect(res.Segments[1].Start).To(Equal(secondsToNanos(8.12)))
// Full text is the segments joined with single spaces.
Expect(res.Text).To(Equal("first part, second part."))
})
It("falls back to a single whole-clip text segment when nothing parses", func() {
res := transcriptResultFromRaw("plain transcript with no markers")
Expect(res.Segments).To(HaveLen(1))
Expect(res.Segments[0].Text).To(Equal("plain transcript with no markers"))
Expect(res.Segments[0].Start).To(Equal(int64(0)))
Expect(res.Text).To(Equal("plain transcript with no markers"))
})
})
var _ = Describe("secondsToNanos", func() {
It("converts fractional seconds to int64 nanoseconds", func() {
Expect(secondsToNanos(0)).To(Equal(int64(0)))
Expect(secondsToNanos(1)).To(Equal(int64(1e9)))
Expect(secondsToNanos(0.28)).To(Equal(int64(0.28 * 1e9)))
})
})

5
backend/go/moss-tts-cpp/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
moss-tts-cpp
*.so
*.dylib
sources/
package/

View File

@@ -0,0 +1,48 @@
cmake_minimum_required(VERSION 3.18)
project(gomosstts-cpp LANGUAGES C CXX)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(MOSS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/sources/moss-tts.cpp)
# Override upstream's CMAKE_CUDA_ARCHITECTURES before add_subdirectory.
if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES)
set(CMAKE_CUDA_ARCHITECTURES "75-virtual;80-virtual;86-real;89-real")
endif()
# Add the upstream project. Its own CMakeLists vendors ggml under
# third_party/ggml and builds the moss-tts library (STATIC by default, since we
# do NOT set MOSS_TTS_SHARED). EXCLUDE_FROM_ALL keeps its CLI tool / tests /
# examples from building unless referenced.
add_subdirectory(${MOSS_DIR} mosstts EXCLUDE_FROM_ALL)
add_library(gomosstts-cpp MODULE cpp/mossttscpp.cpp)
target_link_libraries(gomosstts-cpp PRIVATE moss-tts)
target_include_directories(gomosstts-cpp PRIVATE ${MOSS_DIR}/include ${MOSS_DIR}/src)
target_include_directories(gomosstts-cpp SYSTEM PRIVATE ${MOSS_DIR}/third_party/ggml/include)
# Link GPU backends if the upstream ggml created them. moss-tts links these
# PUBLIC when enabled, but we re-list them so the MODULE carries the symbols
# even under static ggml (mirrors qwen3-tts-cpp).
foreach(backend blas cuda hip metal vulkan sycl)
if(TARGET ggml-${backend})
target_link_libraries(gomosstts-cpp PRIVATE ggml-${backend})
if(backend STREQUAL "cuda")
find_package(CUDAToolkit QUIET)
if(CUDAToolkit_FOUND)
target_link_libraries(gomosstts-cpp PRIVATE CUDA::cudart)
endif()
endif()
endif()
endforeach()
if(MSVC)
target_compile_options(gomosstts-cpp PRIVATE /W4 /wd4100 /wd4505)
else()
target_compile_options(gomosstts-cpp PRIVATE -Wall -Wextra
-Wno-unused-parameter -Wno-unused-function)
endif()
set_property(TARGET gomosstts-cpp PROPERTY CXX_STANDARD 17)
set_target_properties(gomosstts-cpp PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})

View File

@@ -0,0 +1,140 @@
CMAKE_ARGS?=
BUILD_TYPE?=
NATIVE?=false
GOCMD?=go
GO_TAGS?=
JOBS?=$(shell nproc --ignore=1)
# moss-tts.cpp version
MOSSTTS_REPO?=https://github.com/mudler/moss-tts.cpp
MOSSTTS_CPP_VERSION?=ee722b8e9205ee9b1b1c398a4e87e4e393e9be41
SO_TARGET?=libgomosstts-cpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
endif
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS+=-DGGML_CUDA=ON
else ifeq ($(BUILD_TYPE),openblas)
CMAKE_ARGS+=-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
ROCM_HOME ?= /opt/rocm
ROCM_PATH ?= /opt/rocm
export CXX=$(ROCM_HOME)/llvm/bin/clang++
export CC=$(ROCM_HOME)/llvm/bin/clang
AMDGPU_TARGETS ?= gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(OS),Darwin)
ifneq ($(BUILD_TYPE),metal)
CMAKE_ARGS+=-DGGML_METAL=OFF
else
CMAKE_ARGS+=-DGGML_METAL=ON
CMAKE_ARGS+=-DGGML_METAL_EMBED_LIBRARY=ON
endif
endif
ifeq ($(BUILD_TYPE),sycl_f16)
CMAKE_ARGS+=-DGGML_SYCL=ON \
-DCMAKE_C_COMPILER=icx \
-DCMAKE_CXX_COMPILER=icpx \
-DGGML_SYCL_F16=ON
endif
ifeq ($(BUILD_TYPE),sycl_f32)
CMAKE_ARGS+=-DGGML_SYCL=ON \
-DCMAKE_C_COMPILER=icx \
-DCMAKE_CXX_COMPILER=icpx
endif
sources/moss-tts.cpp:
mkdir -p sources/moss-tts.cpp
cd sources/moss-tts.cpp && \
git init && \
git remote add origin $(MOSSTTS_REPO) && \
git fetch origin && \
git checkout $(MOSSTTS_CPP_VERSION) && \
git submodule update --init --recursive --depth 1 --single-branch
# Detect OS
UNAME_S := $(shell uname -s)
# Only build CPU variants on Linux
ifeq ($(UNAME_S),Linux)
VARIANT_TARGETS = libgomosstts-cpp-avx.so libgomosstts-cpp-avx2.so libgomosstts-cpp-avx512.so libgomosstts-cpp-fallback.so
else
# On non-Linux (e.g., Darwin), build only fallback variant (as a dylib)
VARIANT_TARGETS = libgomosstts-cpp-fallback.dylib
endif
moss-tts-cpp: main.go gomossttscpp.go $(VARIANT_TARGETS)
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o moss-tts-cpp ./
package: moss-tts-cpp
bash package.sh
build: package
clean: purge
rm -rf libgomosstts-cpp*.so libgomosstts-cpp*.dylib package sources/moss-tts.cpp moss-tts-cpp
purge:
rm -rf build*
# Variants must build sequentially
.NOTPARALLEL:
# Build all variants (Linux only)
ifeq ($(UNAME_S),Linux)
libgomosstts-cpp-avx.so: sources/moss-tts.cpp
$(info ${GREEN}I moss-tts-cpp build info:avx${RESET})
SO_TARGET=libgomosstts-cpp-avx.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgomosstts-cpp-custom
rm -rf build-libgomosstts-cpp-avx.so
libgomosstts-cpp-avx2.so: sources/moss-tts.cpp
$(info ${GREEN}I moss-tts-cpp build info:avx2${RESET})
SO_TARGET=libgomosstts-cpp-avx2.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on" $(MAKE) libgomosstts-cpp-custom
rm -rf build-libgomosstts-cpp-avx2.so
libgomosstts-cpp-avx512.so: sources/moss-tts.cpp
$(info ${GREEN}I moss-tts-cpp build info:avx512${RESET})
SO_TARGET=libgomosstts-cpp-avx512.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on" $(MAKE) libgomosstts-cpp-custom
rm -rf build-libgomosstts-cpp-avx512.so
endif
# Build fallback variant (all platforms)
libgomosstts-cpp-fallback.so: sources/moss-tts.cpp
$(info ${GREEN}I moss-tts-cpp build info:fallback${RESET})
SO_TARGET=libgomosstts-cpp-fallback.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgomosstts-cpp-custom
rm -rf build-libgomosstts-cpp-fallback.so
# Build fallback variant as a dylib (Darwin)
libgomosstts-cpp-fallback.dylib: sources/moss-tts.cpp
$(info ${GREEN}I moss-tts-cpp build info:fallback (dylib)${RESET})
SO_TARGET=libgomosstts-cpp-fallback.dylib CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgomosstts-cpp-custom
rm -rf build-libgomosstts-cpp-fallback.dylib
libgomosstts-cpp-custom: CMakeLists.txt cpp/mossttscpp.cpp cpp/mossttscpp.h
mkdir -p build-$(SO_TARGET) && \
cd build-$(SO_TARGET) && \
cmake .. $(CMAKE_ARGS) && \
cmake --build . --config Release -j$(JOBS) --target gomosstts-cpp && \
cd .. && \
(mv build-$(SO_TARGET)/libgomosstts-cpp.so ./$(SO_TARGET) 2>/dev/null || \
mv build-$(SO_TARGET)/libgomosstts-cpp.dylib ./$(SO_TARGET) 2>/dev/null)
test: moss-tts-cpp
@echo "Running moss-tts-cpp tests..."
bash test.sh
@echo "moss-tts-cpp tests completed."
all: moss-tts-cpp package

View File

@@ -0,0 +1,79 @@
# MOSS-TTS C++ backend
This backend runs the OpenMOSS **MOSS-TTS-Local (v1.5)** GGUF model through
[moss-tts.cpp](https://github.com/mudler/moss-tts.cpp), a from-scratch C++/ggml
port with no Python at inference time. It generates **48 kHz stereo** speech and
supports reference-audio voice cloning.
The engine loads three GGUFs: the local transformer (the model), the
MOSS-Audio-Tokenizer neural codec, and the text tokenizer. It is loaded via
purego (cgo-less `dlopen`) exactly like `qwen3-tts-cpp`.
## Model configuration
The model path points at the local transformer GGUF. The codec and text
tokenizer are auto-discovered as siblings of the model:
- codec: a `*.gguf` whose name contains `audio` + `tokenizer` (or `codec`),
e.g. `moss-audio-tokenizer-v2-f32.gguf`
- tokenizer: the other `*.gguf` whose name contains `tokenizer`,
e.g. `moss-tokenizer-v1_5.gguf`
```yaml
name: moss-tts-cpp
backend: moss-tts-cpp
parameters:
model: moss-tts-local-v1_5-q8_0.gguf
known_usecases:
- tts
tts:
audio_path: voices/default-reference.wav # optional model-wide clone reference
```
Override discovery when the filenames are non-standard:
```yaml
options:
- "codec:moss-audio-tokenizer-v2-f32.gguf"
- "tokenizer:moss-tokenizer-v1_5.gguf"
- "seed:42"
```
GGUFs for the Local v1.5 model (plus its codec and tokenizer) live at
[mudler/MOSS-TTS-Local-Transformer-v1.5-GGUF](https://huggingface.co/mudler/MOSS-TTS-Local-Transformer-v1.5-GGUF).
## Voice cloning
MOSS-TTS-Local has no named speakers; cloning is driven purely by a
reference-audio **path**. Request precedence is: a request `voice` that ends in
a known audio extension (`.wav`, `.flac`, `.mp3`, `.ogg`, `.m4a`), then
`tts.audio_path`. The engine decodes the reference itself, so no client-side
resampling is required.
## API example
```bash
curl http://localhost:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{
"model": "moss-tts-cpp",
"input": "This request uses a saved reference voice.",
"voice": "/path/to/reference.wav"
}' \
--output speech.wav
```
## Native end-to-end test
The labeled test loads real GGUFs, synthesizes a 48 kHz stereo WAV, and streams
audio:
```bash
make -C backend/go/moss-tts-cpp moss-tts-cpp
MOSSTTS_MODEL=/path/to/moss-tts-local-v1_5-q8_0.gguf \
MOSSTTS_CODEC=/path/to/moss-audio-tokenizer-v2-f32.gguf \
MOSSTTS_TOKENIZER=/path/to/moss-tokenizer-v1_5.gguf \
MOSSTTS_LIBRARY=backend/go/moss-tts-cpp/libgomosstts-cpp-fallback.so \
go test ./backend/go/moss-tts-cpp -ginkgo.label-filter=e2e
```

View File

@@ -0,0 +1,100 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
)
// mossttsSampleRate is the MOSS-TTS-Local v1.5 output rate; the engine also
// reports it per-synthesis via out_sr, which the writers honour.
const mossttsSampleRate = 48000
// mossttsChannels is the MOSS-TTS-Local output layout: 48 kHz stereo, samples
// interleaved [L,R,L,R,...].
const mossttsChannels = 2
// wavHeaderStereo returns a 44-byte WAV header for a streaming 16-bit stereo PCM
// stream at sampleRate, with placeholder (0xFFFFFFFF) sizes since the total
// length is unknown up front. Emitted as the first chunk of TTSStream so the
// HTTP layer receives a self-describing WAV.
func wavHeaderStereo(sampleRate int) []byte {
if sampleRate <= 0 {
sampleRate = mossttsSampleRate
}
const blockAlign = mossttsChannels * 2 // 16-bit stereo
var buf bytes.Buffer
w := func(v any) { _ = binary.Write(&buf, binary.LittleEndian, v) }
buf.WriteString("RIFF")
w(uint32(0xFFFFFFFF))
buf.WriteString("WAVE")
buf.WriteString("fmt ")
w(uint32(16)) // Subchunk1Size
w(uint16(1)) // PCM
w(uint16(mossttsChannels)) // stereo
w(uint32(sampleRate)) // sample rate
w(uint32(sampleRate * blockAlign)) // byte rate = SR * blockAlign
w(uint16(blockAlign)) // block align
w(uint16(16)) // bits per sample
buf.WriteString("data")
w(uint32(0xFFFFFFFF))
return buf.Bytes()
}
// floatToPCM16LE clamps each sample to [-1,1] and encodes it as little-endian
// signed 16-bit PCM. Input may be interleaved stereo; the layout is preserved.
func floatToPCM16LE(samples []float32) []byte {
out := make([]byte, len(samples)*2)
for i, s := range samples {
if s > 1 {
s = 1
} else if s < -1 {
s = -1
}
v := int16(s * 32767)
out[i*2] = byte(v) // #nosec G115 -- intentional little-endian split of a clamped int16
out[i*2+1] = byte(v >> 8) // #nosec G115 -- high byte of the same clamped int16
}
return out
}
// writeWAVStereo writes interleaved [L,R,...] float samples as a finalized
// 16-bit stereo WAV at sampleRate (falling back to the v1.5 default of 48 kHz).
func writeWAVStereo(dst string, samples []float32, sampleRate int) error {
if sampleRate <= 0 {
sampleRate = mossttsSampleRate
}
f, err := os.Create(dst) // #nosec G304 -- dst is the server-chosen output path from the TTS request, not user-traversable
if err != nil {
return fmt.Errorf("moss-tts: create %q: %w", dst, err)
}
enc := wav.NewEncoder(f, sampleRate, 16, mossttsChannels, 1)
ints := make([]int, len(samples))
for i, s := range samples {
if s > 1 {
s = 1
} else if s < -1 {
s = -1
}
ints[i] = int(s * 32767)
}
b := &audio.IntBuffer{
Format: &audio.Format{NumChannels: mossttsChannels, SampleRate: sampleRate},
Data: ints,
SourceBitDepth: 16,
}
if err := enc.Write(b); err != nil {
_ = enc.Close()
_ = f.Close()
return fmt.Errorf("moss-tts: encode WAV: %w", err)
}
if err := enc.Close(); err != nil {
_ = f.Close()
return fmt.Errorf("moss-tts: finalize WAV: %w", err)
}
return f.Close()
}

View File

@@ -0,0 +1,49 @@
package main
import (
"path/filepath"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// These specs pin the voice-selection logic in resolveRequest, in particular
// the config-level audio_path (tts.audio_path -> ModelOptions.AudioPath) being
// used as the default voice-cloning reference. MOSS-TTS-Local takes the
// reference as a WAV path (the engine decodes it), so resolveRequest is pure
// path logic and needs no model / C library.
var _ = Describe("resolveRequest voice/clone selection", func() {
var dir, refWav string
BeforeEach(func() {
dir = GinkgoT().TempDir()
refWav = filepath.Join(dir, "ref.wav")
})
It("uses the config audio_path as the clone reference when Voice is empty", func() {
m := &MossTtsCpp{audioPath: refWav}
ref, seed := m.resolveRequest(&pb.TTSRequest{Text: "hi"})
Expect(ref).To(Equal(refWav))
Expect(seed).To(Equal(0)) // zero-value loadOptions seed
})
It("lets a per-request audio Voice override audio_path", func() {
other := filepath.Join(dir, "other.wav")
m := &MossTtsCpp{audioPath: refWav}
ref, _ := m.resolveRequest(&pb.TTSRequest{Text: "hi", Voice: other})
Expect(ref).To(Equal(other))
})
It("does not clone for a bare-token Voice, falling back to audio_path", func() {
m := &MossTtsCpp{audioPath: refWav}
ref, _ := m.resolveRequest(&pb.TTSRequest{Text: "hi", Voice: "serena"})
Expect(ref).To(Equal(refWav))
})
It("reads a per-request seed override from params", func() {
m := &MossTtsCpp{opts: loadOptions{seed: -1}}
_, seed := m.resolveRequest(&pb.TTSRequest{Text: "hi", Params: map[string]string{"seed": "123"}})
Expect(seed).To(Equal(123))
})
})

View File

@@ -0,0 +1,119 @@
#include "mossttscpp.h"
#include "ggml-backend.h"
#include "moss_tts_capi.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
// Single loaded Local pipeline. base.SingleThread serializes calls on the Go
// side, so a plain global is safe (mirrors qwen3-tts-cpp's g_ctx).
static moss_local *g_local = nullptr;
static void ggml_log_cb(enum ggml_log_level level, const char *log,
void * /*data*/) {
if (!log)
return;
const char *lvl = "?????";
switch (level) {
case GGML_LOG_LEVEL_DEBUG: lvl = "DEBUG"; break;
case GGML_LOG_LEVEL_INFO: lvl = "INFO"; break;
case GGML_LOG_LEVEL_WARN: lvl = "WARN"; break;
case GGML_LOG_LEVEL_ERROR: lvl = "ERROR"; break;
default: break;
}
fprintf(stderr, "[%-5s] %s", lvl, log);
fflush(stderr);
}
int mtl_load(const char *local_path, const char *codec_path,
const char *tokenizer_path) {
ggml_log_set(ggml_log_cb, nullptr);
ggml_backend_load_all();
if (!local_path || local_path[0] == '\0') {
fprintf(stderr, "[moss-tts-cpp] ERROR: local_path is required\n");
return 1;
}
if (!codec_path || codec_path[0] == '\0') {
fprintf(stderr, "[moss-tts-cpp] ERROR: codec_path is required\n");
return 2;
}
if (!tokenizer_path || tokenizer_path[0] == '\0') {
fprintf(stderr, "[moss-tts-cpp] ERROR: tokenizer_path is required\n");
return 3;
}
fprintf(stderr,
"[moss-tts-cpp] Loading local=%s codec=%s tokenizer=%s\n",
local_path, codec_path, tokenizer_path);
g_local = moss_local_load(local_path, codec_path, tokenizer_path);
if (!g_local) {
fprintf(stderr, "[moss-tts-cpp] FATAL: moss_local_load failed\n");
return 4;
}
fprintf(stderr, "[moss-tts-cpp] Model loaded (%s)\n", moss_tts_version());
return 0;
}
float *mtl_tts(const char *text, const char *reference_wav, int seed,
int *out_n, int *out_sr) {
if (out_n)
*out_n = 0;
if (out_sr)
*out_sr = 0;
if (!g_local) {
fprintf(stderr, "[moss-tts-cpp] ERROR: model not loaded\n");
return nullptr;
}
if (!text || text[0] == '\0') {
fprintf(stderr, "[moss-tts-cpp] ERROR: text is required\n");
return nullptr;
}
// An empty reference path means "no cloning": pass NULL so the engine skips
// the clone branch rather than trying to open "".
const char *ref = (reference_wav && reference_wav[0] != '\0')
? reference_wav
: nullptr;
int n = 0, sr = 0;
float *pcm = moss_local_tts(g_local, text, ref, seed, &n, &sr);
if (!pcm || n <= 0) {
fprintf(stderr, "[moss-tts-cpp] ERROR: moss_local_tts failed\n");
if (pcm)
moss_free(pcm);
return nullptr;
}
// Copy into a plain malloc buffer the Go side frees via mtl_pcm_free, then
// release the engine-owned buffer with moss_free (mirrors qwen3-tts-cpp,
// keeping ownership on the C runtime's malloc/free).
size_t bytes = (size_t)n * sizeof(float);
float *buf = (float *)malloc(bytes);
if (!buf) {
fprintf(stderr, "[moss-tts-cpp] ERROR: malloc(%zu) failed\n", bytes);
moss_free(pcm);
return nullptr;
}
memcpy(buf, pcm, bytes);
moss_free(pcm);
if (out_n)
*out_n = n;
if (out_sr)
*out_sr = sr;
return buf;
}
void mtl_pcm_free(float *p) { free(p); }
void mtl_unload(void) {
if (g_local) {
moss_local_free(g_local);
g_local = nullptr;
}
}
const char *mtl_version(void) { return moss_tts_version(); }

View File

@@ -0,0 +1,36 @@
#pragma once
// Thin C shim over moss-tts.cpp's flat C-API (include/moss_tts_capi.h) for the
// MOSS-TTS-Local (v1.5) pipeline. It holds the loaded pipeline as a single
// global handle so the purego bindings on the Go side stay handle-free (mirrors
// the qwen3-tts-cpp qt3_* shim). Only the Local variant is wired here; the
// Delay / Realtime / Nano variants of the upstream API are intentionally
// unused.
extern "C" {
// Load the Local (MossTTSLocal v1.5) pipeline from its three GGUFs: the local
// transformer, the MOSS-Audio-Tokenizer codec, and the text tokenizer. Sets up
// ggml logging + backend registration first. Returns 0 on success, non-zero on
// failure.
int mtl_load(const char *local_path, const char *codec_path,
const char *tokenizer_path);
// Synthesize `text` to a malloc'd interleaved f32 PCM buffer (caller frees via
// mtl_pcm_free). v1.5 output is 48 kHz stereo: *out_n is the total number of
// float samples across both channels (frames * 2) and *out_sr is the sample
// rate. `reference_wav` is an optional path to a clone-reference WAV (may be
// NULL/empty for no cloning); the engine decodes it itself. `seed` < 0 means
// random. Returns NULL on failure (out_n / out_sr set to 0).
float *mtl_tts(const char *text, const char *reference_wav, int seed,
int *out_n, int *out_sr);
// Free a buffer returned by mtl_tts.
void mtl_pcm_free(float *p);
// Release the loaded pipeline.
void mtl_unload(void);
// Upstream engine version string (moss_tts_version()).
const char *mtl_version(void);
}

View File

@@ -0,0 +1,72 @@
package main
import (
"os"
"strings"
"github.com/ebitengine/purego"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func ttsReq(text, voice, dst string) *pb.TTSRequest {
return &pb.TTSRequest{Text: text, Voice: voice, Dst: dst}
}
var _ = Describe("moss-tts-cpp e2e", Label("e2e"), func() {
var loaded bool
BeforeEach(func() {
modelPath := os.Getenv("MOSSTTS_MODEL")
codecPath := os.Getenv("MOSSTTS_CODEC")
tokenizerPath := os.Getenv("MOSSTTS_TOKENIZER")
if modelPath == "" || codecPath == "" || tokenizerPath == "" {
Skip("MOSSTTS_MODEL / MOSSTTS_CODEC / MOSSTTS_TOKENIZER not set; skipping e2e")
}
if !loaded {
lib := os.Getenv("MOSSTTS_LIBRARY")
if lib == "" {
lib = "./libgomosstts-cpp-fallback.so"
}
h, err := purego.Dlopen(lib, purego.RTLD_NOW|purego.RTLD_GLOBAL)
Expect(err).ToNot(HaveOccurred())
purego.RegisterLibFunc(&CppLoad, h, "mtl_load")
purego.RegisterLibFunc(&CppTTS, h, "mtl_tts")
purego.RegisterLibFunc(&CppPCMFree, h, "mtl_pcm_free")
purego.RegisterLibFunc(&CppUnload, h, "mtl_unload")
Expect(CppLoad(modelPath, codecPath, tokenizerPath)).To(Equal(0))
loaded = true
}
})
It("synthesizes a stereo WAV file via TTS", func() {
m := &MossTtsCpp{opts: loadOptions{seed: 42}}
dst := GinkgoT().TempDir() + "/out.wav"
err := m.TTS(ttsReq("Hello world.", "", dst))
Expect(err).ToNot(HaveOccurred())
fi, err := os.Stat(dst)
Expect(err).ToNot(HaveOccurred())
Expect(fi.Size()).To(BeNumerically(">", int64(44)))
})
It("streams audio chunks via TTSStream", func() {
m := &MossTtsCpp{opts: loadOptions{seed: 42}}
results := make(chan []byte, 4096)
done := make(chan error, 1)
go func() { done <- m.TTSStream(ttsReq("Hello there, streaming test.", "", ""), results) }()
var chunks int
var first []byte
for c := range results {
if chunks == 0 {
first = c
}
chunks++
}
Expect(<-done).ToNot(HaveOccurred())
Expect(chunks).To(BeNumerically(">=", 2))
Expect(string(first[0:4])).To(Equal("RIFF"))
Expect(strings.HasPrefix(string(first[8:12]), "WAVE")).To(BeTrue())
})
})

View File

@@ -0,0 +1,224 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
)
var (
// mtl_load(local_path, codec_path, tokenizer_path) int
CppLoad func(localPath, codecPath, tokenizerPath string) int
// mtl_tts(text, reference_wav, seed, out_n, out_sr) -> float*
CppTTS func(text, referenceWav string, seed int, outN, outSR unsafe.Pointer) uintptr
// mtl_pcm_free(ptr)
CppPCMFree func(ptr uintptr)
// mtl_unload()
CppUnload func()
)
type MossTtsCpp struct {
base.SingleThread
opts loadOptions
// audioPath is the model-config reference voice (tts.audio_path), the
// default clone reference when a request omits an audio Voice.
audioPath string
}
func (m *MossTtsCpp) Load(opts *pb.ModelOptions) error {
model := opts.ModelFile
if model == "" {
model = opts.ModelPath
}
if !filepath.IsAbs(model) && opts.ModelPath != "" {
model = filepath.Join(opts.ModelPath, model)
}
m.opts = parseOptions(opts.Options)
dir := filepath.Dir(model)
// Resolve the codec GGUF (MOSS-Audio-Tokenizer): explicit option, else
// auto-discover an *audio*tokenizer*/codec sibling of the model.
codec := resolveAux(m.opts.codecPath, dir)
if codec == "" {
codec = discoverCodec(dir, model)
}
if codec == "" {
return fmt.Errorf("moss-tts: no codec GGUF found; set option 'codec:<file>'")
}
m.opts.codecPath = codec
// Resolve the text tokenizer GGUF: explicit option, else auto-discover the
// *tokenizer* sibling that is not the audio codec or the model.
tokenizer := resolveAux(m.opts.tokenizerPath, dir)
if tokenizer == "" {
tokenizer = discoverTokenizer(dir, model, codec)
}
if tokenizer == "" {
return fmt.Errorf("moss-tts: no tokenizer GGUF found; set option 'tokenizer:<file>'")
}
m.opts.tokenizerPath = tokenizer
m.audioPath = opts.AudioPath
if m.audioPath != "" && !filepath.IsAbs(m.audioPath) {
m.audioPath = filepath.Join(dir, m.audioPath)
}
xlog.Info("[moss-tts-cpp] Load", "model", model, "codec", codec, "tokenizer", tokenizer)
if rc := CppLoad(model, codec, tokenizer); rc != 0 {
return fmt.Errorf("moss-tts: failed to load model (rc=%d)", rc)
}
return nil
}
// resolveAux resolves an explicitly configured auxiliary GGUF path relative to
// the model directory when it is not absolute. Empty stays empty.
func resolveAux(p, dir string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(dir, p)
}
// isAudioCodecName reports whether a GGUF filename denotes the MOSS-Audio-
// Tokenizer codec (e.g. moss-audio-tokenizer-v2-f32.gguf) rather than the text
// tokenizer (moss-tokenizer-v1_5.gguf).
func isAudioCodecName(name string) bool {
n := strings.ToLower(name)
return strings.Contains(n, "codec") ||
(strings.Contains(n, "audio") && strings.Contains(n, "tokenizer"))
}
// discoverCodec returns the first codec GGUF in dir (excluding the model), or "".
func discoverCodec(dir, model string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
modelBase := filepath.Base(model)
for _, e := range entries {
name := e.Name()
if name == modelBase || !strings.HasSuffix(strings.ToLower(name), ".gguf") {
continue
}
if isAudioCodecName(name) {
return filepath.Join(dir, name)
}
}
return ""
}
// discoverTokenizer returns the first text-tokenizer GGUF in dir that is neither
// the model nor the audio codec, or "".
func discoverTokenizer(dir, model, codec string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
modelBase := filepath.Base(model)
codecBase := filepath.Base(codec)
for _, e := range entries {
name := e.Name()
lower := strings.ToLower(name)
if name == modelBase || name == codecBase || !strings.HasSuffix(lower, ".gguf") {
continue
}
if strings.Contains(lower, "tokenizer") && !isAudioCodecName(name) {
return filepath.Join(dir, name)
}
}
return ""
}
// resolveRequest derives the synthesis inputs from a TTSRequest: the optional
// clone-reference WAV path and the seed. MOSS-TTS-Local drives cloning purely
// from a reference-audio path (the engine decodes it), so there is no
// language / speaker / instruct plumbing here (unlike qwen3-tts-cpp).
func (m *MossTtsCpp) resolveRequest(req *pb.TTSRequest) (refPath string, seed int) {
refPath = resolveVoice(req.Voice)
if refPath == "" && m.audioPath != "" {
// No per-request voice: fall back to the config clone reference.
refPath = m.audioPath
}
if refPath != "" && !filepath.IsAbs(refPath) {
refPath = filepath.Join(filepath.Dir(m.opts.codecPath), refPath)
}
seed = m.opts.seed
if req.Params != nil {
seed = parseInt(req.Params["seed"], seed)
}
return
}
func (m *MossTtsCpp) TTS(req *pb.TTSRequest) error {
if req.Dst == "" {
return fmt.Errorf("moss-tts: TTS requires a destination path")
}
if req.Text == "" {
return fmt.Errorf("moss-tts: TTS requires text")
}
refPath, seed := m.resolveRequest(req)
var n, sr int32
ptr := CppTTS(req.Text, refPath, seed, unsafe.Pointer(&n), unsafe.Pointer(&sr)) // #nosec G103 -- out-params for the purego-bound C-API
if ptr == 0 {
return fmt.Errorf("moss-tts: synthesis failed")
}
// Register the free as soon as we own a non-null buffer, so the n<=0 guard
// below cannot leak it (defensive: the C contract returns NULL on failure).
defer CppPCMFree(ptr)
if n <= 0 {
return fmt.Errorf("moss-tts: synthesis produced no samples")
}
//nolint:govet // C-allocated PCM, copied out before free
src := unsafe.Slice((*float32)(unsafe.Pointer(ptr)), int(n)) // #nosec G103 -- C-allocated PCM, copied out before free
out := make([]float32, int(n))
copy(out, src)
return writeWAVStereo(req.Dst, out, int(sr))
}
// TTSStream synthesizes one-shot (MOSS-TTS-Local has no native streaming C-API)
// and then emits a self-describing stereo WAV: a header chunk followed by the
// interleaved PCM in fixed-size slices, so the HTTP layer still receives a
// streamed WAV (the gRPC TTSStream path never sets Message, so the backend owns
// the header - see core/backend/tts.go:ModelTTSStream).
func (m *MossTtsCpp) TTSStream(req *pb.TTSRequest, results chan []byte) error {
defer close(results)
if req.Text == "" {
return fmt.Errorf("moss-tts: TTSStream requires text")
}
refPath, seed := m.resolveRequest(req)
var n, sr int32
ptr := CppTTS(req.Text, refPath, seed, unsafe.Pointer(&n), unsafe.Pointer(&sr)) // #nosec G103 -- out-params for the purego-bound C-API
if ptr == 0 {
return fmt.Errorf("moss-tts: synthesis failed")
}
defer CppPCMFree(ptr)
if n <= 0 {
return fmt.Errorf("moss-tts: synthesis produced no samples")
}
//nolint:govet // C-allocated PCM, copied out before free
src := unsafe.Slice((*float32)(unsafe.Pointer(ptr)), int(n)) // #nosec G103 -- C-allocated PCM, copied out before free
out := make([]float32, int(n))
copy(out, src)
results <- wavHeaderStereo(int(sr))
const frameChunk = 4096 // interleaved stereo samples per emitted chunk
for off := 0; off < len(out); off += frameChunk {
end := off + frameChunk
if end > len(out) {
end = len(out)
}
results <- floatToPCM16LE(out[off:end])
}
return nil
}

View File

@@ -0,0 +1,52 @@
package main
// Note: this is started internally by LocalAI and a server is allocated for each model
import (
"flag"
"os"
"runtime"
"github.com/ebitengine/purego"
grpc "github.com/mudler/LocalAI/pkg/grpc"
)
var (
addr = flag.String("addr", "localhost:50051", "the address to connect to")
)
type LibFuncs struct {
FuncPtr any
Name string
}
func main() {
libName := os.Getenv("MOSSTTS_LIBRARY")
if libName == "" {
if runtime.GOOS == "darwin" {
libName = "./libgomosstts-cpp-fallback.dylib"
} else {
libName = "./libgomosstts-cpp-fallback.so"
}
}
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
panic(err)
}
libFuncs := []LibFuncs{
{&CppLoad, "mtl_load"},
{&CppTTS, "mtl_tts"},
{&CppPCMFree, "mtl_pcm_free"},
{&CppUnload, "mtl_unload"},
}
for _, lf := range libFuncs {
purego.RegisterLibFunc(lf.FuncPtr, lib, lf.Name)
}
flag.Parse()
if err := grpc.StartServer(*addr, &MossTtsCpp{}); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,124 @@
package main
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMossTtsCpp(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "moss-tts-cpp suite")
}
var _ = Describe("resolveVoice", func() {
It("treats an audio path as a clone reference (case-insensitive ext)", func() {
Expect(resolveVoice("/x/ref.WAV")).To(Equal("/x/ref.WAV"))
})
It("recognizes mp3/flac/ogg/m4a", func() {
for _, p := range []string{"a.mp3", "b.flac", "c.ogg", "d.m4a"} {
Expect(resolveVoice(p)).To(Equal(p))
}
})
It("ignores a bare token (no named speakers in MOSS-TTS-Local)", func() {
Expect(resolveVoice("serena")).To(BeEmpty())
})
It("returns empty for empty input", func() {
Expect(resolveVoice(" ")).To(BeEmpty())
})
})
var _ = Describe("parseOptions", func() {
It("extracts codec, tokenizer, seed", func() {
o := parseOptions([]string{
"codec:codec.gguf", "tokenizer:tok.gguf", "seed:7", "unknown:ignored",
})
Expect(o.codecPath).To(Equal("codec.gguf"))
Expect(o.tokenizerPath).To(Equal("tok.gguf"))
Expect(o.seed).To(Equal(7))
})
It("accepts audio_tokenizer / text_tokenizer aliases", func() {
o := parseOptions([]string{"audio_tokenizer:c.gguf", "text_tokenizer:t.gguf"})
Expect(o.codecPath).To(Equal("c.gguf"))
Expect(o.tokenizerPath).To(Equal("t.gguf"))
})
It("defaults seed -1", func() {
Expect(parseOptions(nil).seed).To(Equal(-1))
})
})
var _ = Describe("isAudioCodecName", func() {
DescribeTable("distinguishes the audio codec from the text tokenizer",
func(name string, want bool) {
Expect(isAudioCodecName(name)).To(Equal(want))
},
Entry("audio tokenizer is the codec", "moss-audio-tokenizer-v2-f32.gguf", true),
Entry("codec keyword", "some-codec.gguf", true),
Entry("text tokenizer is not the codec", "moss-tokenizer-v1_5.gguf", false),
Entry("model is not the codec", "moss-tts-local-v1_5-q8_0.gguf", false),
)
})
var _ = Describe("discoverCodec / discoverTokenizer", func() {
var dir, model, codec, tok string
BeforeEach(func() {
dir = GinkgoT().TempDir()
model = filepath.Join(dir, "moss-tts-local-v1_5-q8_0.gguf")
codec = filepath.Join(dir, "moss-audio-tokenizer-v2-f32.gguf")
tok = filepath.Join(dir, "moss-tokenizer-v1_5.gguf")
for _, p := range []string{model, codec, tok} {
Expect(os.WriteFile(p, []byte("x"), 0o644)).To(Succeed())
}
})
It("finds the audio codec, excluding the model", func() {
Expect(discoverCodec(dir, model)).To(Equal(codec))
})
It("finds the text tokenizer, excluding model and codec", func() {
Expect(discoverTokenizer(dir, model, codec)).To(Equal(tok))
})
})
var _ = Describe("wavHeaderStereo", func() {
It("emits a 44-byte streaming WAV header at 48 kHz stereo 16-bit", func() {
h := wavHeaderStereo(48000)
Expect(h).To(HaveLen(44))
Expect(string(h[0:4])).To(Equal("RIFF"))
Expect(string(h[8:12])).To(Equal("WAVE"))
Expect(string(h[12:16])).To(Equal("fmt "))
Expect(string(h[36:40])).To(Equal("data"))
var channels uint16
Expect(binary.Read(bytes.NewReader(h[22:24]), binary.LittleEndian, &channels)).To(Succeed())
Expect(channels).To(Equal(uint16(2)))
var sampleRate uint32
Expect(binary.Read(bytes.NewReader(h[24:28]), binary.LittleEndian, &sampleRate)).To(Succeed())
Expect(sampleRate).To(Equal(uint32(48000)))
})
It("falls back to 48 kHz for a non-positive sample rate", func() {
var sampleRate uint32
h := wavHeaderStereo(0)
Expect(binary.Read(bytes.NewReader(h[24:28]), binary.LittleEndian, &sampleRate)).To(Succeed())
Expect(sampleRate).To(Equal(uint32(48000)))
})
})
var _ = Describe("floatToPCM16LE", func() {
It("clamps and converts float PCM to little-endian int16 bytes", func() {
b := floatToPCM16LE([]float32{0, 1.0, -1.0, 2.0, -2.0})
Expect(b).To(HaveLen(10))
read := func(off int) int16 {
var v int16
_ = binary.Read(bytes.NewReader(b[off:off+2]), binary.LittleEndian, &v)
return v
}
Expect(read(0)).To(Equal(int16(0)))
Expect(read(2)).To(Equal(int16(32767)))
Expect(read(4)).To(Equal(int16(-32767)))
Expect(read(6)).To(Equal(int16(32767))) // clamped from 2.0
Expect(read(8)).To(Equal(int16(-32767))) // clamped from -2.0
})
})

View File

@@ -0,0 +1,78 @@
package main
import (
"strconv"
"strings"
)
// loadOptions holds the parsed model-level options. MOSS-TTS-Local needs three
// GGUFs (local transformer, audio codec, text tokenizer); the model path is the
// local transformer and codec/tokenizer are resolved as siblings or via these
// options.
type loadOptions struct {
codecPath string
tokenizerPath string
seed int
}
func splitOption(o string) (key, value string, ok bool) {
i := strings.Index(o, ":")
if i < 0 {
return "", "", false
}
return strings.TrimSpace(o[:i]), strings.TrimSpace(o[i+1:]), true
}
// parseOptions reads the backend "key:value" option slice. Unknown keys are
// ignored. Default seed is -1 (engine random).
func parseOptions(opts []string) loadOptions {
o := loadOptions{seed: -1}
for _, oo := range opts {
key, value, ok := splitOption(oo)
if !ok {
continue
}
switch key {
case "codec", "audio_tokenizer":
o.codecPath = value
case "tokenizer", "text_tokenizer":
o.tokenizerPath = value
case "seed":
if n, err := strconv.Atoi(value); err == nil {
o.seed = n
}
}
}
return o
}
var refAudioExts = []string{".wav", ".flac", ".mp3", ".ogg", ".m4a"}
// resolveVoice interprets the request Voice field. MOSS-TTS-Local has no named
// speakers, only reference-audio cloning, so a value ending in a known audio
// extension is treated as a clone-reference path and anything else is ignored
// (the caller then falls back to the config audio_path).
func resolveVoice(voice string) (refPath string) {
v := strings.TrimSpace(voice)
if v == "" {
return ""
}
lower := strings.ToLower(v)
for _, ext := range refAudioExts {
if strings.HasSuffix(lower, ext) {
return v
}
}
return ""
}
func parseInt(v string, def int) int {
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}

View File

@@ -0,0 +1,65 @@
#!/bin/bash
# Script to copy the appropriate libraries based on architecture
# This script is used in the final stage of the Dockerfile
set -e
CURDIR=$(dirname "$(realpath $0)")
REPO_ROOT="${CURDIR}/../../.."
# Create lib directory
mkdir -p $CURDIR/package/lib
cp -avf $CURDIR/moss-tts-cpp $CURDIR/package/
cp -fv $CURDIR/libgomosstts-cpp-*.so $CURDIR/package/ 2>/dev/null || true
cp -fv $CURDIR/libgomosstts-cpp-*.dylib $CURDIR/package/ 2>/dev/null || true
cp -fv $CURDIR/run.sh $CURDIR/package/
# Detect architecture and copy appropriate libraries
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
# x86_64 architecture
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 $CURDIR/package/lib/ld.so
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
# ARM64 architecture
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 $CURDIR/package/lib/ld.so
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ $(uname -s) = "Darwin" ]; then
echo "Detected Darwin"
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries based on BUILD_TYPE
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah $CURDIR/package/
ls -liah $CURDIR/package/lib/

View File

@@ -0,0 +1,57 @@
#!/bin/bash
set -ex
# Get the absolute current dir where the script is located
CURDIR=$(dirname "$(realpath "$0")")
cd /
echo "CPU info:"
if [ "$(uname)" != "Darwin" ]; then
grep -e "model\sname" /proc/cpuinfo | head -1
grep -e "flags" /proc/cpuinfo | head -1
fi
if [ "$(uname)" = "Darwin" ]; then
# macOS: single dylib variant (Metal or Accelerate)
LIBRARY="$CURDIR/libgomosstts-cpp-fallback.dylib"
export DYLD_LIBRARY_PATH="$CURDIR"/lib:$DYLD_LIBRARY_PATH
else
LIBRARY="$CURDIR/libgomosstts-cpp-fallback.so"
if grep -q -e "\savx\s" /proc/cpuinfo ; then
echo "CPU: AVX found OK"
if [ -e "$CURDIR"/libgomosstts-cpp-avx.so ]; then
LIBRARY="$CURDIR/libgomosstts-cpp-avx.so"
fi
fi
if grep -q -e "\savx2\s" /proc/cpuinfo ; then
echo "CPU: AVX2 found OK"
if [ -e "$CURDIR"/libgomosstts-cpp-avx2.so ]; then
LIBRARY="$CURDIR/libgomosstts-cpp-avx2.so"
fi
fi
# Check avx 512
if grep -q -e "\savx512f\s" /proc/cpuinfo ; then
echo "CPU: AVX512F found OK"
if [ -e "$CURDIR"/libgomosstts-cpp-avx512.so ]; then
LIBRARY="$CURDIR/libgomosstts-cpp-avx512.so"
fi
fi
export LD_LIBRARY_PATH="$CURDIR"/lib:$LD_LIBRARY_PATH
fi
export MOSSTTS_LIBRARY=$LIBRARY
# If there is a lib/ld.so, use it
if [ -f "$CURDIR"/lib/ld.so ]; then
echo "Using lib/ld.so"
echo "Using library: $LIBRARY"
exec "$CURDIR"/lib/ld.so "$CURDIR"/moss-tts-cpp "$@"
fi
echo "Using library: $LIBRARY"
exec "$CURDIR"/moss-tts-cpp "$@"

View File

@@ -0,0 +1,33 @@
#!/bin/bash
set -e
CURDIR=$(dirname "$(realpath $0)")
cd "$CURDIR"
echo "Running moss-tts-cpp backend tests..."
# Auto-download the Local v1.5 model + codec + tokenizer only when
# MOSSTTS_MODEL is not set.
if [ -z "$MOSSTTS_MODEL" ]; then
MODEL_DIR="./moss-tts-models"
mkdir -p "$MODEL_DIR"
REPO_ID="mudler/MOSS-TTS-Local-Transformer-v1.5-GGUF"
BASE_URL="https://huggingface.co/${REPO_ID}/resolve/main"
FILES=( "moss-tts-local-v1_5-q8_0.gguf" "moss-audio-tokenizer-v2-f32.gguf" "moss-tokenizer-v1_5.gguf" )
for file in "${FILES[@]}"; do
dest="${MODEL_DIR}/${file}"
if [ -f "${dest}" ]; then
echo " [skip] ${file}"
else
echo " [download] ${file}..."
curl -L -o "${dest}" "${BASE_URL}/${file}" --progress-bar
fi
done
export MOSSTTS_MODEL="${MODEL_DIR}/moss-tts-local-v1_5-q8_0.gguf"
export MOSSTTS_CODEC="${MODEL_DIR}/moss-audio-tokenizer-v2-f32.gguf"
export MOSSTTS_TOKENIZER="${MODEL_DIR}/moss-tokenizer-v1_5.gguf"
fi
go test -v -timeout 1200s .
echo "All moss-tts-cpp tests passed."

View File

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

View File

@@ -34,7 +34,15 @@ type Opus struct {
decoders map[string]*cachedDecoder
}
// Load accepts only the codec's own name (what the realtime WebRTC path
// sends) or no name at all — there is no model artefact here, so without
// this check the model loader's greedy autoload (which probes every
// installed backend with real model names) would happily bind an LLM to
// the audio codec.
func (o *Opus) Load(opts *pb.ModelOptions) error {
if m := opts.GetModel(); m != "" && m != "opus" {
return fmt.Errorf("opus: refusing to load %q: opus is an audio codec, not a model backend", m)
}
o.decoders = make(map[string]*cachedDecoder)
go o.evictLoop()
return Init()

View File

@@ -304,6 +304,17 @@ func computeTHD(samples []int16, fundamentalHz float64, sampleRate, numHarmonics
// --- Opus specs ---
var _ = Describe("Opus Load", func() {
It("accepts its own name (the realtime WebRTC path)", func() {
Expect((&Opus{}).Load(&pb.ModelOptions{Model: "opus"})).To(Succeed())
})
It("refuses foreign model names from the greedy autoload", func() {
err := (&Opus{}).Load(&pb.ModelOptions{Model: "some-llm.gguf"})
Expect(err).To(MatchError(ContainSubstring("audio codec")))
})
})
var _ = Describe("Opus", func() {
var o *Opus

View File

@@ -1,6 +1,6 @@
# parakeet-cpp backend Makefile.
#
# Upstream pin lives below as PARAKEET_VERSION?=e8acc6172a94e20a952cf1843decace5d771a94b
# Upstream pin lives below as PARAKEET_VERSION?=1da853421de9710cbe894a0110711de5a0516486
# (.github/bump_deps.sh) can find and update it - matches the
# whisper.cpp / ds4 / vibevoice-cpp convention.
#
@@ -15,7 +15,7 @@
# That's what the L0 smoke test uses. The default target below does the
# proper clone-at-pin + cmake build so CI doesn't need a side-checkout.
PARAKEET_VERSION?=e8acc6172a94e20a952cf1843decace5d771a94b
PARAKEET_VERSION?=1da853421de9710cbe894a0110711de5a0516486
PARAKEET_REPO?=https://github.com/mudler/parakeet.cpp
GOCMD?=go

View File

@@ -767,10 +767,6 @@ func (p *ParakeetCpp) AudioTranscriptionStream(ctx context.Context, opts *pb.Tra
return nil
}
// decodeWavMono16k converts any input audio to 16 kHz mono PCM and returns the
// float samples plus the clip duration in seconds. Mirrors the whisper
// backend: utils.AudioToWav (ffmpeg) normalises rate/channels, go-audio
// decodes the PCM.
// convertToWavMono16k converts an arbitrary audio file to a 16 kHz mono WAV in
// a fresh temp dir and returns the path together with a cleanup func the caller
// must defer. WAV inputs already at 16 kHz/mono/16-bit are passed through by
@@ -792,6 +788,10 @@ func convertToWavMono16k(path string) (string, func(), error) {
return converted, cleanup, nil
}
// decodeWavMono16k converts any input audio to 16 kHz mono PCM and returns the
// float samples plus the clip duration in seconds. Mirrors the whisper
// backend: utils.AudioToWav (ffmpeg) normalises rate/channels, go-audio
// decodes the PCM.
func decodeWavMono16k(path string) ([]float32, float32, error) {
converted, cleanup, err := convertToWavMono16k(path)
if err != nil {

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?=9dbe7ea26a01b30fccb117ae5e86807c1dc23d42
QWEN3TTS_CPP_VERSION?=e93292bee1778854ab7dcb2d325ffe531fef910f
SO_TARGET?=libgoqwen3ttscpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -0,0 +1,85 @@
# Qwen3-TTS C++ backend
This backend runs Qwen3-TTS GGUF models through
[qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp). It supports
24 kHz speech generation, streaming, named speakers, voice design, and
reference-audio cloning depending on the model variant.
## Gallery models
The following Base models accept LocalAI Voice Library profiles:
- `qwen3-tts-cpp`
- `qwen3-tts-cpp-0.6b-base-q4`
- `qwen3-tts-cpp-1.7b-base`
- `qwen3-tts-cpp-1.7b-base-q4`
Gallery models containing `customvoice` or `voicedesign` implement those Qwen
modes instead and are not advertised as raw reference-audio models.
Install a Base model with:
```bash
local-ai models install qwen3-tts-cpp
```
## Model configuration
Base filenames are detected automatically. Set `tts.voice_cloning` only when a
verified private conversion has a name that does not identify it as a Base or
VoiceClone model:
```yaml
name: private-qwen-voice
backend: qwen3-tts-cpp
parameters:
model: qwen-private/talker.gguf
known_usecases:
- tts
tts:
voice_cloning: true
audio_path: voices/default-reference.wav # optional model-wide fallback
```
The tokenizer GGUF is auto-discovered when its filename contains `tokenizer`
and it is stored beside the talker. Otherwise set
`options: ["tokenizer:qwen-private/tokenizer.gguf"]`.
`tts.voice_cloning: false` removes a model from Voice Library compatibility
results and rejects saved `localai://voice-profiles/...` references. It does
not disable Qwen's named-speaker or VoiceDesign modes. Setting it to `true`
cannot add cloning to a backend that lacks LocalAI's reference-audio contract.
Request precedence is: a request `voice`, then `tts.voice`, then
`tts.audio_path`. A saved profile supplies its private WAV and exact transcript
for that request without changing the model YAML.
## API example
Create or select a profile in **Operate → Voice Library**, then pass its stable
URI to either speech endpoint:
```bash
curl http://localhost:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{
"model": "qwen3-tts-cpp",
"input": "This request uses a saved reference voice.",
"voice": "localai://voice-profiles/PROFILE_ID"
}' \
--output speech.wav
```
## Native end-to-end test
The labeled test loads real GGUFs, synthesizes speech, streams audio, and
exercises cloning with a generated 24 kHz reference WAV:
```bash
make -C backend/go/qwen3-tts-cpp qwen3-tts-cpp
QWEN3TTS_MODEL=/path/to/qwen-talker-0.6b-base-Q8_0.gguf \
QWEN3TTS_CODEC=/path/to/qwen-tokenizer-12hz-Q8_0.gguf \
QWEN3TTS_LIBRARY=backend/go/qwen3-tts-cpp/libgoqwen3ttscpp-fallback.so \
go test ./backend/go/qwen3-tts-cpp -ginkgo.label-filter=e2e
```

View File

@@ -7,10 +7,10 @@ GO_TAGS?=
JOBS?=$(shell nproc --ignore=1)
# rt-detr.cpp (GitHub redirects the historical mudler/rt-detr.cpp to the new
# mudler/rf-detr.cpp slug). Pin to a specific commit if you need a stable
# localai-org/rf-detr.cpp slug). Pin to a specific commit if you need a stable
# build; leaving this on `master` always picks up the latest C-API surface
# (incl. the per-detection accessor functions used by gorfdetrcpp.go).
RFDETR_REPO?=https://github.com/mudler/rf-detr.cpp.git
RFDETR_REPO?=https://github.com/localai-org/rf-detr.cpp.git
RFDETR_VERSION?=65c0ffcc9a9bc9dae38252f63d0417c9845a6cf7
ifeq ($(NATIVE),false)

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