Commit Graph
8703 Commits
Author SHA1 Message Date
mudler-agentandEttore Di Giacinto 0b5cab414a feat(galleryop): limit concurrent backend operations (#12888)
* feat(config): add a limit for concurrent backend operations

Add --max-concurrent-backend-operations
(LOCALAI_MAX_CONCURRENT_BACKEND_OPERATIONS) and the runtime setting
max_concurrent_backend_operations. It sets how many backend install,
upgrade and delete operations run at the same time. The default is 1,
which keeps the current behaviour. Values below 1 become 1.

The runtime value is also kept in an atomic, so the scheduler that reads
it never races with a settings change. The setting shows in the Settings
page under Backends. This commit only adds the setting; the next one
uses it.

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

* feat(galleryop): run backend operations in a bounded pool

One goroutine read both gallery channels and ran each operation inline,
so one slow backend operation held every operation behind it, model
operations included. In distributed mode the manager also visited the
nodes one at a time and waited for each up to the install timeout.

Backend operations now go through a scheduler that runs up to
max_concurrent_backend_operations of them at once. Operations start in
the order they arrive. Install, upgrade and delete of one backend share
a key and never overlap; an operation that waits for its backend does
not hold a slot, so independent operations pass it. Cancelling a queued
operation drops it at once. The limit is read on every scheduling step,
so a runtime change applies without a restart. With the default of 1 the
behaviour is the old single worker. Model operations get their own loop
and stay serial.

In distributed mode the manager sends one operation to its nodes in
parallel, up to the same limit, and keeps the result in node order. A
per (node, backend) lock keeps a live operation and the reconciler from
sending the same operation to a node together; the reconciler skips a
row that is locked and checks that the row still exists before it sends.
Timeouts are unchanged.

The download progress timers were plain package variables. They are
atomics now, because operations can run in parallel.

Assisted-by: Claude:claude-sonnet-5-5 go-vet go-test
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-25 00:26:45 +02:00
mudler-agentandEttore Di Giacinto 21096764e2 ci(addons): run the add-on packages workflow on packaging test changes (#12889)
The workflow listed addons, pkg/addons, scripts/addons and
gallery/addons, but not tests/addons. The validate job runs the
packaging contract test in tests/addons/packaging through
scripts/addons/package_test.sh. A change to that test did not start the
workflow, so commit 48478a89c (#12884), which fixed a failing spec
there, was never exercised.

Add tests/addons/packaging/** to the push and pull_request path lists.

Assisted-by: Claude:claude-sonnet-5-5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-25 00:26:11 +02:00
localai-org-maint-botandlocalai-org-maint-bot a9236ef74c feat(gallery): add Nemotron Bolt E4B variants (#12880)
Add Q4 and Q8 GGUF builds with the F16 vision projector. Link the builds
as variants and document installation.

Assisted-by: Codex:gpt-6

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-10-24 23:50:41 +02:00
localai-org-maint-botandmudler 4e496bd5bb chore(model-gallery): ⬆️ update checksum (#12886)
⬆️ Checksum updates in the model library

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-10-24 23:47:18 +02:00
mudler-agentandEttore Di Giacinto c60a2e47d6 feat(backends): timestral.cpp forecast backend and /v1/forecast API (#12885)
* feat(grpc): add a Forecast RPC for time-series models

Add Forecast to the backend service with columnar request and response
messages: flat row-major arrays, NaN for missing values, and one message
per series so a caller can send series of different shapes in one call.
The response keeps the request order.

Wire the RPC through the server, client, embedded client, connection
evicting client, in-flight tracking client and the base backend, which
answers unimplemented. core/backend gains a Forecast helper that stamps
the model identity and records a trace, like the other single-purpose
RPCs.

Assisted-by: Claude:claude-sonnet-5-5 go-vet

* feat(config): add the forecast usecase

FLAG_FORECAST marks a model as a time-series forecaster. The timestral-cpp
backend declares it as its default usecase, so a config with that backend
needs no extra lines. The backend is also listed as a non-text backend, so
a forecast model is never offered as a chat model. It shows up in the
gallery filter and in the capability list.

Assisted-by: Claude:claude-sonnet-5-5 go-vet

* feat(api): add POST /v1/forecast

Take one or more numeric series and return quantile trajectories for the
next prediction_length steps. Targets are [variates][steps] or a flat
array for one variate; JSON null marks a missing value. Past and known
covariates and categorical codes are checked for length before the
backend sees them. Quantiles default to 0.1, 0.5 and 0.9 in the backend.

The route is gated by a new default-on forecast API feature. The endpoint
is listed in the swagger spec, the /api/instructions index, the UI model
filter and the capability symbols.

Assisted-by: Claude:claude-sonnet-5-5 go-vet

* feat(backends): add the timestral-cpp forecast backend

Wrap the timestral.cpp C ABI with purego, the way the other ggml
backends do: libtimestral is built from a pinned commit with ggml
embedded, and the Go binary loads it at start.

Forecast validates the counts against the array lengths, groups the
series by shape, makes one library call per group and puts the results
back in the order the caller sent them. Quantiles default to 0.1, 0.5
and 0.9, and a level the model does not have is refused with the list of
valid ones. A mutex guards the handle because the library is not
reentrant. Request size is bounded by two model options.

The unit specs use a fake library. The integration specs and a gRPC
spec run against the 5M F32 model that test.sh downloads and checks
against a fixed SHA-256.

Assisted-by: Claude:claude-sonnet-5-5 go-vet

* build(backends): register timestral-cpp in the build and the library

Add the CPU image to the backend matrix for amd64 and arm64, plus an
Apple silicon entry that is not built or tested yet. The image has no GPU
keys because the engine runs on the CPU only. Wire the make targets, the
path filter and the dependency bump bot.

The library entries were generated with backendcatalog. The images are
not published yet, so the release entries are unpinned until a
maintainer runs --repin. The gallery reader test now counts the names in
the library instead of a fixed number, so adding a backend does not
break it.

Assisted-by: Claude:claude-sonnet-5-5 go-vet

* feat(gallery): add the Timestral forecast models

Add Timestral 5M, 28M, 123M and 209M as F32 entries, each with its F16
build as a variant. Q8_0 and the K-quants are left out until the quality
question is settled. Each entry runs on the timestral-cpp backend, on
the CPU only.

The weights use Mistral's Modified MIT licence, which bars use by
companies above a monthly revenue threshold. The first paragraph after
the summary says so, the licence file is installed next to the weights,
and the revenue-restricted-license tag makes the entries filterable.

The memory each build needs has not been measured, so no size is set.

Assisted-by: Claude:claude-sonnet-5-5

* docs(forecast): document /v1/forecast and the Timestral models

Add a feature page with the request and response shapes, the error
cases, the model options and the licence note. List the engine in the
README, the compatibility table and the backend categories.

Assisted-by: Claude:claude-sonnet-5-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 22:47:04 +02:00
mudler-agentandEttore Di Giacinto b9b5434692 test(ui): stub the pin routes in the add-on e2e fixtures (#12882)
test(ui): stub the add-on routes in the add-on e2e fixtures

The add-on owner now runs as its own workload. With no owner running the
server reports the add-ons feature off and answers 5xx on
/api/addons/builtin and /api/addons/pins. The add-on pages are gated on
that flag, and the navigation shell calls both routes, which the browser
logs as failed requests. Specs that draw add-on pages assert no console
errors, so they failed on master.

Stub those routes on the browser context: the feature flag is switched on
with the server's other flags kept, and the pin routes answer with an empty
set. A spec's own page routes still take precedence. Studio's old-address
and 404 specs call the same stub, since they expect add-ons to be on. The
server behaviour is unchanged.

Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 22:46:09 +02:00
mudler-agentandEttore Di Giacinto 48478a89cd test(addons): refuse an unlisted platform in the packaging contract (#12884)
Tables now enables the four platforms the CI hosts run on (#12879), so the
spec that expected the host platform to be refused as not enabled failed on
the add-on packages run for 88aa56518. The refused platform is now one the
matrix never lists.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 22:06:27 +02:00
mudler-agentandEttore Di Giacinto f8b7576dc7 feat(addons): install development add-ons by tag, as backends do (#12883)
A development add-on entry now names a moving tag, sets mutable and has
no digest, the same shape a development backend entry has. A release
entry stays pinned by digest.

One helper, catalog.Pin, turns a mutable entry into a digest at install:
it resolves the tag, checks the digest form and builds the version from
the first 12 hex digits. The backend library installer and the add-on
installer both call it. The add-on installer then verifies the signature
against the resolved digest and records that digest in the receipt. Kind,
platform and install label checks stay in each caller.

The official index lists the development entries as <id>-development,
which the publish workflow already moves in its promote job. The catalog
command renders the development channel as mutable entries. The update
check reports no state for a mutable entry until it resolves the tag too.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 22:04:56 +02:00
mudler-agentandEttore Di Giacinto b8f5453cc1 ci(addons): publish every add-on to one repository (#12881)
All add-ons now publish to ADDON_OCI_REPOSITORY as tags of one
repository. Each tag starts with the add-on id: <id>-sha-<short sha> for
a commit, <id>-<version> for a release, <id>-development and
<id>-latest for the channel aliases.

The publish step, the alias promotion and the catalog renderer add the
prefix, so the per-add-on repository step of the workflow is gone. A new
addonpkg target command prints the reference an add-on publishes to
without a network request. The workflow policy now refuses a derived
repository.

The host parser already reads a tag reference and a repository with a
nested path, so both the new form and the old per-repository form
install. A test covers each.

The artifact fields in gallery/addons/index.yaml were changed by hand to
the tag form. The digests are unchanged and stay until the add-ons are
published again.


Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 18:26:40 +02:00
mudler-agentandEttore Di Giacinto 34306b852d fix(ci): give tables a platform so add-on packages publish (#12879)
The plan job of the add-on package workflow runs the platform matrix
for every add-on. On a publishing event it fails closed when an add-on
has no enabled platform. The Tables platform file listed three
platforms, all with enabled: false, so every master push failed
the plan and nothing was published or signed.

Enable the same four platforms as the other first-party add-ons
(linux/amd64, linux/arm64, darwin/arm64, darwin/amd64), each with its
native runner and the executable-start check. The fail-closed check in
the workflow is unchanged.

The tests assumed Tables was disabled. The matrix spec now requires at
least one enabled platform in the checked-in Tables file. The platform
spec covers Tables like the other add-ons. The shell suite builds a
disabled copy of the Tables file for the two fail-closed cases.


Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 18:08:54 +02:00
mudler-agentandEttore Di Giacinto 9c4a8b2a87 fix(ui): keep backends off the add-on browse list (#12878)
The add-on Browse list showed backend entries next to add-ons, with
their own install flow. Backends belong on the Backends page, where
the library chips already cover them.

Ask the library for add-ons only and drop any entry that still says
it is a backend. Entries with no kind stay add-ons. Remove the
backend-only install flow from the Browse view.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 15:56:39 +02:00
mudler-agentandEttore Di Giacinto 840fa3cc00 test(nodes): give the DistributedBackendManager fixtures a system state (#12877)
The disable-backend-install switch (#12754) makes InstallBackend and
UpgradeBackend read d.systemState.DisableBackendInstall. The test
fixtures build the manager as a struct literal or through
NewDistributedBackendManager with an empty ApplicationConfig, so
systemState is nil and every install and upgrade spec panics with a nil
pointer dereference.

Set a zero-value SystemState in each fixture. That keeps installs
allowed, which is what these specs expect.

Assisted-by: Claude Code:claude-haiku-5-5

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 15:34:21 +02:00
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
mudler-agentandEttore Di Giacinto 994d3acd4d fix(ci): repair master failures in CLI bind test, addons UI build and e2e (#12876)
* test(cli): use a globally routable IPv6 address in the public bind spec

2001:db8::/32 is the IPv6 documentation prefix and is now classed as
non-public, so the bind is trusted and the spec failed. Use a public
address from 2606:4700:4700::/48 instead.

* fix(addons): rename installFlow.js so it no longer clashes with InstallFlow.jsx

installFlow.js and InstallFlow.jsx differ only by case. On a
case-insensitive filesystem the bundler can resolve
`import FlowStatus from './InstallFlow'` to the .js module, which has
no default export, and the macOS build fails with MISSING_EXPORT.

Rename the module to installAndTurnOn.js, after its main export, and
update its two importers. No code changes.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(addons): keep Reload on the page of an add-on that is turned off

The clearer empty states for a stopped add-on dropped the Reload button
from the unavailable page, so a user who turned the add-on back on had
no way to mount it again without leaving the page. Render the button in
the actions of the off state, next to Turn on.

* test(ui): serve the Voice add-on in the diarization profiles spec

The remembered voices link now opens the page of the Voice add-on, which
the core build no longer draws. Install the built Voice bundle next to
Studio, as the other specs do for their add-ons, and build it in the
global setup.

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 10:05:30 +02:00
localai-org-maint-botandmudler 795d9dac7b chore: ⬆️ Update ikawrakow/ik_llama.cpp to 804ee29c28a5e892df6e84941cb78628ed4c266c (#12866)
⬆️ 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-10-24 08:37:41 +02:00
localai-org-maint-botandmudler 4449adb395 chore(model-gallery): ⬆️ update checksum (#12874)
⬆️ Checksum updates in the model library

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-10-24 01:30:57 +02:00
mudler-agentandEttore Di Giacinto 98e3f2ef95 feat(addons): log the reason behind a generic denial (#12875)
The dispatch and sibling addon routes answer 403 "Addon request
denied" for every error that is not a named type, and the server
logged only the status code. An operator could not tell which check
refused a request.

Write one Warn line at each fall-through to the generic denial: the
action (or route pattern), the add-on ID, the typed error kind and the
innermost message of the error chain. Fixed refusals (the recovery and
update action rule, an invalid add-on ID, digest or path) log the rule
name. The request body, arguments, headers, cookies and credential
references are never logged. The response is unchanged.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-24 01:30:41 +02:00
3d68e7228c realtime: announce speakers in transcription-only sessions (#12871)
feat(realtime): announce speakers in transcription-only sessions

With voice_recognition.identity.announce on, a realtime session sends
a conversation.item.speaker event for each user turn. Only
generateResponse sent this event. A transcription-only session never
calls generateResponse, so it resolved the speaker of each turn and
then dropped the result.

In a transcription-only session with announce on, send the event
before the completed transcription event of the turn, with the same
item_id. The rules are the same as on the response path: no event for
an unknown speaker unless announce_unknown is set, and no event for a
speaker that an enforcing gate rejects.

Sessions with announce off and response sessions do not change.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:07:02 +02:00
G ManandEttore Di Giacinto 3934ee8e34 fix(mlx-vlm): bump mlx-vlm pin to v0.6.12 (#12818)
mlx-vlm v0.4.4 attaches the image placeholder to the last message that is
not system or assistant. After a tool call that is the tool result, which
the chat template renders as text, so the next turn fails with "Image
features and image tokens do not match: tokens: 0". v0.6.12 keeps the
placeholder on the user message that carried the image.

The v0.4.4 pin (#9568) worked around the mlx-cuda wheels stopping at
0.31.1. They are at 0.32.4 now, and v0.6.12 (mlx>=0.32.0) resolves with
mlx[cuda12] and mlx[cuda13]. v0.7.x would need backend changes: it drops
mlx_vlm.tool_parsers, which backend.py imports.

Signed-off-by: gman <genx7up@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-10-23 16:46:14 +02:00
mudler-agentandEttore Di Giacinto 86994fac46 feat(gallery): add flint and bump llama.cpp to a37cc461 (#12854)
* chore(llama-cpp): bump to a37cc461 and drop the TTS patch

Move the llama.cpp pin to a37cc461, the merge of the Flint
architecture (ggml-org/llama.cpp#30928).

The TTS task patch is gone: upstream now carries
SERVER_TASK_TYPE_TTS, server_task::tts_inp, server_task_result_tts,
server_context_meta::has_cap_tts and the mtmd gen-audio helpers. The
patch is detected as already applied on the new pin. The gRPC server
uses the same symbols. The bonsai and turboquant forks still lack
them and keep using disable-tts-task.sh, so they are unchanged.

The score task patch is still needed: upstream has no
SERVER_TASK_TYPE_SCORE. It is regenerated against the new pin and
applies with no offset or fuzz. Upstream added
SERVER_TASK_TYPE_TRANSCRIBE_RT to server_task::need_logits(), which
broke one hunk, so SCORE is added next to it by hand.

The C++ backend was not built for this change.

Assisted-by: Claude:claude-sonnet-5-5

* feat(gallery): add flint

Add Recursive Flint, a 39.8B mixture-of-experts coding model with
about 2.5B active parameters, from the Unsloth GGUF repository. It
runs on llama.cpp with the Flint architecture support added upstream
in ggml-org/llama.cpp#30928.

Three quantizations are listed, with the default declaring the others
as variants: UD-Q4_K_XL (25 GB), UD-Q2_K_XL (13.6 GB) for small hosts
and UD-Q6_K_XL (36 GB) for larger ones. Sampling defaults follow the
model card (temperature 1, top_p 0.95, top_k 20). The library file
gallery/models/index.yaml is edited and gallery/index.yaml is
regenerated from it.

Assisted-by: Claude:claude-sonnet-5-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-23 15:58:25 +02:00
localai-org-maint-botandmudler 08de1cb919 chore: ⬆️ Update ikawrakow/ik_llama.cpp to c12b5ac33be74ea4fd09a5beed371725ddf2afe1 (#12841)
⬆️ 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-10-23 15:26:05 +02:00
localai-org-maint-botandmudler ac7c84802e chore: ⬆️ Update mudler/vllm.cpp to c9e6bb7b3ae7f7676ea35f570d5a54af12a6a73b (#12842)
⬆️ Update mudler/vllm.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-10-23 15:25:51 +02:00
mudler-agentandEttore Di Giacinto c6687ce67c feat(addons): add-ons UI direction B: one name, Turn on and Turn off, preview pane (#12858)
* refactor(addons): one name and one verb pair in the add-ons UI

The sidebar, the page and the tabs now say Add-ons. The tabs are My
add-ons, Find add-ons and Sources. Turn on and Turn off replace Enable,
Start, Disable and Stop wherever they label a button, and the state words
read On and Off. The subtitle no longer names the owner.

Other locales get the new keys; the main labels are translated and the
rest carry the English text until a translator refines them. A unit test
checks that every locale has the keys of English.

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

* feat(addons): show what an add-on does on its card and move actions to a menu

A card now shows the add-on description, an "Adds a page" line and the
number of chat tools when they apply. The pin button and the bare arrows
are replaced by one overflow menu with Show in sidebar, Details and
Remove. Reordering a pinned add-on stays in the same menu. Remove asks
first and runs the typed uninstall action; only administrators see it.

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

* feat(addons): add a preview pane for the selected add-on

Selecting a card, or choosing Details in its menu, opens a pane beside
the list. It shows the name and state, the description, what the add-on
adds, Turn on or Turn off and Remove, and its version and source. The pane
replaces the modal sheet and uses the data the list already has. The
source comes from the installed list that the host already serves to
administrators. Screenshot slots show only when the host lists images for
the add-on; today it lists none, so no slot is drawn.

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

* feat(addons): clear first-run and empty states for My add-ons

With nothing installed, an administrator now sees three add-ons from the
sources, each with Install and turn on, and a link to Find add-ons. A
search that finds nothing says that no add-on matches and offers to clear
the search, instead of saying that nothing is installed. Install and turn
on is one action of the user: it installs, then turns the add-on on, with
a separate operation identity for each step and the progress or the reason
for a failure shown on the card.

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

* feat(addons): one button and folded details on Find add-ons entries

Each entry shows its description, an "Adds a page" label when the library
entry declares a main page, and one button: Install and turn on. An
add-on that is installed but off offers Turn on. Details is folded into
the entry and shows the trust and build facts the entry already carries,
so the separate details sheet is gone. A backend keeps a plain Install,
because a backend has no on or off.

The library entry carries no tool count, so the entry cannot say how many
chat tools an add-on adds. That label appears on My add-ons, where the
list knows it.

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

* feat(addons): install and turn on in one dialog with inline progress

Install and turn on now asks once. The dialog then stays open and shows
two steps, the install and the turn on, and ends on the result or on the
reason a step failed with a way to try again. A failed turn on keeps the
install and the retry only turns the add-on on. Each step is its own
action with its own operation identity, so an unknown outcome still
blocks further actions on the add-on until it is read or dismissed. The
first-run cards use the same dialog. The two-step flow is a plain function
with unit tests.

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

* refactor(addons): fold the Installed tab into My add-ons

The page now has three tabs: My add-ons, Find add-ons and Sources. What
the Installed tab showed is in the preview pane of each add-on, under
More details for administrators: health, trust, compatibility, storage,
data recovery and updates, with Check for updates and Upgrade. Remove
and Turn on or Turn off are on the card and in the pane. An old
?tab=installed address opens My add-ons, and the management link
?manage=ID opens the details of that add-on. A card shows an update mark
when the host reports one. Closing the pane returns focus to its card.

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

* docs(addons): describe the new add-ons page labels

Update the add-ons feature page and the pages that point at it: the
sidebar item and the page are called Add-ons, the tabs are My add-ons,
Find add-ons and Sources, Turn on and Turn off replace Start and Stop,
the card menu replaces the pin control, and the preview pane holds what
the Installed tab showed.

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

* feat(addons): say what is wrong on the page of an add-on that cannot open

The page of an add-on that cannot open now depends on the add-on. One
that is installed but off says "<name> is off" and offers Turn on to an
administrator. One that is not installed says so and sends an
administrator to Find add-ons. One that failed shows the reason and a link
to its details. A reader gets a line that says who can fix it, and no
button. The page title is the name of the add-on, with a capital first
letter when the host lists no title, and no longer the bare identifier.

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

* feat(chat): put the add-on tools control next to MCP and say what off means

The add-on tools control moves from the chat header to the composer, right
after the MCP control, because both decide what the model may call in this
chat. Its menu starts with one line: an add-on that is off here does
nothing in this chat. Only add-ons that ship tools appear in it, as
before.

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

* chore(addons): clear lint warnings of the new components

Add the lint header the add-on components use for JSX-only imports, drop
an unneeded directive, stop exporting a helper from a component file and
extract the dependencies of an effect.

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

* test(addons): update the backend library specs for the folded entry details

A backend says its kind on the entry, an add-on says it under Details,
Details is a fold of the entry and an installed backend has no Install
button.

Assisted-by: Claude:claude-sonnet-5-5 [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-10-23 15:25:37 +02:00
mudler-agentandEttore Di Giacinto 0a44303c21 fix(addons): one page title, one primary action and clearer empty states (#12850)
A visual pass over the add-on pages found the same few problems on
several of them. Fix them with one rule per problem instead of page by
page.

Titles: every add-on page takes the host page-title size. Talk and Tables
no longer shrink it, Studio draws one "Studio" title above its tabs (the
card of a generator is a section), and Voice keeps one title on all its
tabs. The Text to speech tab gets a styled form. The "not available"
page shows the add-on name, not the lowercase id, and sits at the same
inset as an add-on page.

One primary action: the empty state of a list carries the add button, so
the header button waits until there is something in the list (Voice
library, enrolling a speaker or a person, Add rows). The Add-ons page
shows one way into Browse. A table with no columns says so once.

Tables: Share is hidden when there are no named accounts. The active
table tab is highlighted and an empty tab list leaves no gap before
"New table".

Studio: the tab strip stays on one row at 1280 wide, /studio/3d opens
the 3D tab, and an input chip has a divider between its label and its
placeholder.

Fine-tune and Quantize: the Experimental badge sits beside the lede, not
inside the h1; an open step shows its number instead of an empty circle;
the sticky action bar keeps a gap above it.

The footer shows nothing for the empty version of a source build, and
the Voice Text to speech tab says where to get a model when none is
installed.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-23 12:35:44 +02:00
mudler-agentandEttore Di Giacinto 6108ba69fa fix(ci): make master green after the library merge (#12855)
The library change added InstallLibraryBackend to the LocalAIClient
interface, but the stub in the MCP endpoint test was not updated. The
mcp test package no longer compiled, which failed golangci-lint and
stopped the Ginkgo run in tests-linux and tests-apple.

The same change added the official_libraries setting, so the Settings
page now shows 55 settings plus the three branding images. Update the
count the grouping spec expects from 57 to 58.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-23 12:27:29 +02:00
localai-org-maint-botandmudler 7d528e90f4 docs: ⬆️ update docs version mudler/LocalAI (#12839)
⬆️ 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-10-23 12:02:29 +02:00
localai-org-maint-botandmudler 0c6ee7080a chore: ⬆️ Update 0xShug0/audio.cpp to 80e66d2cfedc9c113c7c8034ff4030afd43ea33f (#12843)
⬆️ Update 0xShug0/audio.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-10-23 12:02:17 +02:00
localai-org-maint-botandmudler 320e45d420 chore(model-gallery): ⬆️ update checksum (#12846)
⬆️ 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-10-23 11:56:27 +02:00
mudler-agentandEttore Di Giacinto 3b179ed142 ci(gallery): have the index bots write the model library (#12849)
* ci(gallery): have the checksum bot write the model library

The checksum bot edited gallery/index.yaml, which is a generated copy
of gallery/models/index.yaml. The model library check fails any PR
where the copy differs from the generated one, and an edit made to the
copy never reaches the source.

Point the checker at the library, read the files under .model.files as
the library keeps them, and run modelcatalog after the checker so the
PR carries both files.

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

* ci(gallery): have the other index bots write the model library

The gallery agent and the variant proposer still read and wrote the
generated gallery copy, so their pull requests failed the model
library check and their changes never reached the source.

The gallery agent now appends library entries to
gallery/models/index.yaml, converting its entries with the gallery
package and skipping ids the library already has. Its duplicate check
reads the same file.

The variant proposer decodes the library shape (the body under
model:) and splices variants into the model body through a library
layout in galleryedit. The anchor refusal goes: the library has no
anchors. Its tests use library entries.

Both workflows run modelcatalog after the bot and before the pull
request step, and use the Go version from go.mod.

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-23 11:55:56 +02:00
mudler-agentandEttore Di Giacinto 60254e582d refactor(ui): remove the built-in copies of the add-on pages (#12852)
The Face recognition, Voice recognition, Voice library, Fine-tune and
Quantize pages now live only in their add-ons. The router still kept a
built-in copy of each one and showed it when the add-on was not
installed, which meant two pages to maintain for the same job.

Replace the MovedPage wrapper with a plain redirect. The old addresses
(/app/face, /app/voice, /app/voice-library, /app/fine-tune and the
rest) keep working and always open the add-on route. When the add-on is
not installed or not ready, the add-on shell shows its "not available"
page. The model of /app/face/:model and /app/voice/:model still travels
as ?model=.

Delete the six page files, the components, styles and helpers only they
used, the biometrics translation namespace, and the end-to-end specs
that tested the built-in copies. Drop the two add-on parity tests that
compared the add-on page with the deleted core page. Server APIs and
feature flags are unchanged.


Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-23 11:54:53 +02:00
mudler-agentandEttore Di Giacinto 756b019ce4 [httpclient] validate redirect targets and pin dialed addresses for user URLs (#12853)
fix(httpclient): validate redirect targets and pin dialed addresses for user URLs

Clients that fetch user-supplied URLs followed redirects without checking
the target, so a public host could send them to a loopback, link-local
or private address. The host was also resolved twice, once to validate
it and once to connect, so a changing DNS answer could bypass the check.

Add httpclient.WithExternalOnly. It validates every redirect hop against
the external URL policy and, for the default transport, resolves the
host, refuses it if any address is non-public, and dials the validated
address. Environment proxies are not used for such clients.

Move the policy (ValidateExternalURL, IsPublicIP) into pkg/httpclient
so it can be used without an import cycle; pkg/utils keeps delegating
to it. IsPublicIP now also refuses 100.64.0.0/10, 0.0.0.0/8,
192.0.0.0/24, 198.18.0.0/15, 240.0.0.0/4, multicast, and NAT64
addresses that wrap a non-public IPv4 address.

Use the option for the base64, image, audio and video downloads, and
drop the video client's own per-hop check.

Assisted-by: Claude Code:claude-haiku-5-5

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-23 11:54:42 +02:00
mudler-agentandEttore Di Giacinto f1ff707b97 feat(addons): list and install backends from a library (#12816)
* feat(addons): add the add-on runtime, navigation and first-party add-ons

Add the add-on runtime to LocalAI: manifests, the host operation broker,
admin gating by account role, the add-on UI bundle builder and the SDK
for add-on pages. Move the navigation to Chat, Models, Add-ons and
Settings, with pinned add-ons in the sidebar and an Add-ons drawer.

Ship the first-party add-ons (voice, face, fine-tuning, quantization) as
packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS
(darwin/amd64) is enabled in the platform files.

Squashed from the review branch. The tree is unchanged; the pre-squash
history is kept on a local backup branch.

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

* fix(gosec): tighten file modes and annotate false positives

Write job state, configs and imported models with mode 0600, clean
the paths that are read from disk, and mark the route tables, the
header name and the retry jitter as reviewed false positives.

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

* fix(addons): keep API keys off recovery mutations and same-site cookies

POST /api/addons/dispatch forwarded the recovery and update
mutations to the owner for any administrator key, which skipped the
browser-session rule of their own routes. Refuse those actions
there. Also apply the same-origin rule to the legacy token cookie,
not only the session cookie, because the global CSRF check lets
same-site requests through.

Do not fail the start of LocalAI when an add-on library cannot be
refreshed; log it and keep the installed add-ons up.

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

* fix(addons): refuse a reinstall that would migrate retained data unbacked

An upgrade takes a verified backup before the new package migrates
the data. A reinstall over retained data has no running generation
to stop, so the new package would migrate it without one. Refuse a
reinstall whose data format differs from the retained one.

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

* fix(addons): release the manager lock while a release is fetched

Submit held the state lock across the catalog lookup and the package
download, so the apps list and status reads stalled for the whole
install. Serialize Submit and Reconcile with their own lock and drop
the state lock around the fetch only.

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

* fix(addons): kill an add-on only after three missed health probes

One slow 100 ms probe killed a busy add-on and dropped the work it
was serving. Require three misses in a row.

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

* feat(ui): move settings under the operate hub

The Operate hub already has Settings as a tab, so the sidebar no longer shows a separate Settings entry. The /app/settings route stays, and the hub tab still reaches it. The navigation test now expects Chat, Models and Add-ons as the primary items.

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

* fix(addons): accept same-host browser requests that carry no fetch metadata

Browsers do not send Sec-Fetch-* headers over plain HTTP on an address
that is not localhost, so every add-on request from a LAN address was
refused as cross-site. Keep refusing a Sec-Fetch-Site value other than
same-origin. When the header is absent, accept the request only if its
Origin, or failing that its Referer, names the same host and port as
the request.

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

* fix(ui): show the server's reason when an add-on request is refused

A 403 or 400 answer showed a generic message and hid the reason in the
response body, so a refusal by the browser guard could not be told from
a missing permission. Show error.message from the body as the server
sent it, and keep the generic text when the body has none.

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

* feat(addons): run the add-on owner in the normal binary

The owner was only enabled when an embedding host set the add-on config,
so a normal run answered every add-on request as unavailable.

Add --addons-enabled (default true) and --addons-ownership-mode
(default named-user, or local-owner; anything else stops startup).
The node is its own owner: state lives under the data path, and the
carrier uses loopback keys made at each start and kept in memory.
--addons-enabled=false turns the owner off completely.

Named-user mode needs the account database. With authentication off
the owner stays off and a warning says how to use local-owner.

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

* fix(addons): keep the loopback owner address stable across restarts

The single-node loopback transport took a new ephemeral port at each
start. The owner stores its address in its identity file and refuses to
start when the address differs, so every start after the first failed
with "invalid addon owner configuration".

Choose the port once, keep it in the add-on state root (loopback-port,
mode 0600) and reuse it. If the port is taken at a later start, stop
with an error that names the port and --addons-enabled=false, because
another port would break the identity check. The keys and certificate
stay in memory.

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

* feat(addons): let add-on pages keep the host modules they were written against

A page written for the web UI imports React, the router, i18next and a
few shared components. The add-on builder refuses a bundle that holds a
host file, so moving such a page into an add-on meant rewriting it.

Add a compat mode to the shared builder. It answers those imports from a
directory of stand-ins that read the SDK, bundles the dynamic import of
the page code into the one output file, and collects the style sheets the
page imports. A relative import that is not in the add-on's own source
resolves to the file at the same path under the compat directory.

Widen the SDK for these pages, without a version change: more host
components (spinner, modal, lightbox, model picker) and hooks (models,
resources, voice profiles, operations, MCP client), a style on icons,
the icon names the pages draw, sdk.navigation.openApp to open a page of
the app (only addresses under /app, and the way back as the only state)
and sdk.portal. The shell passes the query string to the page.

The stand-ins cover the router (the tab in the path, the model in the
query), the translator over the host locale files, the core API calls
through sdk.core.fetch, and byte copies of the pure helpers, with tests
that keep the copies equal to the originals.

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

* feat(addons): ship Studio and Talk as add-ons instead of built-in pages

Studio and Talk were compiled into the web bundle and always listed in
the Add-ons drawer. They are add-ons now, like Voice, Face, fine-tuning
and quantization: a person installs and removes them, the drawer lists
them only while they are installed, and they are packaged the same way.

Move the pages, their components, hooks and helpers into addons/studio
and addons/talk. Each has a manifest, a UI bundle, a release platform
matrix and a catalog template, and is built by the compat mode of the
shared builder. The core API stays where it is: the pages call it with
the signed-in person's session. The history keeps its browser storage
keys. The unit tests of the moved helpers move with them.

In the core, drop the two pages, the Create group of the sidebar (Chat
joins Home and Models in the old layout), the built-in catalog entries
(the catalog is empty) and the talk locale namespace. The old addresses
(/app/studio, /app/talk and the generator links) redirect to the add-on
pages, with the model as ?model=. The Home /studio command is offered
only while the Studio add-on is installed and opens it.

The release workflow and its tests now list seven add-ons.

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

* test(ui): run the Studio and Talk specs against the add-on bundles

The pages are add-ons now, so a spec that draws one installs the real
built bundle first: the apps list names it and its module and style
sheet are served from the build output, the way the add-on owner serves
them. studio-addon.js holds that helper, and the Studio and Talk
fixtures call it.

Update the specs for the new addresses (/app/addons/studio/studio, the
model as ?model=), the session permission the add-on route needs, the
legacy redirects, the Home /studio command that appears only while the
add-on is installed, and the sidebar without a Create group.

Add specs for the add-ons themselves: every tab draws with no script
error, the shell titles the page once, the old addresses say so when the
add-on is not installed, a button of the page reaches a core page with
the way back, and the drawer lists both only after they are installed.

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

* fix(ui): centre the icon of a built-in tile in its icon box

The drawer drew the icon of a built-in tile (Studio's palette was one)
in a bare 40px box. Only the placeholder box was laid out as a centred
grid with the 20px size, so the real icon sat in the top-left corner at
the size of the page text.

Give the built-in tile the same classes as the placeholder box, and
check in a browser that the icon is centred.

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

* docs(addons): describe Studio and Talk as add-ons

Replace the built-in list (now empty) with the Studio and Talk add-on
sections, the new targets of the old links, the Home command, and the
seven packaged add-ons. Describe the SDK additions the pages use:
components, hooks, icons, openApp and portal. Say on the Studio page
that it is installed from the drawer.

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

* test(ui): click the shell title in the Studio slash-key spec

The add-on shell titles the page, and the Studio overview no longer draws
a second title, but the generator tabs keep theirs, so a bare h1 locator
matched two elements. Click the first, which is the shell's.

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

* fix(addons): read the loopback port file through os.Root

The persisted port file was read with a path built from the add-on
root, which gosec reports as a file inclusion through a variable. Open
it through os.Root so the name cannot leave the root, even by a
symbolic link, and require a regular file with no group or other
access. A looser file now fails closed instead of being trusted.

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

* test(ui): expect Settings under the Operate hub, not the sidebar

Settings moved out of the sidebar into the Operate hub. The navigation
spec still expected a sidebar link. Assert that the sidebar has no
Settings entry, and that the Operate hub opens Settings as a tab.

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

* test(ui): drop Settings from the 404 places

Settings is a tab of the Operate hub and not a destination of its own,
so the 404 page does not list it. Assert that it is absent.

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

* test(ui): drive the media history and TTS specs through the Studio add-on

The image, video, sound and speech generators are pages of the Studio
add-on now, and the old addresses redirect to it. Install the add-on
with the Studio fixtures, open its pages and use its compose card, so
the specs run against the page that exists.

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

* test(ui): count the first-run steps, not every list item

The recommended models are a list inside the second step, so counting
all list items read 3 only while the models were still loading. Count
the step elements, which does not depend on the load order.

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

* test(ui): fail the node refresh by state, not by call count

The spec served the list to the first request and failed the rest. The
first request is not always the one from the page, so the page could
get the failure first and show no list. Serve the list until the page
shows it, then fail the refreshes.

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

* feat(addons): default to local-owner when auth is off

A server without authentication has no accounts, so named-user left
the add-on owner off. Resolve the unset mode in one place: local-owner
when authentication is off, named-user when it is on. A flag or
environment value still wins, and an unknown value is still refused.

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

* feat(addons): register an official add-on library by default

Add gallery/addons/index.yaml, a YAML index of the four first-party
add-ons (fine-tuning, quantization, voice, face) in the library format.
The entries come from addonpkg catalog over locally built linux/amd64
packages, so the digests pin those images.

On the first start, when the owner has no sources.json, register the
raw GitHub URL of that index on master as the "official" library and
write the file. The file is then the record: a library the admin
removes stays removed, and an existing file is never changed.
--addons-official-library=false skips the default.

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

* docs(addons): document the ownership default and the official library

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

* feat(addons): list Studio and Talk in the first-party index

Add the Studio and Talk entries to gallery/addons/index.yaml next to
the four existing ones. They come from addonpkg catalog over locally
built linux/amd64 packages, so the digests pin those images.

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

* test(addons): expect Studio and Talk in the official index

The official index now lists six add-ons. Update the test that
decodes it so it accepts Studio and Talk.

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

* feat(addons): add a real icon to each first-party add-on

Each icon is a 256 by 256 PNG: a rounded square in one colour of the
UI kit palette with a white outline glyph from the kit sprite. Fine-tuning
uses sliders, quantization layers, voice waveform, face user, studio
palette, talk phone and tables database. They were rendered with headless
Chrome from the sprite paths.

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

* feat(addons): ship a committed icon.png instead of the generated square

The shared UI builder and the Tables builder copy ui/icon.png from the
add-on source tree to the package unchanged when the file exists. They
write the generated placeholder only when it does not. Add a fixture with
an icon and a test for both cases.

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

* feat(addons): list the tables add-on in the first-party index

Add the Tables entry to gallery/addons/index.yaml next to the six
existing ones. It comes from addonpkg catalog over a locally built
linux/amd64 package, so the digest pins that image. The official index
test now expects seven add-ons.

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

* feat(addons): make the Voice add-on one page with tabs

The Voice add-on had four pages: recognition, library, create and
speech, and the drawer listed two of them. It now has one page, "voice",
and one drawer entry. Recognition, Library, Text to speech and From a
recording are tabs of that page, drawn with the same tab strip the
recognition page already used. The tab is the first path segment
(voice/library), as in the Studio add-on, and the form that adds a voice
is a view of the Library tab (voice/library/new).

The old page names stay declared in the manifest. Each one redirects to
its tab and keeps the query string, so saved links still open.

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

* feat(ui): send the old voice routes to the Voice tabs

/app/voice, /app/voice-library and /app/voice-library/new now open the
matching tab of the Voice add-on page while the add-on is installed and
running, and the core pages otherwise. The moved-page helper compares
the first segment of the target with the declared pages, so a target
can name a tab. The docs and the navigation spec list the new addresses.

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

* test(addons): cover the Voice tabs and the old page redirects

The add-on unit tests render the tabs instead of the four pages. The
Playwright spec checks the tab strip, the address of each tab, the
missing Library tab for a member, and that the old page addresses of the
add-on redirect to their tab with the query string kept. The migration
table test follows the new targets.

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

* fix(studio): keep the model menu inside a narrow screen

The Home model picker sizes its menu from the viewport, and assumes the
picker sits at the page gutter. In the Studio composer card the field is
further in, so on a 390px screen the menu of the Sound expansion picker
ended 7px past the right edge. Size the menu from the field it opens
under instead.

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

* test(ui): build the add-on bundles once for the whole run

Each worker built the Studio and Talk bundles into the same dist
directory, and the build empties that directory first. A worker that
started, or restarted after a failure, removed the files another worker
was serving, so a Studio spec saw "the style sheet of the add-on could
not be loaded". Build once in a global setup before the workers start.
A run without the setup still builds per worker.

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

* test(ui): register the worker when the add-node spec says so

The mocked roster added the worker on the third read of /api/nodes. The
shell and the page both read it, and which one asks first depends on
load, so the page could take the new worker as part of the roster it
opened with and never show it as found. The worker now joins after the
page shows it is listening.

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

* test(ui): hold the gallery answer until the skeleton is seen

The spec delayed the answer by a fixed 800 ms and then looked for the
skeleton. The assertion polls at growing intervals, so on a slow machine
it could step over that window. The answer now waits until the skeleton
has been seen, and the spec then checks that the skeleton goes away.

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

* test(ui): give the PDF tag the time the other PDF specs have

pdf.js and its worker load on first use. The other specs in this file
wait up to ten seconds for the result of that load. The first one waited
five, and missed the tag on a busy machine.

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

* test(ui): build the Face and Fine-tuning bundles in the global setup

The navigation spec serves the built Fine-tuning bundle, but nothing
built it, so two specs failed on a fresh checkout when the dist folder
was missing. The spec also built the Face bundle in a hook that every
worker runs, and a build rewrites the folder the other workers serve.

The global setup now builds Studio, Talk, Face and Fine-tuning once,
before the workers start. The spec builds the two bundles itself only
when it runs without the global setup.

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

* test(ui): fix the Home answers in the stale pin spec

The spec asserts that the page shows no alert. The Home page raises its
own alert when the model list fails or a staging operation has failed,
and both answers come from the shared test server. A slow reply or a
failed install left there counted as an alert about the pin.

The spec now fixes both answers, so the alert count tells only about the
pin. The assertions are unchanged.

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

* test(ui): read the server address in the add-node spec from the page

The spec expected the register address to carry port 8089, so it failed
on any other port. It now expects the origin the page was opened from.

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

* test(addons): give the navigation routes spec its own catalog

No page is built in today, so the catalog is empty and the three specs
that expect the studio and talk entries failed on the list and on the
pin seed. They passed only while Studio and Talk were built in.

The spec now sets a catalog of two ungated entries for each example and
restores the old one afterwards. The assertions are unchanged.

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

* fix(cli): report a failed write of the MLX hostfile

The tunnel callback ignored the error of os.WriteFile and set the
hostfile variable even when the file was not written. It now logs the
error and leaves the variable alone.

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

* test(addons): discard the close error of the held listener

errcheck flags the deferred Close of the listener that keeps the port
busy. The test has no use for that error, so the defer drops it
explicitly.

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

* test(addons): move the add-on tool tests to Ginkgo

The fine-tuning, quantization and add-on host library tests used
t.Errorf, t.Fatal and t.Run, which the forbidigo rule rejects. They now
use Describe, It and Gomega, with a suite bootstrap in each package. The
cases and the assertions are the same.

The tables in the host client and the tool schema tests keep one entry
per case.

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

* test(addons): move the host operation tests to Ginkgo

The hostops tests used the testing package calls that forbidigo rejects.
They now run as Ginkgo specs under one suite bootstrap. Every check and
every per-operation case is kept.

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

* test(addons): move the pin store tests to Ginkgo

The pin service, file store, sweep and shared store contract tests used
the testing package calls that forbidigo rejects. The store contract is
now a function that registers its specs for each store, so the file
store, the memory-only store and the database store run the same cases.

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

* test(addons): move the remaining add-on tests to Ginkgo

The built-in catalog, tool arguments, loopback carrier, host operation
contract, packaging, application, config, CLI and endpoint tests used
the testing package calls that forbidigo rejects. They now run as Ginkgo
specs. The endpoint tests also drop the unchecked Close errors that
errcheck reports when the auth tag is on.

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

* test(ui): wait for the targeted Reinstall button before the install click

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then clicks Reinstall with
a script click. The click can run before the targeted page has rendered
or while the button is still disabled, so no install request is sent and
the poll on the install count times out. It failed 1 run in 10 with two
workers and on every retry in CI.

The spec now waits for the button to be enabled first. The assertions
are unchanged.

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

* chore(addons): refresh the voice package digest in the first-party index

The Voice add-on became one page with tabs and got a new icon, so the
digest in gallery/addons/index.yaml no longer matched the build. Render
the entry again with addonpkg catalog over a locally built linux/amd64
package; the main page is now voice. The other entries are unchanged.

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

* test(ui): wait for the target node's Reinstall button after the popstate

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then waits for a button
named Reinstall and clicks it. Until React renders the targeted page,
the page still draws the generic Reinstall button. It is enabled and has
the same name, so the wait passed on it, the click started a global
install, and no node install request was sent. The poll on the install
count timed out with the confirm dialog still open.

With the CPU throttled to 8x, the spec failed on 20 of 20 runs, and the
button it clicked had the title "Reinstall". The spec now waits for the
button titled "Reinstall on <node>". With the throttle it passes 30 of
30 runs. The assertions are unchanged.

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

* test(ui): give the Studio hand-off spec a budget for the cold bundle load

The "a result made through a hand-off records which result it came from"
spec opens the Studio add-on straight from an address, twice. Each is a
cold load: the page shows "Loading" until the add-on module has run, and
the next assertion had the default five seconds. The load takes about
two seconds on an idle run and seven to ten under CPU load, so the spec
failed on a loaded machine with the hand-off note not yet drawn.

With the CPU throttled to 6x and eight busy loops running, it failed on
21 of 30 runs. The first assertion after each load now waits up to 20
seconds, and the spec then passed 30 of 30 under the same load. The
assertions are unchanged.

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

* fix(addons): carry the identity page styles in the Face add-on

The Face page is drawn with the shared identity views, but its style sheet
held only the clip and error rules. The page, card, model list, known-faces
table, enrol sheet and distance scale classes had no rules. They looked
right only when the host had already loaded the style sheet of its own
Faces page, which a visit through the add-on drawer does not do. Opened
that way, the cards had no gap, the model list showed bullets, and the
install buttons ran into the names.

The Face sheet now holds the same identity rules as the core sheet, so the
page looks the same whichever way it is opened.

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

* fix(addons): scope the narrow-screen identity rules of the Voice add-on

The rules for narrow screens (single-column pairs, smaller tool padding,
compact rank rows) were copied into the Voice style sheet without their
media query, so they applied at every width. They now sit in a
max-width 720px query, and the reduced-motion rule in its own query, as in
the core sheet.

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

* fix(addons): drop the repeated title from the Voice and Face pages

The add-on shell already titles the page, so the recognition pages showed the name twice: "Voice" above "Voices", "Face" above "Faces". Keep the supporting line and the model picker, as the Studio page does, and remove the unused title strings.

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

* feat(ui): replace the Pinned sidebar section with an Apps entry

The sidebar no longer lists pinned add-ons. It shows one Apps entry that
opens the add-ons drawer, with a Show all link below it that opens the
same drawer. Pins are used from the drawer, where a pinned tile keeps
its pin and move buttons. The server pins API is unchanged.

Remove the PinnedNav component and its locale title, rename the
items.addons label to Apps, and update the e2e specs and the add-ons
documentation to match.

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

* test(addons): assert the single shell title on the Voice and Face pages

The add-on shell now shows the one title, "Voice" or "Face", and the page no longer repeats it. The tests still looked for the h1 "Voices" and "Faces". Assert that the add-on page has exactly one h1 and that it is the shell title. The core pages keep their own "Voices" and "Faces" titles.

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

* fix(addons): drop the repeated title from the Voice Library and Text to speech pages

The add-on shell already titles the page "Voice", so these two tabs showed a second heading below it ("Speech voices", "Speak text"). Keep the supporting line, as the Studio and recognition pages do, and remove the unused title strings. The e2e spec now checks that each tab has exactly one h1, the shell title.

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

* feat(ui): list pinned apps under Apps in the sidebar

A pinned app now shows as an indented child of the Apps entry, with its icon and title, in pin order. Show all stays the last child. The pins come from GET /api/addons/pins through the navigation context, and the apps from the add-on registry, which the drawer feeds on every list read, so the links follow the drawer. A pin without an installed app is not drawn, nothing extra shows when no app is pinned, and the children are hidden when the sidebar is collapsed to the rail.

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

* docs(addons): say that pinned apps are listed under Apps in the sidebar

The sidebar lists the pinned apps between Apps and Show all, in pin order. The pins section and the navigation section said that pins do not appear in the sidebar.

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

* fix(ui): remove the Show all link under Apps in the sidebar

The link repeated the Apps entry, which opens the same drawer, and looked redundant when no app was pinned. Apps is now the only add-on entry; pinned apps still list under it in pin order. The link, its locale key and the style comment are gone, and the e2e spec and the add-ons docs now say that Apps is the only entry.

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

* feat(ui): show a first-run card on Apps when nothing is installed

An administrator who opens Apps with no add-on installed now sees a
short card with three steps: choose a library, browse and install,
start the add-on. Its button opens the Browse tab. The card is hidden
once an add-on is installed and for anyone who cannot install.

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

* fix(ui): hide the pinned apps in the icon rail

When the window is narrower than 1024 px the sidebar shrinks to the icon rail by CSS alone, without the collapsed class. The pinned app links were still drawn there, with the indent meant for the full sidebar, so their icons sat to the right of the rail and were cut off. Hide the pinned app links in both rail states. The Apps entry keeps its centred icon. The e2e spec checks that the rail shows no pinned app and that every icon is centred at 1280 px collapsed and at 1000 px.

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

* test(ui): stop the backend redirect test racing the installed list

The test reads the redirected URL right after the path changes. The
Backends page then loads the installed list and drops a selected backend
that is not installed, so the backend parameter was sometimes already gone
when the test read it. The mock server has no llama-cpp, and about 15 of
40 repeated runs failed on the backend assertion. The redirect itself was
correct, including show_all.

Serve the selected backend in the installed list, so the page keeps the
selection and the assertions stay as strict as before.

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

* fix(addons): accept a same-host Referer or Origin for human confirmation

Browsers send no Sec-Fetch-* headers over plain HTTP on an address that
is not localhost, so creating a resource from a LAN address failed with
"Creating a resource requires your own browser session".

When Sec-Fetch-Site is present it must still be same-origin with an
empty destination, as before. When it is absent, Origin or, failing
that, Referer must name the request host and port. A destination or
mode that is present must still be the expected one. This matches the
rule in BrowserProtection. The credential, token cookie, API key and
tool-context checks are unchanged.

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

* fix(ui): keep hub entries off the Apps route in the sidebar

The Build hub still owned /app/addons through its legacy Add-ons tab,
so the sidebar lit both Apps and Build on the Apps page. A tab whose
gate is closed now owns no route when the sidebar asks, so only the
Apps entry is active there. The hubs keep their own routes, and the
legacy navigation still gives /app/addons to the Build hub.

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

* feat(ui): label the pin control on app tiles and in the detail view

The pin was a small icon-only button and hard to find. Each app tile now
shows a button reading Pin, or Pinned with a filled pin, and the detail
view has the same control in its header. Both toggle the same stored pin.

The Apps grid lists pinned apps first, in pin order, then the others.
The sidebar list and the server pins API are unchanged.

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

* test(addons): check the Voice From a recording tab has one title

The Library and Text to speech tabs already assert that the add-on shell
title is the only h1. Do the same for the From a recording tab, so a
heading added there later fails the navigation spec.

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

* fix(ui): stop the add-on shell drawing a title over add-on pages

The app decides what its page shows. The shell drew the add-on name as a
header above every add-on page, so an add-on could not choose its own
title. The shell now draws no header on a live, loading or frozen
add-on page; each add-on titles its own pages. The unavailable and
missing-page states keep the name, because no add-on page is drawn there.

The runtime and shell specs now check that the shell adds no page title.

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

* fix(addons): let each add-on draw its own page title

The shell no longer titles add-on pages, so each add-on draws one h1 per
page. Voice titles its tabs "Voices" (Recognition and From a recording),
"Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech
voice" already had its own. Face draws "Faces", Studio "Studio" and Talk
"Talk". Tables promotes the database list and database name from h2 to h1.
Fine-tuning and Quantization already drew a title.

The add-on unit tests and the navigation and Studio specs now assert
exactly one h1 per page, with the page's own title.

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

* feat(addons): add a kind to library entries and read unknown kinds tolerantly

A library entry now has an optional kind: addon (the default when it is
absent), backend or model. Every index written before kinds existed stays
valid and its entries are add-ons.

A reader keeps the entries of the kinds it knows and skips the others
without reading their fields, so a newer kind never makes an older host
refuse the whole index. The refresh result and the library state report
how many entries were skipped and of which kinds, and the refresh logs a
warning. A kind that is not a plain kind name still makes the index
malformed. Search lists add-ons unless the caller asks for a kind, and
Resolve returns add-ons only, so an add-on install cannot be pointed at
a backend image.

A library policy may name the signer with an anchored identityRegex
instead of an exact identity, so builds from master and from tags both
verify. An add-on install refuses a policy that carries one.

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

* feat(backends): install a backend from a library entry

A library can now list backends. An entry of kind backend names the
image of the backend gallery by digest, and POST /backends/library/install
installs it on the local node. The gallery stays the source of truth and
its install path is the one used: the library resolves the entry, takes
the signature policy from the library, pins the image by digest and calls
the same install as the gallery. Installing a name that came from a
gallery, another library or by hand is refused and changes nothing.

The signature policy of a backend library names the signer with an
anchored identityRegex, so images signed by the merge workflow on master
and on tags both verify. A pattern that is not anchored is refused.

An install is bounded by a size limit per kind: add-ons keep 512 MiB and
a backend image defaults to 8 GiB, set with --addons-backend-max-bytes.
The limit reads the manifest after the signature check and before any
layer is fetched. The metadata of an installed backend records the
library it came from.

scripts/backends/cmd/backendcatalog renders entries from
backend/index.yaml and pins them against the registry. The pilot is the
CPU build of local-store, in gallery/backends/index.yaml. The library is
registered on the first start only with --addons-backend-library, which
is off by default.

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

* feat(ui): show the kind of library entries and install backends from them

The Browse tab lists the entries of every kind this host reads and marks
each one as an add-on or a backend. A backend installs through the
backend route and not through the add-on lifecycle: the confirmation
says that the backend gallery is not changed and that a backend runs
native code, and a refusal of the host, such as a name installed from
another origin, is shown with its own reason. The details give the kind,
the alias and the signer pattern.

The library editor can name the signer with a pattern. Without this a
save would drop the pattern of a backend library. The Libraries tab
reports the entries a refresh skipped because their kind is not read.

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

* docs(addons): describe backends in the library

Document the entry kinds, how a reader skips a kind it does not know,
the backend entry format, the signer identity pattern, the install
route, the size limit of each kind, the pilot library and the command
that renders it. The backend page links to it.

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

* feat(mcp): install a backend from a library entry

The backend library route installs a backend from an add-on library
entry, and the admin REST surface and the MCP catalog must stay in step.
Add the install_library_backend tool with an in-process and an HTTP
client, the route map entry, the safety-rule listing and a skill recipe.

Listing library entries stays on the add-on routes, which the assistant
does not expose, so no list tool is added.

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

* fix(ui): carry the add-on library keys in every locale

The add-on library and backend library strings existed only in English,
so the other locales had no value for them. Add the missing keys of the
addons namespace to de, es, id, it, ko, pt-BR and zh-CN. The values are
the English text until a translation is contributed.

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

* feat(backends): report the steps of a library install as a job

A backend install from a library ran inside the request and showed
nothing until it ended. It is now a job of the gallery service, the
kind the backend gallery installs use. The request answers with the job
ID and GET /backends/jobs/{id} reports the step as the phase of the job:
resolving, verifying, size, fetching and writing, with the byte progress
of the fetch. A job that fails keeps the phase of the step that failed
and gives the reason. A second install of the same name is refused while
one runs.

The downloader and the gallery install report their stages through an
optional hook, so the steps are the real ones and not a guess. The
Browse tab lists the steps of the entry being installed, marks the one
that failed, and omits the signature step for a library that does not
require one.

The library installer also gets Updates and a route that lists the
installed library backends with a newer build in their library, for the
next change.

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

* feat(ui): mark library backends and offer their updates on the Backends page

A backend installed from a library now shows the library ID next to its
name and, in the detail, the library and the digest that metadata.json
pinned. Gallery backends look and behave as before, and the gallery
reinstall is not offered for a library backend, because the gallery does
not list it.

When the cached index of the library lists another build in the channel
of the installed version, the row says "Update available" and offers an
Update button. The button starts the same install as the Browse tab, and
the detail shows its steps. Nothing is upgraded on its own. The check
says nothing when the library no longer lists the installed version and
has several channels, instead of guessing one.

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

* fix(mcp): match the library installer to the progress type

The MCP install of a library backend was written against the earlier
Install signature. The job-based install now takes a Progress value,
so the application did not build. The MCP interface drops its unused
status callback and the application adapts the installer to it.

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

* feat(gallery): add the model library entry shape and its reader

A model library entry is the shared library envelope (kind, id, name,
version, description, license, urls, tags, icon, channel, policy,
schemaVersion) plus a model body: url, backend, size, files,
configFile, overrides, variants and promptTemplates. The envelope lives
in the catalog package so the other kinds can move to it.

A gallery entry converts to a library entry and back to the same
gallery entry. Floats are written so they read back as floats, and an
empty variants list stays distinct from an absent one, because both
occur in the real index.

The reader is strict: unknown fields, duplicate ids, a second document
and a flow-style list are refused. An entry of another kind is skipped
and reported, as in the add-on reader. The gallery index stays the
default and is not changed.

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

* feat(gallery): render the model library from the model gallery

scripts/models/cmd/modelcatalog reads gallery/index.yaml with the same
structs the host reads it with, converts every entry to a library entry
and back, and fails if any entry does not come back equal. It writes
gallery/models/index.yaml and the golden files for the tests. The
output is deterministic, and the gallery is only read.

The golden files are a digest pair for every entry (the gallery entry
and the library entry made from it) and a readable gallery and library
pair for each kind of entry: plain, with variants, with an empty
variants list, with a config file, with a size, with a backend, with
floats in the overrides, without a url, without files and with a
multiline description.

All 2212 entries of the index round trip. The gallery struct does not
read a few keys (last_checked, parameters and four stray capitalised
keys); the host drops them when it loads the index, so the library does
not carry them, and the command reports them.

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

* feat(gallery): add --model-index-source to read the model library

--model-index-source selects the file the host reads the models of the
official gallery from: gallery (the default) or library. With library
the gallery named localai reads the model library, from
--model-library-url or the file in the repository, and keeps its
configured name and URL on every entry. A gallery the owner added keeps
its own index, and add-on and backend galleries never take this path. A
library that cannot be fetched or read is an error and not a fall back
to the gallery, so the setting is never silent.

The tests round trip every entry of the real gallery index, compare it
with the golden files, check the rendered library file reads and agrees
with itself, and cover the strict reader, the kinds it skips, prompt
templates, number types, empty lists and the setting.

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

* feat(backends): upgrade library backends with the auto-upgrade switch

The upgrade checker now installs the newer build of a library backend
when auto_upgrade_backends is on, on the same check that upgrades the
gallery backends. With the switch off nothing is installed and the
Update button stays manual. The switch is read on every check, so a
change in the runtime settings applies at the next one. There is no
second toggle.

The tests cover both states and a host without a library.

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

* feat(cli): add local-ai library to manage library sources

local-ai library has list, add, remove, enable and disable. It works on
the source store the server reads (addons/sources.json under the data
path), so it also works with the server stopped. A source has a kind
(addon, backend or model); the store records whether it is a shipped
default, and removing a default leaves a tombstone so it does not come
back at the next start.

The shipped model and backend gallery indexes are default sources next
to the official add-on library. Defaults are added at every start when
they have no record yet, and an existing file that holds no default is
left alone as before.

The --model-index-source and --model-library-url flags are removed: a
model library is added as a model source, and the gallery entry says it
is a library.

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

* feat(addons): let a source of your own win an entry over a default

When two enabled sources list the same entry (same kind and id), the
source the user added wins over a shipped default, and the default is
ignored for that entry. When two sources the user added list it, search
and resolve are refused with an error that names the entry and both
sources, and nothing is picked silently. Among shipped defaults nothing
changes.

The tests cover a user source over a default, a tie between two user
sources, a default ignored once a user source wins, and the case where
disabling a source ends the conflict.

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

* feat(cli): map the gallery flags to library sources at startup

--galleries and --backend-galleries keep loading the same JSON. At
startup each entry they name also becomes a source the user owns, of
kind model or backend, so local-ai library list shows it. The mapping is
additive: an entry whose kind and URL is already a source (a shipped
default, one added with the CLI, or one mapped at an earlier start) is
not added twice, and a derived id never collides with another source.

The sources then decide which galleries the server reads: a gallery
whose source is disabled or removed is left out, and a model library or
gallery added with local-ai library add is added. A list set by the flags
still replaces the shipped defaults. If the store cannot be opened, the
galleries stay as the flags set them. The autoload switches are not
touched.

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

* refactor(library): use id for every kind and seed the default libraries

The backend entries of the library used addonId while the model
entries use id. Release now has the field ID, written as id for every
kind. ReadIndex reads addonId as a legacy alias for one release, for
add-ons and backends, and refuses an entry that holds both names. The
backend index and the catalog generator write id.

The shipped libraries are default sources. The pilot backend library is
now on by default, like the official add-on library, and the model
library is a new default model source, official-models. The server
reads it next to the localai gallery. A source the user removed stays
removed, and a source the user adds is added to the defaults.

The model catalog header held a backtick that ended its raw string, so
the generator did not build. The header no longer has it.

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

* feat(library): choose the default model source by build

A build read both the localai gallery and the official-models library
by default, so every model was listed twice. Now a build reads one of
them. library.ModelSource names it: "library" or "gallery". The model
source that is not chosen is still shipped, as a disabled default, and
local-ai library enable turns it on.

The value is a Go variable set at link time with -ldflags, because the
same source tree makes both kinds of build and a constant would need a
commit to change. It is "library" in the source, so a build from
master, go build and go test read the library. Only the exact value
"gallery" selects the gallery, so a mistyped value cannot leave a build
without a model source.

The Makefile sets it from LOCALAI_MODEL_SOURCE. When that is not set, a
commit that carries a tag gets "gallery" and any other commit gets
"library". .goreleaser.yaml passes the same variable and defaults to
"gallery". The release workflow sets it to "library" on a tag, which is
the cutover.

A default source is seeded once, so a data directory that an earlier
build seeded keeps its enabled flags. Switch it with local-ai library
enable and disable.

Tests assert the master default, the tagged default before the cutover
and after it, and that a mistyped value reads the library. The model
library header now says that CI checks it and when a build reads it.

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

* ci(library): check the model library and cut over on tag

No workflow checked gallery/models/index.yaml, so it could go stale
when gallery/index.yaml changed. A new workflow, model-library.yml, runs
on pull requests and on master when the gallery, the library, the
generator or core/gallery change. It runs the generator tests and the
library shape tests (the file decodes, it has the library shape, every
real entry round trips), then renders the library again from
gallery/index.yaml and fails on any difference with git diff
--exit-code. The error message gives the command that fixes it.

release.yaml gets the cutover. On a tag, a step sets
LOCALAI_MODEL_SOURCE=library before GoReleaser runs, and
.goreleaser.yaml links it into the binary. Without that step a tagged
build reads the gallery. The step is the one place to remove when the
gallery is retired.

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

* feat(library): make the model library the source of the gallery

The library, gallery/models/index.yaml, was rendered from the gallery.
Now it is the source and models are edited there. modelcatalog reads
the library and writes gallery/index.yaml, a generated copy that stays
only so releases before the cutover keep working.

The gallery file is written in the shape the host reads: the same
field order, floats such as 1.0 keep their form, and an empty variants
list stays apart from an absent one. The command converts every
library entry to a gallery entry and back, loads its own output the way
the host does, and fails when an entry comes back different.

The gallery copy drops what the library cannot carry: last_checked,
parameters and four capitalised keys (Base Model, Context Length,
License, Training Data). The gallery struct has no field for any of
them, so the host ignored them on load. Every run lists these keys and
how often the file being replaced still had each. Loading the old and
the new gallery with the host structs gives equal entries for all
2212 models.

The tests that read the library and the golden files follow in the
next commit.

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

* ci(library): check the library and that the gallery is its copy

The model-library workflow assumed the gallery was the source. It now
checks the library file, the new source. The library decodes, every
entry has the library shape, every entry converts to a gallery entry
and back, and the gallery reader loads the generated gallery and sees
the same names, file uris and digests. The workflow then generates
gallery/index.yaml again and fails on any diff with git diff
--exit-code. The message says the file is generated, not to edit it by
hand, and gives the command that regenerates it.

The tests in core/gallery read the library file now. The round trip
runs library to gallery and back on all 2212 real entries, a new test
compares the checked-in gallery with the generated copy so go test finds
a hand edit too, and the golden digests and samples are written from
the library. The version of an entry is no longer compared with a digest
of its content, since an edit to the library does not change it.

The docs say the library is the source, the gallery file is a generated
copy for releases before the cutover, and edits go to the library. The
agent guide for adding models says the same.

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

* feat(library): add admin routes for library sources and an official libraries switch

The library sources of the add-on, backend and model kinds had a CLI
but no HTTP routes. Add them for administrators, on the same store and
the same functions as `local-ai library`, so the two cannot drift:

  GET    /api/library/sources
  POST   /api/library/sources
  POST   /api/library/sources/{id}/enable
  POST   /api/library/sources/{id}/disable
  DELETE /api/library/sources/{id}

The list carries kind, default, removed (a tombstone), suspended and the
conflict message of a source that clashes with another one. A change to
a model or backend source applies to the running server. A deleted
source drops its gallery by address; a removed default keeps its record.

Add the runtime setting official_libraries (default on), with the flag
--official-libraries. When it is off, every shipped default source of
the three kinds is suspended: it is not read, stays in the store marked
default, and comes back when the setting is on again. Sources the user
added are not affected.

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

* feat(ui): list library sources of all three kinds in the Libraries tab

The Libraries tab of the Add-ons page now lists the sources of the
add-on, backend and model kinds from the new library routes. It has a
kind filter with counts, a Default badge, the state of each source
(enabled, disabled, removed, or off while the official libraries are
off), turn on and off, a Restore button for a removed default, and the
conflict message next to a source that clashes. Adding a source takes a
kind and, for backends and models, an index format.

Settings gets the toggle "Use official libraries" for the
official_libraries setting.

Add unit tests for the list and the kind filter, and a Playwright spec
that runs on a fresh test server started with --library-sources (its own
config, port 8097): it lists the three kinds, adds a model source,
disables and removes a default, checks the conflict message and the
official libraries toggle.

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

* feat(library): extend the backend body to carry what the gallery carries

The backend library entry only held an image and its digest. The backend
gallery holds more that matters at install and in the UI, so the library
could not replace it. Add the missing fields to the backend kind and keep
the strict reader.

New fields, all refused on an add-on entry:

- mirrors: ordered fallback images of the same backend, each oci://.
  The install pins them with the digest of the artifact.
- capabilities: a map from a host capability to a backend id. An entry
  with capabilities and no artifact is a meta backend. It has no digest,
  os or arch, is listed for every platform, and installing it installs the
  backend its capability names, in the same library and channel.
- license, urls, tags: display metadata, bounded and checked as text.
- icon: an https URL. The add-on ui.icon stays a path in the package. The
  host never fetches the icon.
- mutable: the artifact is a moving tag, such as a development image. The
  entry has no digest. The install resolves the tag to a digest, verifies
  that image and records the digest it installed.
- schemaVersion: the envelope version. A higher one is refused.

The index bounds grow to 8 MiB and 4000 entries, because the backend
library lists every image for every platform. Release now holds lists, so
the add-on staging compares it with Equal instead of ==.

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

* feat(library): render the backend library from the whole backend gallery

Add the conversion between a gallery backend and its library entries in
core/gallery, and make scripts/backends/cmd/backendcatalog build the
library from the gallery and the gallery from the library.

The library now holds all 1093 gallery entries as 1088 backends, because
five names appear twice in the gallery with the same content and are
folded. That is 934 concrete backends and 154 meta backends, written as
1240 entries: a backend built for several platforms has one entry for
each, with the digest of that platform image.

- 539 release entries are pinned by the digest the registry serves for the
  platform.
- 543 development entries (master-* tags) keep their tag and are mutable.
  The install pins them.
- 4 release entries are not pinned, because the registry has no image for
  them: cpu-qdrant-store, metal-qdrant-store, cpu-gemxcpp and
  vulkan-gemxcpp. They are marked mutable and no digest is invented.
  Render them again with --repin once the images exist.

The generator can import a gallery once, pin the release images again, and
write the gallery file from the library without the network. Before it
writes it converts every backend to the library and back and fails if one
does not come back equal. A policy is no longer written on each entry,
because the host takes the policy of the library source.

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

* test(library): check that every backend gallery entry round trips

Prove that the backend library can replace the backend gallery. The golden
input is the real backend gallery from before the library became the
source: 1093 entries, kept under core/gallery/testdata/backend_library
with the pins the library holds and the digest of each generated entry.

TestBackendLibraryRoundTripsEveryRealGalleryEntry converts each entry to
library entries, writes them as an index, reads them as a host does,
validates them and converts them back. It asserts that all 1093 entries
come back equal on every field the gallery reader uses, and logs the
count. The five names that appear twice in the gallery have the same
content, so the twin is checked against the first one and folded.

The other tests check the library file in the repository: every entry
passes the host checks, every backend round trips, every meta backend
names concrete backends the library has, a development entry keeps its
master-* tag and has no digest, and the golden digests still match. Unit
tests cover each field, the fields the library cannot carry, which are
refused rather than dropped, and entries of one backend that disagree.

An entry with no artifact, digest, os or arch no longer writes them as
empty strings, so a meta backend and a mutable entry stay short.

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

* feat(library): make the backend library the source of the backend gallery

Flip the source the way the model library did. The backend library,
gallery/backends/index.yaml, is where backends are edited. The backend
gallery, backend/index.yaml, is now generated from it by
scripts/backends/cmd/backendcatalog and stays only so releases before the
cutover keep working. It no longer has anchors or hand-written order, and
it holds 1088 backends: the five names that were listed twice with the same
content are folded.

Which source a build reads is set at build time, like the model source:

- core/library.BackendSource is "library" or "gallery". Only the exact value
  "gallery" selects the gallery, so a mistyped value cannot leave a build
  with no backend source.
- A build from master reads the library. The Makefile reads the gallery for
  a commit that carries a tag (LOCALAI_BACKEND_SOURCE), and GoReleaser
  defaults to the gallery.
- A step in release.yaml sets LOCALAI_BACKEND_SOURCE=library, so a tagged
  build cuts over by itself.
- Both sources are shipped and one is enabled. With
  --addons-backend-library=false every build reads the gallery.

The server hands the shipped backend library to the gallery reader as a
library, so the Backends page lists it and installs from it the way it did
from the gallery. A backend library the user adds stays with the add-on
catalog. The shipped library is now a backend source, not an add-on one.

The new workflow backend-library.yml runs the tests, regenerates
backend/index.yaml and fails on a diff. It never reads the registry.
backendcatalog --add merges backends written in the gallery shape into the
library, so adding a backend does not mean writing platform entries by
hand; the agent guide says so.

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

* docs(library): describe the backend library as the source of the backends

Rewrite the backends and add-ons pages for the backend library. The library
file is the source of truth and the backend gallery is a generated copy that
is never edited by hand.

- backends.md: the library and the generated gallery, which source a build
  reads (master, tagged before the cutover, tagged after it), the
  --addons-backend-library switch, how to add a backend and generate the
  gallery, and what CI checks.
- addons.md: the full backend entry with the new fields (mirrors,
  capabilities, license, urls, tags, icon URL, mutable, schemaVersion),
  meta backends, development images pinned at install, and the backend
  library section in place of the pilot one.
- compatibility-table.md: point to the library as the source.

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

* feat(library): mirrors on library sources, editable through the API

Library sources carry an optional list of mirrors. The mirrors go into the
gallery entries the server reads, and a source can be edited with
PUT /api/library/sources/:id. The mirror forms accepted are http(s),
github:, oci:// and file://, at most eight per source.

Sources saved before this change load without mirrors.

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

* feat(ui): library chips on the model and backend pages

The model and backend pages manage their own library sources. Filter
chips above the list narrow it to one library, and a panel adds, edits,
turns on or off, refreshes and removes sources of that kind. The add-on
Libraries tab lists add-on sources only.

Settings shows the model and backend galleries as a read-only view of
the library sources, with their mirrors. Mirrors are edited in the panel.

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

* feat(ui): show the library chips when a kind has one library

The chips were hidden until a page had two libraries, so a single library
had no visible filter. The row now shows whenever a page has a library.

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

* docs(gallery): state the mirror rule and the cutover for the old index

The library no longer has no mirrors: a source can list them, so the note
now says that the shipped official-models library has none. The mirror
section says that clearing a source's mirrors waits for a restart. The
cutover section says the old gallery file is removed in the cutover
release.

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

* fix(gosec): tighten the modes the catalog generators write

The generators wrote generated files and sample folders with 0644 and
0755. The generated index copies are tracked by git, which keeps only the
executable bit, so the tracked files do not change. Local copies get
0600 and 0750. The library path read in mergeLibrary is the operator's
own argument, so that read is annotated.

Assisted-by: Claude:claude-haiku-5-5 [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-10-23 01:00:07 +02:00
Plamen K. Kosseff 7b9ef6a0f8 feat(backends): --disable-backend-install disables backend downloads. (#12754)
feat(backends): --disable-backend-install refuses every backend acquisition

A hardening switch for deployments that must run exactly the backend set they
were tested with (production, air-gapped, immutable images, package-managed
hosts). With LOCALAI_DISABLE_BACKEND_INSTALL=1 every install and upgrade path
refuses — gallery, OCI/URL/path, node-scoped, auto-upgrade, boot-time external
backends — HTTP routes answer 403 up front, and a model whose backend is
missing is refused before any download: 409 on the API, an error on preload
and import. Whether a backend is present is asked of the execution target:
the local backend directory on a single node, the workers' listings on a
distributed frontend, which installs nothing itself. Backends already
present, including system-path ones, keep working.

Assisted-by: Claude Code:claude-fable-5

Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
2026-10-22 17:58:53 +02:00
mudler-agentandEttore Di Giacinto e45dc5e9f7 feat(addons): installable add-ons foundation and the Tables add-on (#12702)
* feat(addons): add the add-on runtime, navigation and first-party add-ons

Add the add-on runtime to LocalAI: manifests, the host operation broker,
admin gating by account role, the add-on UI bundle builder and the SDK
for add-on pages. Move the navigation to Chat, Models, Add-ons and
Settings, with pinned add-ons in the sidebar and an Add-ons drawer.

Ship the first-party add-ons (voice, face, fine-tuning, quantization) as
packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS
(darwin/amd64) is enabled in the platform files.

Squashed from the review branch. The tree is unchanged; the pre-squash
history is kept on a local backup branch.

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

* fix(gosec): tighten file modes and annotate false positives

Write job state, configs and imported models with mode 0600, clean
the paths that are read from disk, and mark the route tables, the
header name and the retry jitter as reviewed false positives.

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

* fix(addons): keep API keys off recovery mutations and same-site cookies

POST /api/addons/dispatch forwarded the recovery and update
mutations to the owner for any administrator key, which skipped the
browser-session rule of their own routes. Refuse those actions
there. Also apply the same-origin rule to the legacy token cookie,
not only the session cookie, because the global CSRF check lets
same-site requests through.

Do not fail the start of LocalAI when an add-on library cannot be
refreshed; log it and keep the installed add-ons up.

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

* fix(addons): refuse a reinstall that would migrate retained data unbacked

An upgrade takes a verified backup before the new package migrates
the data. A reinstall over retained data has no running generation
to stop, so the new package would migrate it without one. Refuse a
reinstall whose data format differs from the retained one.

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

* fix(addons): release the manager lock while a release is fetched

Submit held the state lock across the catalog lookup and the package
download, so the apps list and status reads stalled for the whole
install. Serialize Submit and Reconcile with their own lock and drop
the state lock around the fetch only.

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

* fix(addons): kill an add-on only after three missed health probes

One slow 100 ms probe killed a busy add-on and dropped the work it
was serving. Require three misses in a row.

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

* feat(ui): move settings under the operate hub

The Operate hub already has Settings as a tab, so the sidebar no longer shows a separate Settings entry. The /app/settings route stays, and the hub tab still reaches it. The navigation test now expects Chat, Models and Add-ons as the primary items.

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

* fix(addons): accept same-host browser requests that carry no fetch metadata

Browsers do not send Sec-Fetch-* headers over plain HTTP on an address
that is not localhost, so every add-on request from a LAN address was
refused as cross-site. Keep refusing a Sec-Fetch-Site value other than
same-origin. When the header is absent, accept the request only if its
Origin, or failing that its Referer, names the same host and port as
the request.

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

* fix(ui): show the server's reason when an add-on request is refused

A 403 or 400 answer showed a generic message and hid the reason in the
response body, so a refusal by the browser guard could not be told from
a missing permission. Show error.message from the body as the server
sent it, and keep the generic text when the body has none.

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

* feat(addons): run the add-on owner in the normal binary

The owner was only enabled when an embedding host set the add-on config,
so a normal run answered every add-on request as unavailable.

Add --addons-enabled (default true) and --addons-ownership-mode
(default named-user, or local-owner; anything else stops startup).
The node is its own owner: state lives under the data path, and the
carrier uses loopback keys made at each start and kept in memory.
--addons-enabled=false turns the owner off completely.

Named-user mode needs the account database. With authentication off
the owner stays off and a warning says how to use local-owner.

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

* fix(addons): keep the loopback owner address stable across restarts

The single-node loopback transport took a new ephemeral port at each
start. The owner stores its address in its identity file and refuses to
start when the address differs, so every start after the first failed
with "invalid addon owner configuration".

Choose the port once, keep it in the add-on state root (loopback-port,
mode 0600) and reuse it. If the port is taken at a later start, stop
with an error that names the port and --addons-enabled=false, because
another port would break the identity check. The keys and certificate
stay in memory.

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

* feat(addons): let add-on pages keep the host modules they were written against

A page written for the web UI imports React, the router, i18next and a
few shared components. The add-on builder refuses a bundle that holds a
host file, so moving such a page into an add-on meant rewriting it.

Add a compat mode to the shared builder. It answers those imports from a
directory of stand-ins that read the SDK, bundles the dynamic import of
the page code into the one output file, and collects the style sheets the
page imports. A relative import that is not in the add-on's own source
resolves to the file at the same path under the compat directory.

Widen the SDK for these pages, without a version change: more host
components (spinner, modal, lightbox, model picker) and hooks (models,
resources, voice profiles, operations, MCP client), a style on icons,
the icon names the pages draw, sdk.navigation.openApp to open a page of
the app (only addresses under /app, and the way back as the only state)
and sdk.portal. The shell passes the query string to the page.

The stand-ins cover the router (the tab in the path, the model in the
query), the translator over the host locale files, the core API calls
through sdk.core.fetch, and byte copies of the pure helpers, with tests
that keep the copies equal to the originals.

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

* feat(addons): ship Studio and Talk as add-ons instead of built-in pages

Studio and Talk were compiled into the web bundle and always listed in
the Add-ons drawer. They are add-ons now, like Voice, Face, fine-tuning
and quantization: a person installs and removes them, the drawer lists
them only while they are installed, and they are packaged the same way.

Move the pages, their components, hooks and helpers into addons/studio
and addons/talk. Each has a manifest, a UI bundle, a release platform
matrix and a catalog template, and is built by the compat mode of the
shared builder. The core API stays where it is: the pages call it with
the signed-in person's session. The history keeps its browser storage
keys. The unit tests of the moved helpers move with them.

In the core, drop the two pages, the Create group of the sidebar (Chat
joins Home and Models in the old layout), the built-in catalog entries
(the catalog is empty) and the talk locale namespace. The old addresses
(/app/studio, /app/talk and the generator links) redirect to the add-on
pages, with the model as ?model=. The Home /studio command is offered
only while the Studio add-on is installed and opens it.

The release workflow and its tests now list seven add-ons.

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

* test(ui): run the Studio and Talk specs against the add-on bundles

The pages are add-ons now, so a spec that draws one installs the real
built bundle first: the apps list names it and its module and style
sheet are served from the build output, the way the add-on owner serves
them. studio-addon.js holds that helper, and the Studio and Talk
fixtures call it.

Update the specs for the new addresses (/app/addons/studio/studio, the
model as ?model=), the session permission the add-on route needs, the
legacy redirects, the Home /studio command that appears only while the
add-on is installed, and the sidebar without a Create group.

Add specs for the add-ons themselves: every tab draws with no script
error, the shell titles the page once, the old addresses say so when the
add-on is not installed, a button of the page reaches a core page with
the way back, and the drawer lists both only after they are installed.

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

* fix(ui): centre the icon of a built-in tile in its icon box

The drawer drew the icon of a built-in tile (Studio's palette was one)
in a bare 40px box. Only the placeholder box was laid out as a centred
grid with the 20px size, so the real icon sat in the top-left corner at
the size of the page text.

Give the built-in tile the same classes as the placeholder box, and
check in a browser that the icon is centred.

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

* docs(addons): describe Studio and Talk as add-ons

Replace the built-in list (now empty) with the Studio and Talk add-on
sections, the new targets of the old links, the Home command, and the
seven packaged add-ons. Describe the SDK additions the pages use:
components, hooks, icons, openApp and portal. Say on the Studio page
that it is installed from the drawer.

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

* test(ui): click the shell title in the Studio slash-key spec

The add-on shell titles the page, and the Studio overview no longer draws
a second title, but the generator tabs keep theirs, so a bare h1 locator
matched two elements. Click the first, which is the shell's.

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

* fix(addons): read the loopback port file through os.Root

The persisted port file was read with a path built from the add-on
root, which gosec reports as a file inclusion through a variable. Open
it through os.Root so the name cannot leave the root, even by a
symbolic link, and require a regular file with no group or other
access. A looser file now fails closed instead of being trusted.

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

* test(ui): expect Settings under the Operate hub, not the sidebar

Settings moved out of the sidebar into the Operate hub. The navigation
spec still expected a sidebar link. Assert that the sidebar has no
Settings entry, and that the Operate hub opens Settings as a tab.

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

* test(ui): drop Settings from the 404 places

Settings is a tab of the Operate hub and not a destination of its own,
so the 404 page does not list it. Assert that it is absent.

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

* test(ui): drive the media history and TTS specs through the Studio add-on

The image, video, sound and speech generators are pages of the Studio
add-on now, and the old addresses redirect to it. Install the add-on
with the Studio fixtures, open its pages and use its compose card, so
the specs run against the page that exists.

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

* test(ui): count the first-run steps, not every list item

The recommended models are a list inside the second step, so counting
all list items read 3 only while the models were still loading. Count
the step elements, which does not depend on the load order.

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

* test(ui): fail the node refresh by state, not by call count

The spec served the list to the first request and failed the rest. The
first request is not always the one from the page, so the page could
get the failure first and show no list. Serve the list until the page
shows it, then fail the refreshes.

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

* feat(addons): default to local-owner when auth is off

A server without authentication has no accounts, so named-user left
the add-on owner off. Resolve the unset mode in one place: local-owner
when authentication is off, named-user when it is on. A flag or
environment value still wins, and an unknown value is still refused.

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

* feat(addons): register an official add-on library by default

Add gallery/addons/index.yaml, a YAML index of the four first-party
add-ons (fine-tuning, quantization, voice, face) in the library format.
The entries come from addonpkg catalog over locally built linux/amd64
packages, so the digests pin those images.

On the first start, when the owner has no sources.json, register the
raw GitHub URL of that index on master as the "official" library and
write the file. The file is then the record: a library the admin
removes stays removed, and an existing file is never changed.
--addons-official-library=false skips the default.

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

* docs(addons): document the ownership default and the official library

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

* feat(addons): list Studio and Talk in the first-party index

Add the Studio and Talk entries to gallery/addons/index.yaml next to
the four existing ones. They come from addonpkg catalog over locally
built linux/amd64 packages, so the digests pin those images.

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

* test(addons): expect Studio and Talk in the official index

The official index now lists six add-ons. Update the test that
decodes it so it accepts Studio and Talk.

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

* feat(addons): add a real icon to each first-party add-on

Each icon is a 256 by 256 PNG: a rounded square in one colour of the
UI kit palette with a white outline glyph from the kit sprite. Fine-tuning
uses sliders, quantization layers, voice waveform, face user, studio
palette, talk phone and tables database. They were rendered with headless
Chrome from the sprite paths.

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

* feat(addons): ship a committed icon.png instead of the generated square

The shared UI builder and the Tables builder copy ui/icon.png from the
add-on source tree to the package unchanged when the file exists. They
write the generated placeholder only when it does not. Add a fixture with
an icon and a test for both cases.

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

* feat(addons): list the tables add-on in the first-party index

Add the Tables entry to gallery/addons/index.yaml next to the six
existing ones. It comes from addonpkg catalog over a locally built
linux/amd64 package, so the digest pins that image. The official index
test now expects seven add-ons.

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

* feat(addons): make the Voice add-on one page with tabs

The Voice add-on had four pages: recognition, library, create and
speech, and the drawer listed two of them. It now has one page, "voice",
and one drawer entry. Recognition, Library, Text to speech and From a
recording are tabs of that page, drawn with the same tab strip the
recognition page already used. The tab is the first path segment
(voice/library), as in the Studio add-on, and the form that adds a voice
is a view of the Library tab (voice/library/new).

The old page names stay declared in the manifest. Each one redirects to
its tab and keeps the query string, so saved links still open.

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

* feat(ui): send the old voice routes to the Voice tabs

/app/voice, /app/voice-library and /app/voice-library/new now open the
matching tab of the Voice add-on page while the add-on is installed and
running, and the core pages otherwise. The moved-page helper compares
the first segment of the target with the declared pages, so a target
can name a tab. The docs and the navigation spec list the new addresses.

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

* test(addons): cover the Voice tabs and the old page redirects

The add-on unit tests render the tabs instead of the four pages. The
Playwright spec checks the tab strip, the address of each tab, the
missing Library tab for a member, and that the old page addresses of the
add-on redirect to their tab with the query string kept. The migration
table test follows the new targets.

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

* fix(studio): keep the model menu inside a narrow screen

The Home model picker sizes its menu from the viewport, and assumes the
picker sits at the page gutter. In the Studio composer card the field is
further in, so on a 390px screen the menu of the Sound expansion picker
ended 7px past the right edge. Size the menu from the field it opens
under instead.

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

* test(ui): build the add-on bundles once for the whole run

Each worker built the Studio and Talk bundles into the same dist
directory, and the build empties that directory first. A worker that
started, or restarted after a failure, removed the files another worker
was serving, so a Studio spec saw "the style sheet of the add-on could
not be loaded". Build once in a global setup before the workers start.
A run without the setup still builds per worker.

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

* test(ui): register the worker when the add-node spec says so

The mocked roster added the worker on the third read of /api/nodes. The
shell and the page both read it, and which one asks first depends on
load, so the page could take the new worker as part of the roster it
opened with and never show it as found. The worker now joins after the
page shows it is listening.

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

* test(ui): hold the gallery answer until the skeleton is seen

The spec delayed the answer by a fixed 800 ms and then looked for the
skeleton. The assertion polls at growing intervals, so on a slow machine
it could step over that window. The answer now waits until the skeleton
has been seen, and the spec then checks that the skeleton goes away.

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

* test(ui): give the PDF tag the time the other PDF specs have

pdf.js and its worker load on first use. The other specs in this file
wait up to ten seconds for the result of that load. The first one waited
five, and missed the tag on a busy machine.

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

* test(ui): build the Face and Fine-tuning bundles in the global setup

The navigation spec serves the built Fine-tuning bundle, but nothing
built it, so two specs failed on a fresh checkout when the dist folder
was missing. The spec also built the Face bundle in a hook that every
worker runs, and a build rewrites the folder the other workers serve.

The global setup now builds Studio, Talk, Face and Fine-tuning once,
before the workers start. The spec builds the two bundles itself only
when it runs without the global setup.

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

* test(ui): fix the Home answers in the stale pin spec

The spec asserts that the page shows no alert. The Home page raises its
own alert when the model list fails or a staging operation has failed,
and both answers come from the shared test server. A slow reply or a
failed install left there counted as an alert about the pin.

The spec now fixes both answers, so the alert count tells only about the
pin. The assertions are unchanged.

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

* test(ui): read the server address in the add-node spec from the page

The spec expected the register address to carry port 8089, so it failed
on any other port. It now expects the origin the page was opened from.

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

* test(addons): give the navigation routes spec its own catalog

No page is built in today, so the catalog is empty and the three specs
that expect the studio and talk entries failed on the list and on the
pin seed. They passed only while Studio and Talk were built in.

The spec now sets a catalog of two ungated entries for each example and
restores the old one afterwards. The assertions are unchanged.

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

* fix(cli): report a failed write of the MLX hostfile

The tunnel callback ignored the error of os.WriteFile and set the
hostfile variable even when the file was not written. It now logs the
error and leaves the variable alone.

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

* test(addons): discard the close error of the held listener

errcheck flags the deferred Close of the listener that keeps the port
busy. The test has no use for that error, so the defer drops it
explicitly.

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

* test(addons): move the add-on tool tests to Ginkgo

The fine-tuning, quantization and add-on host library tests used
t.Errorf, t.Fatal and t.Run, which the forbidigo rule rejects. They now
use Describe, It and Gomega, with a suite bootstrap in each package. The
cases and the assertions are the same.

The tables in the host client and the tool schema tests keep one entry
per case.

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

* test(addons): move the host operation tests to Ginkgo

The hostops tests used the testing package calls that forbidigo rejects.
They now run as Ginkgo specs under one suite bootstrap. Every check and
every per-operation case is kept.

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

* test(addons): move the pin store tests to Ginkgo

The pin service, file store, sweep and shared store contract tests used
the testing package calls that forbidigo rejects. The store contract is
now a function that registers its specs for each store, so the file
store, the memory-only store and the database store run the same cases.

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

* test(addons): move the remaining add-on tests to Ginkgo

The built-in catalog, tool arguments, loopback carrier, host operation
contract, packaging, application, config, CLI and endpoint tests used
the testing package calls that forbidigo rejects. They now run as Ginkgo
specs. The endpoint tests also drop the unchecked Close errors that
errcheck reports when the auth tag is on.

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

* test(ui): wait for the targeted Reinstall button before the install click

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then clicks Reinstall with
a script click. The click can run before the targeted page has rendered
or while the button is still disabled, so no install request is sent and
the poll on the install count times out. It failed 1 run in 10 with two
workers and on every retry in CI.

The spec now waits for the button to be enabled first. The assertions
are unchanged.

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

* chore(addons): refresh the voice package digest in the first-party index

The Voice add-on became one page with tabs and got a new icon, so the
digest in gallery/addons/index.yaml no longer matched the build. Render
the entry again with addonpkg catalog over a locally built linux/amd64
package; the main page is now voice. The other entries are unchanged.

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

* test(ui): wait for the target node's Reinstall button after the popstate

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then waits for a button
named Reinstall and clicks it. Until React renders the targeted page,
the page still draws the generic Reinstall button. It is enabled and has
the same name, so the wait passed on it, the click started a global
install, and no node install request was sent. The poll on the install
count timed out with the confirm dialog still open.

With the CPU throttled to 8x, the spec failed on 20 of 20 runs, and the
button it clicked had the title "Reinstall". The spec now waits for the
button titled "Reinstall on <node>". With the throttle it passes 30 of
30 runs. The assertions are unchanged.

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

* test(ui): give the Studio hand-off spec a budget for the cold bundle load

The "a result made through a hand-off records which result it came from"
spec opens the Studio add-on straight from an address, twice. Each is a
cold load: the page shows "Loading" until the add-on module has run, and
the next assertion had the default five seconds. The load takes about
two seconds on an idle run and seven to ten under CPU load, so the spec
failed on a loaded machine with the hand-off note not yet drawn.

With the CPU throttled to 6x and eight busy loops running, it failed on
21 of 30 runs. The first assertion after each load now waits up to 20
seconds, and the spec then passed 30 of 30 under the same load. The
assertions are unchanged.

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

* fix(addons): carry the identity page styles in the Face add-on

The Face page is drawn with the shared identity views, but its style sheet
held only the clip and error rules. The page, card, model list, known-faces
table, enrol sheet and distance scale classes had no rules. They looked
right only when the host had already loaded the style sheet of its own
Faces page, which a visit through the add-on drawer does not do. Opened
that way, the cards had no gap, the model list showed bullets, and the
install buttons ran into the names.

The Face sheet now holds the same identity rules as the core sheet, so the
page looks the same whichever way it is opened.

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

* fix(addons): scope the narrow-screen identity rules of the Voice add-on

The rules for narrow screens (single-column pairs, smaller tool padding,
compact rank rows) were copied into the Voice style sheet without their
media query, so they applied at every width. They now sit in a
max-width 720px query, and the reduced-motion rule in its own query, as in
the core sheet.

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

* fix(addons): drop the repeated title from the Voice and Face pages

The add-on shell already titles the page, so the recognition pages showed the name twice: "Voice" above "Voices", "Face" above "Faces". Keep the supporting line and the model picker, as the Studio page does, and remove the unused title strings.

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

* feat(ui): replace the Pinned sidebar section with an Apps entry

The sidebar no longer lists pinned add-ons. It shows one Apps entry that
opens the add-ons drawer, with a Show all link below it that opens the
same drawer. Pins are used from the drawer, where a pinned tile keeps
its pin and move buttons. The server pins API is unchanged.

Remove the PinnedNav component and its locale title, rename the
items.addons label to Apps, and update the e2e specs and the add-ons
documentation to match.

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

* test(addons): assert the single shell title on the Voice and Face pages

The add-on shell now shows the one title, "Voice" or "Face", and the page no longer repeats it. The tests still looked for the h1 "Voices" and "Faces". Assert that the add-on page has exactly one h1 and that it is the shell title. The core pages keep their own "Voices" and "Faces" titles.

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

* fix(addons): drop the repeated title from the Voice Library and Text to speech pages

The add-on shell already titles the page "Voice", so these two tabs showed a second heading below it ("Speech voices", "Speak text"). Keep the supporting line, as the Studio and recognition pages do, and remove the unused title strings. The e2e spec now checks that each tab has exactly one h1, the shell title.

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

* feat(ui): list pinned apps under Apps in the sidebar

A pinned app now shows as an indented child of the Apps entry, with its icon and title, in pin order. Show all stays the last child. The pins come from GET /api/addons/pins through the navigation context, and the apps from the add-on registry, which the drawer feeds on every list read, so the links follow the drawer. A pin without an installed app is not drawn, nothing extra shows when no app is pinned, and the children are hidden when the sidebar is collapsed to the rail.

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

* docs(addons): say that pinned apps are listed under Apps in the sidebar

The sidebar lists the pinned apps between Apps and Show all, in pin order. The pins section and the navigation section said that pins do not appear in the sidebar.

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

* fix(ui): remove the Show all link under Apps in the sidebar

The link repeated the Apps entry, which opens the same drawer, and looked redundant when no app was pinned. Apps is now the only add-on entry; pinned apps still list under it in pin order. The link, its locale key and the style comment are gone, and the e2e spec and the add-ons docs now say that Apps is the only entry.

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

* feat(ui): show a first-run card on Apps when nothing is installed

An administrator who opens Apps with no add-on installed now sees a
short card with three steps: choose a library, browse and install,
start the add-on. Its button opens the Browse tab. The card is hidden
once an add-on is installed and for anyone who cannot install.

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

* fix(ui): hide the pinned apps in the icon rail

When the window is narrower than 1024 px the sidebar shrinks to the icon rail by CSS alone, without the collapsed class. The pinned app links were still drawn there, with the indent meant for the full sidebar, so their icons sat to the right of the rail and were cut off. Hide the pinned app links in both rail states. The Apps entry keeps its centred icon. The e2e spec checks that the rail shows no pinned app and that every icon is centred at 1280 px collapsed and at 1000 px.

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

* test(ui): stop the backend redirect test racing the installed list

The test reads the redirected URL right after the path changes. The
Backends page then loads the installed list and drops a selected backend
that is not installed, so the backend parameter was sometimes already gone
when the test read it. The mock server has no llama-cpp, and about 15 of
40 repeated runs failed on the backend assertion. The redirect itself was
correct, including show_all.

Serve the selected backend in the installed list, so the page keeps the
selection and the assertions stay as strict as before.

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

* fix(addons): accept a same-host Referer or Origin for human confirmation

Browsers send no Sec-Fetch-* headers over plain HTTP on an address that
is not localhost, so creating a resource from a LAN address failed with
"Creating a resource requires your own browser session".

When Sec-Fetch-Site is present it must still be same-origin with an
empty destination, as before. When it is absent, Origin or, failing
that, Referer must name the request host and port. A destination or
mode that is present must still be the expected one. This matches the
rule in BrowserProtection. The credential, token cookie, API key and
tool-context checks are unchanged.

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

* fix(ui): keep hub entries off the Apps route in the sidebar

The Build hub still owned /app/addons through its legacy Add-ons tab,
so the sidebar lit both Apps and Build on the Apps page. A tab whose
gate is closed now owns no route when the sidebar asks, so only the
Apps entry is active there. The hubs keep their own routes, and the
legacy navigation still gives /app/addons to the Build hub.

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

* feat(ui): label the pin control on app tiles and in the detail view

The pin was a small icon-only button and hard to find. Each app tile now
shows a button reading Pin, or Pinned with a filled pin, and the detail
view has the same control in its header. Both toggle the same stored pin.

The Apps grid lists pinned apps first, in pin order, then the others.
The sidebar list and the server pins API are unchanged.

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

* test(addons): check the Voice From a recording tab has one title

The Library and Text to speech tabs already assert that the add-on shell
title is the only h1. Do the same for the From a recording tab, so a
heading added there later fails the navigation spec.

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

* fix(ui): stop the add-on shell drawing a title over add-on pages

The app decides what its page shows. The shell drew the add-on name as a
header above every add-on page, so an add-on could not choose its own
title. The shell now draws no header on a live, loading or frozen
add-on page; each add-on titles its own pages. The unavailable and
missing-page states keep the name, because no add-on page is drawn there.

The runtime and shell specs now check that the shell adds no page title.

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

* fix(addons): let each add-on draw its own page title

The shell no longer titles add-on pages, so each add-on draws one h1 per
page. Voice titles its tabs "Voices" (Recognition and From a recording),
"Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech
voice" already had its own. Face draws "Faces", Studio "Studio" and Talk
"Talk". Tables promotes the database list and database name from h2 to h1.
Fine-tuning and Quantization already drew a title.

The add-on unit tests and the navigation and Studio specs now assert
exactly one h1 per page, with the page's own title.

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

* feat(addons): add a kind to library entries and read unknown kinds tolerantly

A library entry now has an optional kind: addon (the default when it is
absent), backend or model. Every index written before kinds existed stays
valid and its entries are add-ons.

A reader keeps the entries of the kinds it knows and skips the others
without reading their fields, so a newer kind never makes an older host
refuse the whole index. The refresh result and the library state report
how many entries were skipped and of which kinds, and the refresh logs a
warning. A kind that is not a plain kind name still makes the index
malformed. Search lists add-ons unless the caller asks for a kind, and
Resolve returns add-ons only, so an add-on install cannot be pointed at
a backend image.

A library policy may name the signer with an anchored identityRegex
instead of an exact identity, so builds from master and from tags both
verify. An add-on install refuses a policy that carries one.

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

* feat(backends): install a backend from a library entry

A library can now list backends. An entry of kind backend names the
image of the backend gallery by digest, and POST /backends/library/install
installs it on the local node. The gallery stays the source of truth and
its install path is the one used: the library resolves the entry, takes
the signature policy from the library, pins the image by digest and calls
the same install as the gallery. Installing a name that came from a
gallery, another library or by hand is refused and changes nothing.

The signature policy of a backend library names the signer with an
anchored identityRegex, so images signed by the merge workflow on master
and on tags both verify. A pattern that is not anchored is refused.

An install is bounded by a size limit per kind: add-ons keep 512 MiB and
a backend image defaults to 8 GiB, set with --addons-backend-max-bytes.
The limit reads the manifest after the signature check and before any
layer is fetched. The metadata of an installed backend records the
library it came from.

scripts/backends/cmd/backendcatalog renders entries from
backend/index.yaml and pins them against the registry. The pilot is the
CPU build of local-store, in gallery/backends/index.yaml. The library is
registered on the first start only with --addons-backend-library, which
is off by default.

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

* feat(ui): show the kind of library entries and install backends from them

The Browse tab lists the entries of every kind this host reads and marks
each one as an add-on or a backend. A backend installs through the
backend route and not through the add-on lifecycle: the confirmation
says that the backend gallery is not changed and that a backend runs
native code, and a refusal of the host, such as a name installed from
another origin, is shown with its own reason. The details give the kind,
the alias and the signer pattern.

The library editor can name the signer with a pattern. Without this a
save would drop the pattern of a backend library. The Libraries tab
reports the entries a refresh skipped because their kind is not read.

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

* docs(addons): describe backends in the library

Document the entry kinds, how a reader skips a kind it does not know,
the backend entry format, the signer identity pattern, the install
route, the size limit of each kind, the pilot library and the command
that renders it. The backend page links to it.

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

* feat(mcp): install a backend from a library entry

The backend library route installs a backend from an add-on library
entry, and the admin REST surface and the MCP catalog must stay in step.
Add the install_library_backend tool with an in-process and an HTTP
client, the route map entry, the safety-rule listing and a skill recipe.

Listing library entries stays on the add-on routes, which the assistant
does not expose, so no list tool is added.

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

* fix(ui): carry the add-on library keys in every locale

The add-on library and backend library strings existed only in English,
so the other locales had no value for them. Add the missing keys of the
addons namespace to de, es, id, it, ko, pt-BR and zh-CN. The values are
the English text until a translation is contributed.

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

* Revert "feat(mcp): install a backend from a library entry"

This reverts commit 7493cff727.

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

* Revert "docs(addons): describe backends in the library"

This reverts commit 57816ed7e6.

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

* Revert "feat(ui): show the kind of library entries and install backends from them"

This reverts commit 49b2b151b7.

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

* Revert "feat(backends): install a backend from a library entry"

This reverts commit 1d8d3e3421.

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

* Revert "feat(addons): add a kind to library entries and read unknown kinds tolerantly"

This reverts commit 484813f856.

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

* fix(ui): drop the backend library keys from the locales

Revert the part of 39f77ae29 that added strings for backend libraries:
the kind labels, the backend install texts, the signer identity pattern
and the skipped entries notice. The add-on library keys stay.

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

* feat(ui): add an add-ons control to the chat header

Add-on tools were on or off only through the /addons command, with a toast
and no state a person could see. The chat header now has an Add-ons button
in the style of the MCP dropdown. It lists each running add-on that ships
tools with a switch, and a badge shows how many are on.

The chat keeps the ids that are on (addonToolIds) next to the older
boolean, and both are saved with the chat. A chat that has only the boolean
means every add-on. The request sends addon_tools alone when every add-on is
on, and adds addon_tool_ids when only some are. /addons turns all on or all
off and uses the same state.

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

* feat(addons): let a chat request name the add-ons whose tools it uses

addon_tools=true still offers the tools of every add-on the caller may use.
A new addon_tool_ids metadata key, a comma-separated list of add-on ids,
narrows that to some add-ons. The list counts only with the opt-in, and a
list that names nothing offers no tool rather than all of them.

The executor drops the tools of add-ons that are not listed and refuses a
call to one as an unknown tool, so a model cannot reach a tool the person
turned off. Both keys are consumed so they do not reach the backend.

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

* test(addons): cover the per-add-on tool list and the chat header control

Go tests cover the list form (the list filters the tools offered and the
calls allowed) and the unchanged boolean form. Unit tests cover the chat
add-on rules. Playwright specs cover the header control: listing only
add-ons that ship tools, toggling each one, the count, /addons in step with
the switches, and the request metadata.

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

* test(ui): check each add-on page has one title when not enabled

The shell names an add-on only where the add-on page cannot mount (not
enabled, failed, no UI, bundle not loaded, no such page). Once the page
mounts it draws its own title, including when it says it is not enabled
on this server, and the shell draws none. The shell code already works
this way; this change states the rule beside the unavailable state and
pins it in the specs.

The shell spec now expects exactly one title in the unavailable states.
The navigation spec checks the Face page without a face model (the
add-on title only) and the Face page of a stopped add-on (the shell
title only).

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

* feat(addons): upgrade add-ons on their own when the setting is on

A new auto_upgrade_addons setting (flag LOCALAI_AUTO_UPGRADE_ADDONS,
default off) lets the owner upgrade an installed add-on when its library
lists a newer version. It follows the backend setting: a startup flag, a
runtime setting, and the same schedule as the backend update check (a
first pass shortly after start, then every six hours).

Each pass fetches the library of every installed add-on, and for each
available update builds the same pinned upgrade command an administrator
sends and runs it through the same lifecycle path. The channel rules, the
signer identity, the digest checks, the SDK compatibility check and the
backup before the upgrade all apply unchanged. The only difference is the
gate: the owner acts on its own setting, so no administrator is asked.
A refused or failed upgrade changes nothing and is logged; a candidate
that failed is not retried until the library lists another digest.

With the setting off, nothing runs and upgrades stay manual.

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

* feat(ui): add the Upgrade add-ons automatically toggle to Settings

The toggle sits beside Upgrade backends automatically and is off by
default. It saves the auto_upgrade_addons runtime setting. The English
locale gets the label key, and the Settings test fixture lists the new
field.

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

* test(addons): cover the automatic upgrade setting and document it

The setting defaults to off in the config, the runtime settings and the
flag, and is read back from the saved settings file. With it on, a real
owner upgrades an installed add-on through the manager; with it off,
nothing changes, and a change of the setting at run time takes effect on
the next pass. The manager specs show the upgrade goes through the same
pinned path as the manual one, and that a corrupted package, a failed
signature policy, an incompatible release, a missing update checker and
a caller who is not an administrator each leave the installed version in
place. The new specs are added to the lifecycle case inventory.

The add-ons page describes the setting, its schedule and its default.

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

* refactor(addons): name the library entry identifier id

The library entry of an add-on used addonId while the shared envelope
and the model entries use id. Release now has the field ID, written as
id, and every writer emits it: the index encoder, the package workflow
templates, the catalog generator and gallery/addons/index.yaml. The
digests in the index did not change.

DecodeIndex still reads addonId as a legacy alias for one release, so
an existing library and a library served by an older tool keep
working. An entry that holds both names is refused. The Apps UI reads
id and falls back to addonId.

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

* test(addons): use the id field in the auto-upgrade test release

The auto-upgrade test arrived after the identifier rename and still
named the release field AddonID, so the application tests did not
build.

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

* fix(addons): annotate the auto-upgrade actor label for gosec

The actor label looks like a credential to gosec G101. It is a fixed name for upgrades the owner starts itself.

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

* fix(schema): persist the per-chat add-on tool fields

The chat header persists addonTools and addonToolIds, but Conversation had no fields for them, so the server dropped them on save. Add both and update the generated swagger files by hand because swag is not installed here.

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

* test(ui): count the add-ons auto-upgrade setting in the settings groups

The setting sits in the Backends and galleries group, once. The page now lists 54 settings plus the three branding images, so the spec expects 57.

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

* test(ui): pause the fake clock ahead of now in the clear-all spec

The fake clock runs from install, so pausing at the current time failed with 'Cannot fast-forward to the past' on a slow runner. The save itself is synchronous and was not lost.

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

* test(ui): hold the Studio capabilities reply longer in the skeleton spec

800 ms was shorter than a slow runner needs to render the page, so the skeleton was gone before the check. The product shows it.

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

* feat(i18n): add the French add-ons locale

Master added the fr locale after this branch forked. The add-ons unit test requires every locale to carry the add-ons namespace, so fr needs its own addons.json.

Assisted-by: Claude Code:claude-sonnet-5-5 node
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-22 17:57:19 +02:00
localai-org-maint-botandmudler 099d6c3dd0 chore: ⬆️ Update 0xShug0/audio.cpp to 41bb3f0ffb6b46b7a3bb3c02fcea52900d88001b (#12812)
⬆️ Update 0xShug0/audio.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-10-22 17:01:22 +02:00
localai-org-maint-botandmudler aeade22b94 chore: ⬆️ Update vllm-metal (darwin) to v0.32.0 (#12811)
⬆️ 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-10-22 17:01:10 +02:00
localai-org-maint-botandmudler 3bf42df729 chore: ⬆️ Update mudler/vllm.cpp to 8af8a3e907330847d35cf223d93c2ba66a1c9389 (#12810)
⬆️ Update mudler/vllm.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-10-22 17:00:59 +02:00
gangna2s-archandgangna2s-arch 6d854d6132 feat(i18n): add French (fr) locale (#12826)
Add French translations for the web UI and register the language in
SUPPORTED_LANGUAGES so it can be selected from the language switcher.

Coverage follows the existing partial locales (it, es, de): 13 namespaces
are translated and any remaining key falls back to English.

Signed-off-by: gangna2s-arch <281744444+gangna2s-arch@users.noreply.github.com>
Co-authored-by: gangna2s-arch <281744444+gangna2s-arch@users.noreply.github.com>
2026-10-22 17:00:24 +02:00
localai-org-maint-botandmudler 4c4440701a chore: ⬆️ Update ikawrakow/ik_llama.cpp to 15e7ecacddd87455f3fbc4805bc0508df01fa8cc (#12813)
⬆️ 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-10-22 16:54:24 +02:00
localai-org-maint-botandmudler debac53e3c chore: ⬆️ Update leejet/stable-diffusion.cpp to a77d3623180b2bc1886f3dd04c0aa04156a987ef (#12814)
⬆️ 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-10-22 16:54:09 +02:00
localai-org-maint-botandmudler 79ba87d6cb chore: ⬆️ Update CrispStrobe/CrispASR to 95e454b493bf6d1ea7171cb154eaace5c44c9ae3 (#12815)
⬆️ 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-10-22 16:53:54 +02:00
localai-org-maint-botandmudler e08f85596a chore(model-gallery): ⬆️ update checksum (#12817)
⬆️ 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-10-22 16:53:41 +02:00
mudler-agentandEttore Di Giacinto 15e37df9a4 fix(ui): stabilize backend lifecycle tests (#12828)
Wait for the node-specific reinstall control after URL navigation.
The generic control shares its name and can retain the previous handler.

Seed installed inventory before checking legacy backend navigation.
Assert rendered selection and cover missing-selection cleanup separately.
Keep deletion conflicts and final-node cleanup assertions intact.

Assisted-by: nib:gpt-6.1-sol

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
v4.12.0
2026-10-22 14:08:24 +02:00
localai-org-maint-botandmudler 7f7b10fba9 chore: ⬆️ Update CrispStrobe/CrispASR to 88354969657c7673419c6218de2cc8e0d45173f1 (#12761)
⬆️ 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-10-21 10:56:25 +02:00
localai-org-maint-botandmudler 998a5fff9c chore: ⬆️ Update leejet/stable-diffusion.cpp to 0518b34374761cb82aeda5ad3c2f0686fdad2cec (#12795)
⬆️ 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-10-21 10:54:57 +02:00
localai-org-maint-botandmudler d1684af2f2 chore(model-gallery): ⬆️ update checksum (#12797)
⬆️ 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-10-21 00:34:41 +02:00
localai-org-maint-botandmudler 9a6cbc2962 chore: ⬆️ Update mudler/vllm.cpp to 46c6846b939313b591f71825a5f5878918663795 (#12796)
⬆️ Update mudler/vllm.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-10-21 00:34:26 +02:00
localai-org-maint-botandmudler 3026809681 chore: ⬆️ Update 0xShug0/audio.cpp to 0bf4d155f2af5f85853415170622dc145e3d62c3 (#12794)
⬆️ Update 0xShug0/audio.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-10-21 00:33:23 +02:00
localai-org-maint-botandmudler 0538a53f45 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 565f08d1eac9b73bd39fa369b3ffc154e8394b27 (#12793)
⬆️ 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-10-21 00:33:12 +02:00
Stefan Walcz 6fc2bc4967 fix(chat-history): create the store after the auth database is opened (#12788)
The conversation routes are only registered when the chat history store
exists, and the store needs the auth database. It was created in start(),
which New() runs before it opens the auth database, so the store was always
nil and /api/conversations answered 404 with auth enabled; the React UI fell
back to localStorage and nothing was synced between devices.

The route tests register the routes with a store directly and never went
through New(). Create the store right after the auth database is opened,
and cover it through New(): with auth the store exists, without auth or with
the WebUI disabled it stays nil.

Docs-Not-Needed: bug fix; the documented server-side chat history now works as described
Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-10-20 18:09:02 +02:00