.agents/adding-backends.md
When adding a new backend to LocalAI, you need to update several files to ensure the backend is properly built, tested, and registered. Here's a step-by-step guide based on the pattern used for adding backends like moonshine:
Create the backend directory under the appropriate location:
backend/python/<backend-name>/backend/go/<backend-name>/backend/cpp/<backend-name>/backend/rust/<backend-name>/For Python backends, you'll typically need:
backend.py - Main gRPC server implementationMakefile - Build configurationinstall.sh - Installation script for dependenciesprotogen.sh - Protocol buffer generation scriptrequirements.txt - Python dependenciesrun.sh - Runtime scripttest.py / test.sh - Test filesFor Rust backends, you'll typically need (see backend/rust/kokoros/ as a reference):
Cargo.toml - Crate manifest; depend on the upstream project as a submodule under sources/build.rs - Invokes tonic_build to generate gRPC stubs from backend/backend.proto (use the BACKEND_PROTO_PATH env var so the Makefile can inject the canonical copy)src/ - The gRPC server implementation (implement Backend via tonic)Makefile - Copies backend.proto into the crate, runs cargo build --release, then package.shpackage.sh - Uses ldd to bundle the binary's dynamic deps and ld.so into package/lib/run.sh - Sets LD_LIBRARY_PATH/SSL_CERT_DIR and execs the binary via the bundled lib/ld.sosources/<UpstreamProject>/ - Git submodule with the upstream Rust crate.github/backend-matrix.ymlThe build matrix is data-only YAML at .github/backend-matrix.yml (not inside backend.yml itself). backend.yml (master push) and backend_pr.yml (PR) load it via scripts/changed-backends.js, which also handles per-file path filtering so only touched backends rebuild on PRs and master pushes alike. Add build matrix entries to .github/backend-matrix.yml for each platform/GPU type you want to support. Look at similar backends for reference — chatterbox/faster-whisper for Python, piper/silero-vad for Go, kokoros for Rust.
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.
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:
if (item.dockerfile.endsWith("<your-dockerfile-suffix>")) {
return `backend/cpp/<your-backend>/`; // or backend/python|go|rust/...
}
The endsWith() test is against the matrix entry's dockerfile: value (e.g. ./backend/Dockerfile.ds4 → endsWith("ds4")). Specificity order matters here just like it does for importers: more-specific suffixes go BEFORE more-generic ones (e.g. ds4 before llama-cpp even though both end with letters, because some upstream might one day call itself super-ds4-llama-cpp). Verify locally before pushing:
# Confirm your dockerfile suffix is unique enough
node -e "
const yaml = require('js-yaml'); const fs = require('fs');
const m = yaml.load(fs.readFileSync('.github/backend-matrix.yml','utf8'));
for (const e of m.include.filter(e => e.backend === '<your-backend>')) {
console.log(e.dockerfile, '->', e.dockerfile.endsWith('<suffix>'));
}"
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 greps 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):
# .github/workflows/bump_deps.yaml
matrix:
include:
- repository: "antirez/ds4"
variable: "DS4_VERSION"
branch: "main"
file: "backend/cpp/ds4/Makefile"
And the corresponding Makefile shape (mirror backend/cpp/llama-cpp/Makefile):
DS4_VERSION?=ae302c2fa18cc6d9aefc021d0f27ae03c9ad2fc0
DS4_REPO?=https://github.com/antirez/ds4
...
ds4:
mkdir -p ds4
cd ds4 && git init -q && \
git remote add origin $(DS4_REPO) && \
git fetch --depth 1 origin $(DS4_VERSION) && \
git checkout FETCH_HEAD
If you have a prepare.sh doing the clone, delete it — the recipe belongs in the Makefile target so make purge && make works as a clean-and-rebuild and so the bump bot finds the pin.
Placement in file:
cpu-chatterbox)gpu-nvidia-cuda-12-chatterbox)gpu-nvidia-cuda-13-chatterbox)Additional build types you may need:
build-type: 'hipblas' with base-image: "rocm/dev-ubuntu-24.04:7.2.1"build-type: 'intel' or build-type: 'sycl_f16'/sycl_f32 with base-image: "intel/oneapi-basekit:2025.3.2-0-devel-ubuntu24.04"build-type: 'l4t' with platforms: 'linux/arm64' and runs-on: 'ubuntu-24.04-arm'Per-arch native builds (linux/amd64 + linux/arm64):
Multi-arch backends are NOT a single matrix entry with platforms: 'linux/amd64,linux/arm64'. Instead, add two entries — one with platforms: 'linux/amd64' + platform-tag: 'amd64' + runs-on: 'ubuntu-latest', one with platforms: 'linux/arm64' + platform-tag: 'arm64' + runs-on: 'ubuntu-24.04-arm' — both sharing the same tag-suffix. The script detects the shared tag-suffix and emits a merge-matrix entry, so backend-merge-jobs (in backend.yml/backend_pr.yml) automatically assembles the manifest list from per-arch digest artifacts. See -cpu-faster-whisper in .github/backend-matrix.yml for a reference shape.
llama-cpp / ik-llama-cpp / turboquant variants only — builder-base-image:
Entries whose dockerfile is ./backend/Dockerfile.{llama-cpp,ik-llama-cpp,turboquant} must also set a builder-base-image field pointing at a prebuilt base from quay.io/go-skynet/ci-cache:base-grpc-* (CI builds these via .github/workflows/base-images.yml). The mapping is by (build-type, platforms) — see existing entries for the pattern. CI uses these prebuilt bases to skip the gRPC compile (~25–35 min cold). Local make backends/<name> ignores builder-base-image and uses the from-source path inside the Dockerfile, so you don't need quay access for local builds.
.github/backend-matrix.yml has two matrices, and they are the source of truth for which OS a backend ships on:
include: — the Linux matrix (x86_64 + arm64; CPU and CUDA / ROCm / SYCL / Vulkan).includeDarwin: — the macOS / Apple Silicon matrix (arm64; Metal where the engine supports it, otherwise a native arm64 CPU build).A new backend must target every OS it can build for — do not ship Linux-only by default. A backend that appears only under include: is silently unavailable on macOS even when its code would run there. Most C/C++/GGML engines build on Darwin out of the box (ggml defaults GGML_METAL=ON on Apple, so a plain build is Metal-enabled), and many Python backends do too (CPU / MPS wheels). If a backend genuinely cannot support an OS (e.g. CUDA-only, no CPU variant), state that in the PR description instead of omitting it silently.
Wiring a backend into includeDarwin: is more than the matrix entry:
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.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.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.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.
backend/index.yamlStep 3a: Add Meta Definition
Add a YAML anchor definition in the ## metas section (around line 2-300). Look for similar backends to use as a template such as diffusers or chatterbox
Step 3b: Add Image Entries
Add image entries at the end of the file, following the pattern of similar backends such as diffusers or chatterbox. Include both latest (production) and master (development) tags.
Note on integrity: OCI backends installed from a gallery whose verification: block is set are verified against a keyless-cosign policy before extraction; tarball/HTTP backends use the optional sha256: field. New backends do not need any extra YAML — the gallery-level verification: block covers every entry. See .agents/backend-signing.md for the producer-side CI step.
The Makefile needs to be updated in several places to support building and testing the new backend:
Step 4a: Add to .NOTPARALLEL
Add backends/<backend-name> to the .NOTPARALLEL line (around line 2) to prevent parallel execution conflicts:
.NOTPARALLEL: ... backends/<backend-name>
Step 4b: Add to prepare-test-extra
Add the backend to the prepare-test-extra target to prepare it for testing. Use the path matching your language bucket (backend/python/, backend/go/, backend/rust/, …):
prepare-test-extra: protogen-python
...
$(MAKE) -C backend/<lang>/<backend-name>
For Rust backends the target is usually the crate build target itself (e.g. $(MAKE) -C backend/rust/<backend-name> <backend-name>-grpc) so the binary is in place before test runs.
Step 4c: Add to test-extra
Add the backend to the test-extra target to run its tests — applies to Go and Rust backends too, not only Python:
test-extra: prepare-test-extra
...
$(MAKE) -C backend/<lang>/<backend-name> test
Each backend's own Makefile should define a test target so this line works regardless of language. Integration tests that need large model downloads should be gated behind an env var (see backend/rust/kokoros/'s KOKOROS_MODEL_PATH pattern) so CI only runs unit tests.
Step 4d: Add Backend Definition
Add a backend definition variable in the backend definitions section (around line 428-457). The format depends on the backend type:
For Python backends with root context (like faster-whisper, coqui):
BACKEND_<BACKEND_NAME> = <backend-name>|python|.|false|true
For Python backends with ./backend context (like chatterbox, moonshine):
BACKEND_<BACKEND_NAME> = <backend-name>|python|./backend|false|true
For Go backends:
BACKEND_<BACKEND_NAME> = <backend-name>|golang|.|false|true
For Rust backends:
BACKEND_<BACKEND_NAME> = <backend-name>|rust|.|false|true
The language field (python/golang/rust/…) must match a backend/Dockerfile.<lang> file.
Step 4e: Generate Docker Build Target
Add an eval call to generate the docker-build target (around line 480-501):
$(eval $(call generate-docker-build-target,$(BACKEND_<BACKEND_NAME>)))
Step 4f: Add to docker-build-backends
Add docker-build-<backend-name> to the docker-build-backends target (around line 507):
docker-build-backends: ... docker-build-<backend-name>
Determining the Context:
backend/python/<backend-name>/ and uses ./backend as context in the workflow file, use ./backend contextbackend/python/<backend-name>/ but uses . as context in the workflow file, use . contextA 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:
{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. 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.
A backend is not "added" until it is discoverable. Update the user-facing docs:
docs/content/features/backends.md - add the backend to the right
category in the "LocalAI supports various types of backends" list (and add a
new category if it introduces a new modality, e.g. sound classification).docs/content/ where its area lives (audio,
vision, etc.) and follow the api-endpoints checklist in
api-endpoints-and-auth.md.If the backend is a native C/C++/GGML engine created and maintained by the
LocalAI team (a from-scratch port like parakeet.cpp, ced.cpp,
vibevoice.cpp, rf-detr.cpp, not a wrapper around a third-party runtime), it
ALSO belongs in the top-level README.md table under "native C/C++/GGML
engines ... developed and maintained by the LocalAI project itself". Add a row
linking the upstream engine repo with a one-line description. This is the
project's showcase of its own engines; a new in-house backend that is missing
from it is a documentation bug.
After adding a new backend, verify:
.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)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.backend/index.yaml in the ## metas sectionbackend/index.yaml for all build variants (latest + development).NOTPARALLEL, prepare-test-extra, test-extra, backend definition, docker-build target eval, docker-build-backends)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.engineNamePreferenceRules (NOT backendBuildTagPreferenceRules, NOT servingFeaturePreferenceTokens) in pkg/system/capabilities.go. A missing entry silently ranks it last and lets the next sort key decide.docs/content/features/backends.md (and any new endpoint/realtime capability documented under docs/content/)README.mdpackage.sh)The final Dockerfile.python stage is FROM scratch — there is no system libc, no apt, no fallback library path. Only files explicitly copied from the builder stage end up in the backend image. That means any runtime dlopen your backend (or its Python deps) needs must be packaged into ${BACKEND}/lib/.
Pattern:
backend/Dockerfile.python (add it to the top-level apt-get install).package.sh in your backend directory that copies the library — and its soname symlinks — into $(dirname $0)/lib. See backend/python/vllm/package.sh for a reference implementation that walks /usr/lib/x86_64-linux-gnu, /usr/lib/aarch64-linux-gnu, etc.Dockerfile.python already runs package.sh automatically if it exists, after package-gpu-libs.sh.libbackend.sh automatically prepends ${EDIR}/lib to LD_LIBRARY_PATH at run time, so anything packaged this way is found by dlopen.How to find missing libs: when a Python module silently fails to register torch ops or you see AttributeError: '_OpNamespace' '...' object has no attribute '...', run the backend image's Python with LD_DEBUG=libs to see which dlopen failed. The filename in the error message (e.g. libnuma.so.1) is what you need to package.
To verify packaging works without trusting the host:
make docker-build-<backend>
CID=$(docker create --entrypoint=/run.sh local-ai-backend:<backend>)
docker cp $CID:/lib /tmp/check && docker rm $CID
ls /tmp/check # expect the bundled .so files + symlinks
Then boot it inside a fresh ubuntu:24.04 (which intentionally does not have the lib installed) to confirm it actually loads from the backend dir.
When you add a new backend, you MUST also make it importable via the model import form (/import-model). The import form dropdown is sourced dynamically from GET /backends/known — it reads the importer registry at core/gallery/importers/importers.go, so the steps below are the ONLY way to make your backend show up.
Required steps:
pipeline_tag, unique repo name pattern, unique artefact like modules.json):
core/gallery/importers/<backend>.go following the Match/Import pattern in llama-cpp.go.importers.go:defaultImporters in specificity order — more specific detectors must appear BEFORE more generic ones (e.g. sentencetransformers before transformers, stablediffusion-ggml before llama-cpp, vllm-omni before vllm). First match wins.ik-llama-cpp and turboquant both consume GGUF the same way llama-cpp does):
Import() to swap the emitted backend: field when preferences.backend matches. See llama-cpp.go for the pattern.sglang, tinygrad, whisperx):
core/http/endpoints/localai/backend.go that feeds /backends/known. A single line addition.core/gallery/importers/importers_test.go (Ginkgo/Gomega):
backend: in preferences wins), and — if the backend's modality has a common pipeline_tag but ambiguous artefacts — an ambiguity test asserting errors.Is(err, importers.ErrAmbiguousImport).Rules of thumb:
llama-cpp for a TTS repo because .gguf is present). Return ErrAmbiguousImport instead.go test ./core/gallery/importers/... — the existing suite will fail if you've shadowed a pre-existing detector.For reference, when moonshine was added:
backend/python/moonshine/{backend.py, Makefile, install.sh, protogen.sh, requirements.txt, run.sh, test.py, test.sh}.NOTPARALLEL lineprepare-test-extra and test-extra targetsBACKEND_MOONSHINE = moonshine|python|./backend|false|truedocker-build-moonshine to docker-build-backends