Files
LocalAI/pkg/system/capabilities.go
T
mudler-agentandEttore Di Giacinto 7c74d26555 feat(addons): run the add-on owner as its own workload (#12867)
* feat(addons): generate and load a static carrier key set

A split deployment needs the owner and the frontends to share
certificates and a signing key that outlive a process. The loopback
carrier makes fresh keys at every start, so it cannot serve that.

GenerateCarrierKeys writes a CA certificate, an owner leaf and a
frontend leaf, and the frontend signing key pair. It drops the CA key,
so nobody can issue a third leaf, and it refuses to overwrite any file.
LoadCarrierConfig reads only the files of one role and refuses key
files that group or other users can access.

The owner never forwards, but NewCarrier still requires a signing key,
so the owner config gets a throwaway key in memory instead of the
frontends' private key.

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

* feat(addons): add util addons-carrier-keys

An operator who runs the add-on owner and its frontends as separate
workloads needs a way to make the shared key set. The command wraps
GenerateCarrierKeys: --out names the directory and --owner-host, which
can repeat, sets the names the owner certificate is valid for. It prints
which files belong on the owner and which on the frontends.

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

* feat(addons): add the owner and frontend roles to local-ai run

Every local-ai run process is its own add-on owner, so a distributed
deployment cannot scale its frontends: replicas that share a data
volume fight over the owner lock.

--addons-role selects combined (today's behaviour, the default), owner
or frontend. The owner and frontend roles also take --addons-owner-address
and --addons-carrier-dir, and the owner takes --addons-owner-listen. They
use the fixed carrier identities and the key set from the carrier key
directory. The frontend role has no add-on root, so it never creates the
add-ons folder. A bad role setup stops startup with a message that names
the flag.

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

* feat(addons): keep the frontend role away from owner state

A frontend holds no add-on state, but it still wrote the library
sources file of the data path, which is the owner's file. The frontend
role now skips the library option and reads only the galleries named by
the flags, and logs a warning when it has no account database, because
its pins are then kept per frontend.

The add-on manager rejects a relative root even for a forwarding
frontend, so the frontend config names the absolute path where an owner
would keep its state. Nothing creates or opens it, and a spec checks
that the data path stays empty.

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

* test(addons): run the owner and frontends as separate applications

The split roles are only useful if an owner and its frontends work as
separate processes that share nothing but the carrier keys and the owner
address. A spec now builds them as separate Application values with
their own data paths and keys from GenerateCarrierKeys.

It covers a frontend that lists apps and reads add-on status through the
owner, two frontends on one key set, a frontend that starts while the
owner is down and answers unavailable, a second owner refused on the
same root, and an owner that restarts on the same root and address and
still serves the installed add-on to the same frontend.

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

* docs(addons): describe running the owner as its own workload

An operator who runs several frontend replicas had no documented way to
stop each one from being its own add-on owner. The add-ons page now has
a section for the owner and frontend roles: one owner replica with its
own volume and a recreate-style rollout, a stable owner address, key
generation and per-role mounts, a Compose example, what a restart and an
owner outage look like, the models-path limit, and the frontend limits.
The singleton section names the roles and the distributed-state mode,
and the distributed mode page links to the new section.

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

* feat(addons): gate the addons feature in /api/features

One function, Application.AddonsFeature, decides whether the add-on
side is on. A combined or owner process answers true when add-ons are
enabled and the owner manager exists. A frontend answers true when the
owner answered the carrier's audience call within 2 s, and keeps that
answer for 10 s so /api/features does not dial the owner per request.

/api/features reports the result as "addons", and fine_tuning and
quantization take the same value, because both are add-ons. The
frontend cache is per-instance state.

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

* feat(addons): answer 503 on every add-on route when add-ons are off

With --addons-enabled=false the real add-on, fine-tuning and
quantization routes and services are not registered. One catch-all per
prefix answers the typed unavailable response of an absent owner, for
any path under it, so a client cannot tell which routes exist. The
response is the one the gateway already writes (gateway.WriteUnavailable
over typedError), not a second format.

With add-ons on, the same routes sit behind RequireAddonsFeature, which
reads Application.AddonsFeature, so a frontend that cannot reach the
owner gives the same answer. The per-user feature middleware still runs
after the gate. No endpoint is added, so the swagger, /api/instructions
and RouteFeatureRegistry surfaces do not change.

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

* feat(addons): build no carrier for a frontend with add-ons off

addonConfigFor already returned the zero config before it read a role
flag when add-ons are disabled. Say so on the way out: when a role,
owner address, listen address or carrier directory flag is set, log one
info line that the flags are ignored. A frontend with
--addons-enabled=false needs no owner address and no key directory,
loads no keys and builds no carrier.

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

* feat(ui): hide add-on entries when the addons feature is off

The UI reads features.addons from /api/features. When it is false:
the sidebar shows no Add-ons entry and no pinned apps, the 404 page
lists no add-on destination, the shell does not call /api/addons/pins
or /api/addons/builtin, and /app/addons, /app/addons/*, /app/fine-tune
and /app/quantize lead back to /app. The Build hub tabs for add-ons,
fine-tuning and quantization were already keyed on features and now
follow the gate. An absent key (an older server) counts as on.

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

* docs(addons): describe the addons feature gate

The --addons-enabled row now says the UI hides the add-on entries and
every add-on, fine-tuning and quantization route answers 503. A new
subsection describes the addons key of /api/features, the 10 s frontend
cache as per-instance state, and that a frontend with add-ons off needs
no owner address or key directory. The fine-tuning and quantization
pages say they are off with --addons-enabled=false.

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

* fix(addons): give a frontend no add-on state root

OwnerConfig.validate required an absolute root for every role, so a
frontend named a path under the data directory that nothing was allowed
to open. Only the owner has a state root now: validate checks it when
the instance is the owner, and the frontend role leaves Root empty.
Nothing on a frontend reads or creates it.

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

* fix(addons): make the carrier signing key optional for the owner

NewCarrier required an Ed25519 signing key of every instance, so the
owner role loaded a throwaway key in memory that it never used. The
owner never signs an outbound request, so it now needs none:
NewCarrier accepts an empty key when the instance is the owner and
still requires a whole key from a frontend, and a malformed key is
refused for both. LoadCarrierConfig no longer generates one. The owner
still verifies frontend signatures with frontend-sign.pub.

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

* Keep the owner address out of the add-on identity file

The owner address is runtime configuration, so storing it and comparing
the whole file made a moved owner refuse its own root. The identity file
now holds the owner ID and storage ID only. An older file that still has
an address loads, and the address is ignored. A different owner ID or
storage ID is still refused.

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

* docs(addons): state that an existing root loads under the owner role

The owner identity no longer stores its address, so a root written by a
single node loads under the owner role when the owner and storage
identity match. Say so, and keep the caveat about absolute paths in the
journal.

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

* fix(addons): refuse library-source routes on a frontend

A frontend must keep no owner state, but the library-source routes, the
ApplyLibraries call and the startup official-libraries step still opened
and wrote addons/sources.json in its data path.

Mark the frontend role in the add-on config, also when add-ons are off.
On a frontend, LibrarySources returns the unavailable error,
ApplyLibraries does nothing and the startup step skips the store. The
six /api/library/sources routes answer 503 with the body that the add-on
feature gate writes, and an X-LocalAI-Reason header names the cause.
Reads are refused too: the carrier has no verb for them. Owner and
combined roles are unchanged.

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

* fix(addons): put the reason in the unavailable body

WriteUnavailable wrote the plain text "addon request failed", and the
library-source refusal put its reason in an X-LocalAI-Reason header.

Write the LocalAI error envelope instead, with a message, a type and the
code "unavailable", and keep the 503 status. The message names the
cause: add-ons are off on this instance, the add-on owner is not
reachable, or library sources are managed on the owner. Remove the
header.

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

* feat(addons): serve library calls on the add-on owner

Add the carrier verbs a frontend needs to work on the library through
the owner: library-list, library-add, library-update, library-enable,
library-disable, library-remove, library-backend-resolve and
library-backend-releases. A call carries the operation in one
LibraryCall field of the wire request, so an old owner rejects it as an
invalid binding. Every verb but the three reads is a mutation, and the
reply is an envelope of data or a typed error, so a frontend gives the
status the owner's own routes give.

The owner serves them through gateway.LibraryAdmin, implemented in the
application on the same LocalAdmin as the owner's HTTP routes. It runs
the owner's administrator check before any store call.

Add two peer-authenticated reads that take no actor and leave the
audience reply unchanged: GET /addons/capabilities (protocol, calls and
the owner's OS and architecture) and GET /addons/library/snapshot (the
gallery-relevant source list and its version).

Split the backend install: Installer.Resolve is the owner's half
(resolve, pick the meta backend for the asker's capability, pin by
digest) and returns a Plan; the node that installs runs the collision
check and the gallery install itself. Move the source administration
behind library.Admin and add library.ReconcileInfos for a source list
read from the owner.

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

* feat(addons): forward library routes from a frontend to the owner

A frontend refused the library-source routes and did not serve the
backend library routes. Forward them instead, and keep the owner the
only writer of the sources.

The library-source routes work on library.Admin: the local store on a
combined or owner process, the carrier on a frontend. The frontend
checks the calling account (a legacy API key is refused with 401), and
the owner checks the administrator right again before any store call.
With add-ons off, a frontend answers these routes with a 503.

A frontend asks the owner for its source list every 60 seconds through
the peer-authenticated snapshot call and rebuilds its model and backend
gallery lists, and the official-libraries switch, only when the version
changed. If the owner does not answer, it keeps the last lists and marks
them stale. Changing official_libraries on a frontend is refused with
409.

The backend library install is split. The owner resolves the entry and
pins the image; the frontend runs the gallery install on its own node,
with its own size limit and strict mode. A frontend refuses library
calls to an owner that does not serve them, or that runs on another OS
or architecture.

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

* docs(addons): document library operations on a frontend

Replace the frontend limits that said a frontend refuses the library
routes with what happens now: the library sources and the backend
library go through the owner, the gallery lists follow the owner every
60 seconds, official_libraries is set on the owner, and the owner and
the frontends must run on one OS and architecture.

Add specs for the split owner: a frontend lists sources through the
owner, a source added on a frontend reaches the owner and then the other
frontend after a refresh, a write with the owner down answers 503 with
the error envelope, official_libraries answers 409 on a frontend and a
change on the owner reaches the frontend's gallery lists, the lists are
kept and marked stale while the owner is down, a platform mismatch and a
legacy API key are refused, and a frontend installs a backend into its
own path from the owner's plan.

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

* feat(addons): check the shared models path with a probe

Exports write into the models path of the owner, and frontends load
models from their own path. A wrong mount fails silently: the export
succeeds and the model never appears.

The owner writes a probe file with a random nonce into its models path
at start. A frontend fetches the nonce over the carrier and checks at
start, then every 60 seconds, that the same file is visible at the same
absolute path. On a mismatch it logs an error, reports model_exports
false and blocks its export and import routes. The owner refuses an
export or import while its own probe is missing, and keeps running if
it cannot write the probe. An unreachable or older owner gives
"unknown" and leaves exports on.

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

* docs(api): describe the library refusal and forwarding

The library-source and backend-library routes forward to the add-on
owner on a frontend and answer 503 when the owner cannot serve them.
Add the 503 to their swagger annotations, regenerate the swagger files,
and say in /api/instructions that a frontend forwards these routes.

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

* feat(addons): let frontends run the backend update check through the owner

In named-user mode a frontend has no user to act as, so the background
backend update check failed there. Add one read-only system call for
the release lookup. The owner accepts it only on the peer-authenticated
route from a pinned frontend, only for backend-releases, and without an
actor. Calls through /addons/forward cannot carry the mark, and every
user-facing call still goes through the administrator check.

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

* feat(mcp): expose the backend update check as a tool

Add list_backend_library_updates, a read-only tool for
GET /backends/library/updates. The in-process client reads the
library installer, and the HTTP client calls the route. The six
library source routes stay out of the catalog: the assistant has no
per-call actor and no approval card for library changes.

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

* feat(api): add export status routes for fine-tuning and quantization

The add-on UIs could not know that model exports are blocked until a user
hit the refusal of the export route. Add GET /api/fine-tuning/status and
GET /api/quantization/status. Each returns {"model_exports": bool}, from
the same check that drives the model_exports key of /api/features.

The routes sit behind the add-ons gate, so they answer 503 when add-ons
are off. They are listed in the swagger spec, the instructions registry,
the route-feature registry and the feature docs.

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

* feat(addons): disable exports when the models probe fails

The fine-tuning and quantization pages now read the new status route
and turn off the export form, the checkpoint export button and the
import button when model_exports is false. A short reason shows next
to the control. A failed or slow answer leaves the controls on, as the
server does for an unknown check.

Assisted-by: Claude:claude-sonnet-5-5
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-10-24 14:29:39 +02:00

542 lines
24 KiB
Go

// Package system provides system detection utilities, including GPU/vendor detection
// and capability classification used to select optimal backends at runtime.
package system
import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/mudler/xlog"
)
const (
// Public constants - used by tests and external packages
Nvidia = "nvidia"
AMD = "amd"
Intel = "intel"
// Private constants - only used within this package
defaultCapability = "default"
disableCapability = "disable"
nvidiaL4T = "nvidia-l4t"
darwinX86 = "darwin-x86"
metal = "metal"
vulkan = "vulkan"
nvidiaCuda13 = "nvidia-cuda-13"
nvidiaCuda12 = "nvidia-cuda-12"
nvidiaL4TCuda12 = "nvidia-l4t-cuda-12"
nvidiaL4TCuda13 = "nvidia-l4t-cuda-13"
capabilityEnv = "LOCALAI_FORCE_META_BACKEND_CAPABILITY"
capabilityRunFileEnv = "LOCALAI_FORCE_META_BACKEND_CAPABILITY_RUN_FILE"
defaultRunFile = "/run/localai/capability"
// Backend detection tokens (private)
backendTokenDarwin = "darwin"
backendTokenMLX = "mlx"
backendTokenMetal = "metal"
backendTokenL4T = "l4t"
backendTokenCUDA = "cuda"
backendTokenROCM = "rocm"
backendTokenHIP = "hip"
backendTokenSYCL = "sycl"
backendTokenCPU = "cpu"
// Engine names (private). Unlike the tokens above these are whole backend
// identities as a gallery entry's `backend:` field spells them, not build
// tags. See the two preference tables below for why the distinction matters.
engineVLLM = "vllm"
engineSGLang = "sglang"
engineLlamaCpp = "llama-cpp"
engineMLX = "mlx"
// Serving feature names (private). A third vocabulary again: not a build
// tag and not an engine, but the inference-time strategy a published build
// enables. They are matched against a gallery entry's declared TAGS and
// nothing else, so they are spelled the way gallery authors spell a tag.
servingFeatureDFlash = "dflash"
servingFeatureMTP = "mtp"
)
// There are THREE preference tables below and they speak DIFFERENT VOCABULARIES,
// matched against three different things. Merging any two looks tempting and
// silently breaks a consumer, because a token that means something in one
// vocabulary means nothing in the others. Read this before editing any of them.
//
// - backendBuildTagPreferenceRules holds BUILD TAGS ("cuda", "rocm", "metal").
// They are matched as substrings of INSTALLED BACKEND BUILD DIRECTORY NAMES
// such as "llama-cpp-cuda-12" or "cuda12-vllm". Consumer: alias resolution
// in ListSystemBackends (core/gallery/backends.go), which picks which
// installed build of one alias to run.
//
// - engineNamePreferenceRules holds ENGINE NAMES ("vllm", "llama-cpp", "mlx").
// They are matched as substrings of a gallery entry's `backend:` value,
// which never carries a build tag: no entry in gallery/index.yaml contains
// "cuda", "rocm", "sycl" or "vulkan" anywhere in its backend name. Consumer:
// gallery variant auto-selection (core/gallery/resolve_variant.go), which
// picks which build of one model's weights to install.
//
// - servingFeaturePreferenceTokens holds SERVING FEATURES ("dflash", "mtp").
// They are matched against a gallery entry's declared TAGS, whole and
// case-insensitively, rather than against a backend, an engine or the entry
// name. Same consumer as the engine table, applied one rank below it. See
// the token list for why a tag is the only signal.
//
// Feeding build tags to the variant ranker matches nothing, which does not
// error: every candidate simply scores equal and size alone decides, so the
// preference silently stops existing. That is exactly the bug this split fixes.
// A serving feature fed to either of the other two tables fails the same way.
// backendPreferenceRule maps a detected capability to preferred tokens, best
// first. Both tables share this shape; only their vocabulary differs.
type backendPreferenceRule struct {
capabilityPrefix string
tokens []string
}
// backendBuildTagPreferenceRules is the BUILD TAG table. See the block comment
// above for the vocabulary contract.
//
// Matching is by capability PREFIX, because a detected capability is refined at
// runtime ("nvidia" becomes "nvidia-cuda-12" when the toolkit is present) and a
// preference is about the vendor rather than the point release. Rules are tried
// in order, so a more specific prefix must precede any rule it shares a prefix
// with.
//
// Tokens are matched as substrings of a build directory name, so "cuda" covers
// "cuda12-llama-cpp". A build matching no token is not an error and is never
// discarded; it simply sorts below every recognised one.
var backendBuildTagPreferenceRules = []backendPreferenceRule{
{Nvidia, []string{backendTokenCUDA, vulkan, backendTokenCPU}},
{AMD, []string{backendTokenROCM, backendTokenHIP, vulkan, backendTokenCPU}},
{Intel, []string{backendTokenSYCL, Intel, backendTokenCPU}},
{metal, []string{backendTokenMetal, backendTokenCPU}},
{darwinX86, []string{darwinX86, backendTokenCPU}},
{vulkan, []string{vulkan, backendTokenCPU}},
}
// defaultBackendBuildTagTokens is what a host with no matching rule prefers.
// A capability nobody has taught this table about degrades to plain CPU rather
// than to an error or to an empty list.
var defaultBackendBuildTagTokens = []string{backendTokenCPU}
// engineNamePreferenceRules is the ENGINE NAME table. See the block comment
// above for the vocabulary contract.
//
// Capability matching is by prefix, exactly as the build tag table does it.
//
// Tokens are matched as SUBSTRINGS of an engine name, which is load bearing:
// "vllm" also covers "vllm-omni", "mlx" also covers "mlx-vlm" and "mlx-audio",
// and "llama-cpp" also covers "ik-llama-cpp". Each of those is a build of the
// engine named by the token, so ranking them together is correct. Order the
// tokens so no token is a substring of an engine that should rank differently.
//
// A capability is deliberately ABSENT rather than guessed at when no engine
// ordering can be justified for it. An absent rule degrades to ordering by size
// alone, which is the behaviour that predates preference and is always safe.
//
// Safe, however, only where every candidate engine is equally at home on the
// host. Where one is not, the rule has to be written here, because the hardware
// filter will not write it: 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 a GPU serving engine is never filtered out on a host
// with no GPU. Left unranked there, it wins on size alone whenever its build is
// the larger one, and a CPU-only box installs vLLM over llama.cpp.
var engineNamePreferenceRules = []backendPreferenceRule{
// vLLM first on every host with a dedicated serving engine build. It is the
// throughput engine, and a model published with a vLLM build is published
// that way precisely because that build is the one worth running.
// SGLang sits directly behind it: same class of GPU serving engine, ships
// cuda/rocm/intel builds alike, but it is behind vLLM because vLLM covers
// far more of the gallery. llama-cpp is the portable fallback.
{Nvidia, []string{engineVLLM, engineSGLang, engineLlamaCpp}},
{AMD, []string{engineVLLM, engineSGLang, engineLlamaCpp}},
{Intel, []string{engineVLLM, engineSGLang, engineLlamaCpp}},
// MLX is the native accelerated runtime on Apple silicon, whereas a
// metal-enabled GGUF build is the portable engine merely compiled with GPU
// offload. No vLLM or SGLang build targets metal, so neither is listed.
{metal, []string{engineMLX, engineLlamaCpp}},
// A Vulkan host has exactly one LLM engine with a Vulkan build, so a
// llama-cpp variant is the only one that will use the GPU at all.
{vulkan, []string{engineLlamaCpp}},
// No usable accelerator: either nothing was detected, or a GPU was found
// with too little VRAM to serve from, both of which report "default".
// llama.cpp is the engine built to run well on a CPU; vLLM and SGLang are
// GPU serving engines whose CPU paths exist but are not what an unattended
// auto-selection should hand a CPU-only box.
//
// The GPU engines are enumerated behind llama.cpp rather than left
// unmatched. Listing llama.cpp alone would already make it win, since an
// unmatched engine ranks below every listed one, but it would leave vLLM
// and SGLang tied with each other and with any engine nobody has ranked
// yet, so download size would decide among them. Naming them fixes that
// order and says the omission of a GPU engine from the top spot is a
// decision rather than an oversight. Ranking never filters, so a vLLM
// variant offered alone is still installed here.
{defaultCapability, []string{engineLlamaCpp, engineVLLM, engineSGLang}},
// An Intel Mac has no accelerated runtime at all: MLX needs Apple silicon,
// and no vLLM or SGLang build targets darwin. Same ordering, same reasons.
// MLX is deliberately left unlisted so it ranks last, since
// IsBackendCompatible admits any darwin-tokened engine on this capability
// and will not drop it.
{darwinX86, []string{engineLlamaCpp, engineVLLM, engineSGLang}},
}
// defaultEnginePreferenceTokens is empty on purpose. It is now only reached by
// a capability nobody has taught this table about, which an operator can force
// via LOCALAI_FORCE_META_BACKEND_CAPABILITY; the detected "default" has its own
// rule above. Guessing an order for hardware we know nothing about would be
// worse than ordering by size alone, which is what the ranker reads an empty
// list as and is the behaviour that predates preference.
var defaultEnginePreferenceTokens = []string{}
// servingFeaturePreferenceTokens is the SERVING FEATURE table, best first. See
// the block comment above for the vocabulary contract.
//
// A serving feature is a way of running the same weights faster rather than a
// different set of weights: a DFlash build pairs the base GGUF with a drafter
// so the target accepts several speculated tokens per step, and an MTP build
// carries extra prediction heads to the same end. Both produce the same
// distribution as the plain build, so whenever one fits there is no reason to
// install the plain build instead.
//
// DFlash leads because it is the newer pairing and accepts more tokens per step
// in practice; MTP follows; a build naming neither is plain and ranks last.
// A build's extra weights make it strictly larger than the plain build, which
// is why this ranks BELOW fit: the size filter drops the pairing on a host too
// small for it before this list is ever consulted.
//
// Unlike the two tables above this one is NOT keyed by capability. A serving
// feature is a property of the published build, and no hardware prefers the
// plain build over an equivalent faster one, so there is no host-shaped
// ordering to express. Should one ever appear, this becomes a rule table like
// its neighbours without any consumer changing.
//
// A token is matched against an entry's declared TAGS, compared whole and
// case-insensitively, and against NOTHING ELSE. A tag is the declaration: an
// author writing "mtp" in a tag list means the feature, so it can be compared
// exactly without the ambiguity free text carries.
//
// Two rejected alternatives, because both look like obvious improvements:
//
// - The ENTRY NAME. It was the original signal and is now gone. A naming
// convention is not a contract, and a name is author-supplied free text
// where a short marker turns up inside unrelated words ("smtp-assistant"),
// or names weights that carry MTP heads without the entry enabling
// anything ("qwen3.6-27b-nvfp4-mtp", whose only option is use_jinja).
// Reading a name infers a capability nobody declared.
//
// - overrides.options, where "spec_type:draft-mtp" is what actually turns
// the feature on. 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 file, so 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: a gallery entry earns the tag
// when it configures the feature in whatever vocabulary its backend uses. The
// rule is written down in .agents/adding-gallery-models.md, and the
// backend-specific syntax never reaches the selection logic.
var servingFeaturePreferenceTokens = []string{servingFeatureDFlash, servingFeatureMTP}
// ServingFeaturePreferenceTokens returns the serving features to prefer, best
// first, for ranking gallery model variants against each other.
//
// It is a package function rather than a SystemState method precisely because
// the answer does not depend on the host; see the table for why.
func ServingFeaturePreferenceTokens() []string {
return slices.Clone(servingFeaturePreferenceTokens)
}
var (
cuda13DirExists bool
cuda12DirExists bool
)
func init() {
_, err := os.Stat(filepath.Join(string(os.PathSeparator), "usr", "local", "cuda-13"))
cuda13DirExists = err == nil
_, err = os.Stat(filepath.Join(string(os.PathSeparator), "usr", "local", "cuda-12"))
cuda12DirExists = err == nil
}
// NewCapabilityState builds a SystemState that reports exactly the supplied
// capability, bypassing all host detection.
//
// This exists so a controller can evaluate backend compatibility on behalf of
// a *remote* worker. Synthesizing a SystemState from the worker's GPU vendor
// would not work: getSystemCapabilities consults
// LOCALAI_FORCE_META_BACKEND_CAPABILITY and /run/localai/capability first, and
// container images routinely set the latter, so the controller's own forced
// capability would silently override every worker's.
func NewCapabilityState(capability string, opts ...SystemStateOptions) *SystemState {
state := &SystemState{}
for _, opt := range opts {
opt(state)
}
state.systemCapabilities = capability
return state
}
// CapabilityFromGPU derives a coarse capability from a GPU vendor and VRAM
// size, mirroring the tail of getSystemCapabilities.
//
// It is the fallback for worker nodes registered before workers began
// reporting their own capability string: the registry only persists the
// vendor and VRAM for those, so OS-dependent capabilities (metal, darwin-x86,
// nvidia-l4t) and the CUDA-runtime refinements are not recoverable. Prefer the
// worker-reported capability whenever it is present.
func CapabilityFromGPU(gpuVendor string, vram uint64) string {
if gpuVendor == "" || vram <= 4*1024*1024*1024 {
return defaultCapability
}
return gpuVendor
}
// CapabilityFilterDisabled returns true when capability-based backend filtering
// is disabled via LOCALAI_FORCE_META_BACKEND_CAPABILITY=disable.
func (s *SystemState) CapabilityFilterDisabled() bool {
return s.getSystemCapabilities() == disableCapability
}
func (s *SystemState) Capability(capMap map[string]string) string {
return PickCapability(s.getSystemCapabilities(), capMap)
}
// ReportedCapability is the capability this host reports, before it is matched
// against the capabilities a meta backend lists. A process that resolves a meta
// backend for another host sends it, and that host calls PickCapability.
func (s *SystemState) ReportedCapability() string { return s.getSystemCapabilities() }
// PickCapability chooses the key of capMap that serves a host which reports
// reportedCapability. Capability uses it for the local host.
func PickCapability(reportedCapability string, capMap map[string]string) string {
// Check if the reported capability is in the map
if _, exists := capMap[reportedCapability]; exists {
xlog.Debug("Using reported capability", "reportedCapability", reportedCapability, "capMap", capMap)
return reportedCapability
}
// Fall back to the explicit "default" catch-all, then to "cpu". The cpu
// fallback matters for meta backends that only enumerate GPU variants +
// cpu (e.g. vllm maps nvidia/amd/intel/cpu but not default): on a
// no-GPU host the reported capability is "default", so without this
// we'd filter the meta out and break auto-install by name.
if _, exists := capMap[defaultCapability]; exists {
xlog.Debug("Capability not in map, falling back to default", "reportedCapability", reportedCapability, "capMap", capMap)
return defaultCapability
}
if _, exists := capMap["cpu"]; exists {
xlog.Debug("Capability not in map, falling back to cpu", "reportedCapability", reportedCapability, "capMap", capMap)
return "cpu"
}
xlog.Debug("The requested capability was not found, using default capability", "reportedCapability", reportedCapability, "capMap", capMap)
return defaultCapability
}
func (s *SystemState) getSystemCapabilities() string {
if s.systemCapabilities != "" {
return s.systemCapabilities
}
capability := os.Getenv(capabilityEnv)
if capability != "" {
xlog.Info("Using forced capability from environment variable", "capability", capability, "env", capabilityEnv)
s.systemCapabilities = capability
return capability
}
capabilityRunFile := defaultRunFile
capabilityRunFileEnv := os.Getenv(capabilityRunFileEnv)
if capabilityRunFileEnv != "" {
capabilityRunFile = capabilityRunFileEnv
}
// Check if /run/localai/capability exists and use it
// This might be used by e.g. container images to specify which
// backends to pull in automatically when installing meta backends.
if _, err := os.Stat(capabilityRunFile); err == nil {
capability, err := os.ReadFile(capabilityRunFile)
if err == nil {
xlog.Info("Using forced capability run file", "capabilityRunFile", capabilityRunFile, "capability", string(capability), "env", capabilityRunFileEnv)
s.systemCapabilities = strings.Trim(strings.TrimSpace(string(capability)), "\n")
return s.systemCapabilities
}
}
// If we are on mac and arm64, we will return metal
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
xlog.Info("Using metal capability (arm64 on mac)", "env", capabilityEnv)
s.systemCapabilities = metal
return s.systemCapabilities
}
// If we are on mac and x86, we will return darwin-x86
if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
xlog.Info("Using darwin-x86 capability (amd64 on mac)", "env", capabilityEnv)
s.systemCapabilities = darwinX86
return s.systemCapabilities
}
// If arm64 on linux and a nvidia gpu is detected, we will return nvidia-l4t
if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
if s.GPUVendor == Nvidia {
xlog.Info("Using nvidia-l4t capability (arm64 on linux)", "env", capabilityEnv)
if cuda13DirExists {
s.systemCapabilities = nvidiaL4TCuda13
return s.systemCapabilities
}
if cuda12DirExists {
s.systemCapabilities = nvidiaL4TCuda12
return s.systemCapabilities
}
s.systemCapabilities = nvidiaL4T
return s.systemCapabilities
}
}
// No GPU detected → default capability
if s.GPUVendor == "" {
xlog.Info("Default capability (no GPU detected)", "env", capabilityEnv)
s.systemCapabilities = defaultCapability
return s.systemCapabilities
}
// GPU detected but insufficient VRAM → default with warning
if s.VRAM <= 4*1024*1024*1024 {
xlog.Warn("VRAM is less than 4GB, defaulting to CPU", "env", capabilityEnv)
s.systemCapabilities = defaultCapability
return s.systemCapabilities
}
// CUDA directories refine capability only for NVIDIA GPUs
if s.GPUVendor == Nvidia {
if cuda13DirExists {
s.systemCapabilities = nvidiaCuda13
return s.systemCapabilities
}
if cuda12DirExists {
s.systemCapabilities = nvidiaCuda12
return s.systemCapabilities
}
}
s.systemCapabilities = s.GPUVendor
return s.systemCapabilities
}
// BackendPreferenceTokens returns a list of substrings that represent the preferred
// backend implementation order for the current system capability. Callers can use
// these tokens to select the most appropriate concrete backend among multiple
// candidates sharing the same alias (e.g., "llama-cpp").
//
// These are BUILD TAGS matched against installed build directory names. For
// engine names as a gallery entry spells them, use EnginePreferenceTokens.
func (s *SystemState) BackendPreferenceTokens() []string {
return s.preferenceTokens(backendBuildTagPreferenceRules, defaultBackendBuildTagTokens)
}
// EnginePreferenceTokens returns the engine names this host prefers, best first,
// for ranking gallery model variants against each other.
//
// These are ENGINE NAMES matched against a gallery entry's `backend:` value
// ("vllm", "llama-cpp", "mlx"), never build tags. Feeding this function's output
// to installed-build alias resolution, or BackendPreferenceTokens' output to
// variant ranking, matches nothing and silently disables the preference.
//
// An empty result is normal and means "no engine ordering applies here", which
// the ranker reads as ordering by size alone.
func (s *SystemState) EnginePreferenceTokens() []string {
return s.preferenceTokens(engineNamePreferenceRules, defaultEnginePreferenceTokens)
}
// preferenceTokens resolves the current capability against one preference table.
// The rules live in the tables above; this only looks them up, so teaching
// LocalAI about a new runtime never means editing logic.
func (s *SystemState) preferenceTokens(rules []backendPreferenceRule, fallback []string) []string {
capStr := strings.ToLower(s.getSystemCapabilities())
for _, rule := range rules {
if strings.HasPrefix(capStr, rule.capabilityPrefix) {
// Copied so a caller cannot mutate the shared table out from under
// every other host lookup.
return slices.Clone(rule.tokens)
}
}
return slices.Clone(fallback)
}
// DetectedCapability returns the raw detected capability string (e.g. "metal",
// "nvidia-cuda-12", "default") with no map-membership fallback applied.
// This can be used by the UI to display what capability was detected.
//
// Why this exists alongside Capability: Capability resolves against a caller
// supplied map and falls back to "default" then "cpu" when the detected value
// is absent from that map, so its answer describes what that caller can serve
// rather than what the hardware is. A caller reasoning about the hardware
// itself, or reporting it to a human, cannot tell a genuinely detected
// "default" apart from a substituted one and needs the undecorated value.
func (s *SystemState) DetectedCapability() string {
return s.getSystemCapabilities()
}
// IsBackendCompatible checks if a backend (identified by name and URI) is compatible
// with the current system capability. This function uses getSystemCapabilities to ensure
// consistency with capability detection (including VRAM checks, environment overrides, etc.).
func (s *SystemState) IsBackendCompatible(name, uri string) bool {
if s.CapabilityFilterDisabled() {
return true
}
combined := strings.ToLower(name + " " + uri)
capability := s.getSystemCapabilities()
// Check for darwin/macOS-specific backends (mlx, metal, darwin)
isDarwinBackend := strings.Contains(combined, backendTokenDarwin) ||
strings.Contains(combined, backendTokenMLX) ||
strings.Contains(combined, backendTokenMetal)
if isDarwinBackend {
// Darwin backends require the system to be running on darwin with metal or darwin-x86 capability
return capability == metal || capability == darwinX86
}
// Check for NVIDIA L4T-specific backends (arm64 Linux with NVIDIA GPU)
// This must be checked before the general NVIDIA check as L4T backends
// may also contain "cuda" or "nvidia" in their names
isL4TBackend := strings.Contains(combined, backendTokenL4T)
if isL4TBackend {
return strings.HasPrefix(capability, nvidiaL4T)
}
// Check for NVIDIA/CUDA-specific backends (non-L4T)
isNvidiaBackend := strings.Contains(combined, backendTokenCUDA) ||
strings.Contains(combined, Nvidia)
if isNvidiaBackend {
// NVIDIA backends are compatible with nvidia, nvidia-cuda-12, nvidia-cuda-13, and l4t capabilities
return strings.HasPrefix(capability, Nvidia)
}
// Check for AMD/ROCm-specific backends
isAMDBackend := strings.Contains(combined, backendTokenROCM) ||
strings.Contains(combined, backendTokenHIP) ||
strings.Contains(combined, AMD)
if isAMDBackend {
return capability == AMD
}
// Check for Intel/SYCL-specific backends
isIntelBackend := strings.Contains(combined, backendTokenSYCL) ||
strings.Contains(combined, Intel)
if isIntelBackend {
return capability == Intel
}
// CPU backends are always compatible
return true
}