Compare commits

...
Author SHA1 Message Date
localai-org-maint-bot 490e4ba778 test(worker): canonicalize missing upload fixture
Resolve the existing model directory before choosing a missing file. On
macOS, resolving the missing leaf fails and otherwise compares an aliased
path with a canonical allowed directory. Keep this test on the Upload
error branch instead of the containment rejection branch.

Assisted-by: Codex:GPT-6
2026-09-08 03:08:43 +00:00
localai-org-maint-bot 4e4597dfc2 fix(worker): resolve staging directory symlinks
Resolve allowed directories before comparing them with resolved files.
Otherwise staging rejects valid files under macOS temporary paths.
Cover aliased roots, sibling paths, and symlinks escaping the root.

Assisted-by: Codex:gpt-6
2026-09-08 01:05:42 +00:00
localai-org-maint-bot 2756eaeab0 Merge master into the distributed test branch
Include current gallery and virtual-model path fixes before continuing
the requested test repairs.

Assisted-by: Codex:gpt-6
2026-09-08 01:05:42 +00:00
localai-org-maint-botandEttore Di Giacinto 561d623976 feat(gallery): add EXL3 vllm.cpp models (#11912)
* docs: design EXL3 gallery entries

Define the pinned Qwen3.8 and DeepSeek V4 Flash entries before changing the gallery.

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

* feat(gallery): add EXL3 vllm.cpp models

Add pinned Qwen3.8 and DeepSeek V4 Flash configurations. Bind the staged DFlash companion path so vllm.cpp opens LocalAI's managed snapshot.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 20:55:42 +02:00
localai-org-maint-botandEttore Di Giacinto aff9db9758 fix(distributed): resolve paths for virtual models (#11911)
Virtual model names have no primary file to anchor the worker path.
Companion assets still stage successfully, but relative options retain
an incorrect model directory and fail to load.

Derive the worker root from successfully staged option assets when the
primary path is absent. Cover Buffalo packs, files, directories,
overrides, and failed transfers. Document the frontend upgrade.

Assisted-by: Codex:gpt-6 golangci-lint

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 19:32:17 +02:00
localai-org-maint-bot 0291189e82 test(nodes): adapt fixtures to worker transport
Merge current master and adapt incoming tests to the distributed APIs.
The sound mock now implements NewClientForNode and uses WorkerLocalAddress.
The upload finalization test supplies the existing direct test dialer.

Assisted-by: Codex:gpt-6
2026-09-07 17:05:45 +00:00
localai-org-maint-botandEttore Di Giacinto e494033607 fix(distributed): finalize stalled model uploads (#11910)
A worker can retain all model bytes with an unfinished-upload marker.
Retries then start at zero and repeatedly fail with HTTP 416.

Verify the existing bytes and finalize same-file retries at full size.
Reuse the normal integrity checks so corrupt content cannot be accepted.
Add regression coverage and document worker recovery.

Assisted-by: Codex:gpt-6 golangci-lint

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 18:52:29 +02:00
localai-org-maint-bot 4e32249227 test: keep distributed specs in their own targets
The general e2e target builds a container, not the host binary that
cluster specs require. Run only its own package; dedicated distributed
and cluster targets retain the full subpackage coverage.

Assisted-by: Codex:GPT-6
2026-09-07 16:08:13 +00:00
localai-org-maint-bot 994ec9dde0 test: guard shared PostgreSQL setup on macOS
Apply the existing Docker skip at the container entry point so the
maintenance-connection regression cannot bypass it.

Assisted-by: Codex:GPT-6
2026-09-07 16:08:13 +00:00
localai-org-maint-bot d397393022 Merge master into the distributed test branch
Assisted-by: Codex:GPT-6
2026-09-07 16:08:13 +00:00
localai-org-maint-botandEttore Di Giacinto 88d19567b8 feat(faces): replay saved face enrollments (#11908)
Accept original embeddings and timestamps so clients can restore faces
when the in-memory store restarts. Derive stable IDs from exact vectors
to make registration retries preserve identity without duplicate entries.

Assisted-by: Codex:GPT-6 golangci-lint

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 17:40:27 +02:00
localai-org-maint-botandEttore Di Giacinto fb8b7a359a fix(distributed): stage sound detection audio (#11907)
* fix(distributed): stage sound detection audio

Sound detection passes frontend temporary paths directly to remote
workers, unlike transcription. Stage the WAV before classification so
CED can read it without a shared temporary directory.

Preserve the original request for retries and propagate staging errors
without calling the backend. Cover staging, request preservation, and
error handling with regression tests.

Assisted-by: Codex:GPT-6 golangci-lint

* test(distributed): verify routed sound staging

Call sound detection through the client returned by SmartRouter.Route.
This checks interface dispatch through both routing wrappers, rather
than constructing FileStagingClient directly.

The test fails without the sound-staging override and passes with it.

Assisted-by: Codex:GPT-6 golangci-lint

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 17:12:37 +02:00
Plamen K. Kosseff 77b8241c51 docs(integrations): add Distribution Packages section (#11904)
Community-maintained packagings that currently track releases —
Homebrew, ALT Sisyphus and the Gentoo local-ai overlay — with a note
that versions may lag. Placement and scope as discussed in the issue.

Assisted-by: Claude:claude-fable-5

Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
2026-09-07 16:36:58 +02:00
57f802aa7a chore: ⬆️ Update leejet/stable-diffusion.cpp to d8fb10c02977c8ca999f3fb4e02df9ecf10f7ba6 (#11898)
* ⬆️ Update leejet/stable-diffusion.cpp

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

* fix(stablediffusion): adapt streaming options

Upstream now selects segmented weight streaming automatically and removes the stream_layers field. Keep the old LocalAI option as a no-op for existing model configurations.

Assisted-by: Codex:gpt-5

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 12:56:55 +02:00
f94ce520ce chore: ⬆️ Update antirez/ds4 to 9ab705347c1775e7599ede7eb81a6255ec7dccb5 (#11808)
* ⬆️ Update antirez/ds4

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

* fix(ds4): link upstream image helpers

The ds4 bump adds vision calls to the engine object. Link the new image preprocessing object into every backend target.

Assisted-by: Codex:gpt-5

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 12:56:38 +02:00
localai-org-maint-botandmudler 5728f78f2d chore: ⬆️ Update ggml-org/llama.cpp to 465e49b9cea78a68b9c244ffb48d0ee24a82873d (#11897)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-07 08:22:41 +02:00
localai-org-maint-botandmudler 763bf6fbab chore: ⬆️ Update 0xShug0/audio.cpp to f6277c1695a83cf388a8282c1c1a8757cf626f18 (#11899)
⬆️ 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-09-07 08:22:16 +02:00
localai-org-maint-bot 8403d9da05 Merge master into test/distributed-e2e-ci
Preserve heartbeat checkpoints and backend readiness across the tunnel
transport changes. Update incoming tests for the renamed worker address
fields and health monitor arguments.

Assisted-by: Codex:gpt-6
2026-09-07 04:08:18 +00:00
Alex Mazzariolandlocalai-org-maint-bot 4d854f96a8 Update containers.md to fix podman image qualification (#11749)
* Update containers.md to fix podman image qualification

Signed-off-by: Alex Mazzariol <alex@alex-maz.info>

* docs(containers): clarify Podman image names

Podman can reject short image names when no registry is configured. Explain why the examples use fully qualified Docker Hub names.

Assisted-by: Codex:gpt-5.6

---------

Signed-off-by: Alex Mazzariol <alex@alex-maz.info>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-06 12:45:56 +02:00
Ettore Di Giacinto e7b2e1ee55 test(openai): drop the audio snapshot nothing reads
The mutex round the realtime transport double added a snapshot accessor for each
recorded slice. Only the event one has a caller, so make lint refuses the build:

    realtime_doubles_test.go:64:25: func (*fakeTransport).recordedAudio is unused (unused)

No spec has ever read the audio log, before the mutex or after it, so the
accessor is deleted rather than nolinted and the struct comment says where the
next one comes from. audioLog stays written, because a double that silently
discarded what a coordinator sent it would be a different double.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:58:36 +00:00
localai-org-maint-botandmudler c3f9e1a3a2 chore: ⬆️ Update ggml-org/llama.cpp to 74a7c897f049c17e7080423aa2111776eff6ebbf (#11889)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-06 09:11:54 +02:00
Ettore Di Giacinto 9d58fb34cc feat(gallery): add S1-mini variants
Squashed merge of #11642.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:35 +00:00
Ettore Di Giacinto b5b4633595 feat(gallery): add nl2sh 1.5B
Squashed merge of #11538.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:35 +00:00
Ettore Di Giacinto 24b9362038 feat(gallery): add GLM-5.3 Flash variants
Squashed merge of #11785.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:35 +00:00
Ettore Di Giacinto 4aae0009a2 feat(gallery): add Apodex 1.1 mini variants
Squashed merge of #11731.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:35 +00:00
mudler 287d77911b chore(model gallery): 🤖 add new models via gallery agent
Squashed merge of #11871.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:35 +00:00
mudler 5dc16c6f52 chore(model gallery): 🤖 add new models via gallery agent
Squashed merge of #11848.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:35 +00:00
mudler 648fbafd93 chore(model gallery): 🤖 add new models via gallery agent
Squashed merge of #11847.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:35 +00:00
mudler ae8dc199d5 chore(model gallery): 🤖 add new models via gallery agent
Squashed merge of #11820.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:34 +00:00
mudler 8dda90e60a chore(model gallery): 🤖 add new models via gallery agent
Squashed merge of #11813.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:34 +00:00
mudler 4f8efd51be chore(model gallery): 🤖 add new models via gallery agent
Squashed merge of #11805.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:34 +00:00
Ettore Di Giacinto d170de2095 test(worker): script the kernel out of the port bookkeeping specs
Probing every candidate before handing it out is right, and it made sixteen
specs that were never about the kernel depend on it. They build a supervisor
directly, name the ports they expect literally, and those literals sit inside
Linux's default ephemeral range, so with the real probe each one asks this host
whether 50051 is bindable at that instant.

The first full -race run over ./core/... and ./pkg/... after the probe landed
went red on four of them, and holding 50051, 50052, 50060 and 50061 from
another process turns eleven red deterministically. Nothing was wrong with the
allocator in either case: something else on the machine held a port, which is
the situation the probe exists to survive.

So the specs that assert bookkeeping now inject a probe that always says yes,
and say why once. The two specs that are about the probe leave the field unset
and keep asking real sockets, which is what still fails if the probe is removed.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:01:23 +00:00
localai-org-maint-botandmudler e170b2d615 chore: ⬆️ Update 0xShug0/audio.cpp to c41ffefb44c16345c0ac57a0030f4cd3b5fbc4b8 (#11888)
⬆️ 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-09-06 08:55:28 +02:00
localai-org-maint-botandmudler b1dbc0686a chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to 040c8b344d8c670ce1475194751d119b5ef82c78 (#11890)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-06 08:55:10 +02:00
localai-org-maint-botandmudler cdcb724523 chore: ⬆️ Update NVIDIA/NeMo-Speech.cpp to ffa38cb2408f1e832a36d46fef5e3e1e80d07e6c (#11887)
⬆️ Update NVIDIA/NeMo-Speech.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-09-06 08:54:52 +02:00
Ettore Di Giacinto 7b66df6651 test: fix the three data races that made -race runs noisy
None was introduced by this branch and all three are in test code, which is
what made them survive: every suite passed on every run and only the race
detector said otherwise. A known-failing -race run is worse than a noisy one,
because a real race raised by production code lands in the same report and is
read as one of these.

galleryop: gatedModelManager guarded the recorded names and not the gate
channel itself. A spec frees the parked worker by closing the gate and
installing a fresh one, on the spec goroutine, while the worker goroutine reads
the field to park on it. The channel is now read and replaced under the same
mutex, and cleanup closes idempotently.

pkg/model: two specs swapped xlog's package logger to capture output and
swapped it back on cleanup. xlog.SetLogger writes an unsynchronised global, so
the restore raced with the backend process watcher, which logs while a process
is stopping; the captured bytes.Buffer was written by that goroutine and read
by an Eventually at the same time. SetLogger is now called once for the whole
test binary, from init, before a goroutine exists to race with, and a spec
swaps the DESTINATION under a mutex through a routing slog.Handler. Per-spec
level filtering is preserved deliberately: one of these specs asserts that a
debug emission is filtered OUT and would pass vacuously against a handler that
recorded everything.

openai: fakeTransport appended to its event and audio logs from the response
and turn coordinators' goroutines while a spec ranged over them. Both are
behind a mutex and are read through snapshot accessors; the fields are renamed
so a raw read from another spec file does not compile.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 05:24:18 +00:00
Ettore Di Giacinto 3462e57a3b fix(distributed): back a failed claim off instead of respinning it
The claim queue's attempts counter grew without bound and nothing read it. At
the default two-second poll a permanently undispatchable row cost about 43000
UPDATEs a day, and it cost more than writes: rows are claimed oldest first, so
the oldest stuck row was re-claimed ahead of every newer one on every tick and
held a dispatch slot while it failed. One poison row starved the queue behind
it.

No dead letter, and that is the decision rather than the omission. Read
settleClaim: the only outcome that releases a claim is one where NOTHING was
learned about the work. No agent worker was connected, the tunnel broke, a peer
could not be reached, the stream was refused before the request body left this
replica. Not one of those is a worker saying it ran the job and it failed, and
an attempt ceiling would turn "the fleet was away long enough" into a job
failure nobody reported, which is the collapse this whole design exists to
prevent pointed at work instead of at nodes. The one verdict available here,
that no build of any worker serves this kind, is already settled as an answer.

So the retry stays unbounded and the RATE does not. Each release stamps the row
with the earliest it may be claimed again, doubling from two seconds to a cap
of sixty, computed in the release statement from the row's own attempts count
and stamped on the DATABASE clock, because that is the clock competing replicas
order the queue on. Queued work becomes claimable again within one cap of the
fleet returning, and a stuck row no longer holds the head of the queue. A claim
released by the reap carries no delay at all: that work was never handed to
anyone, so there is nothing to back off from.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 05:24:18 +00:00
Ettore Di Giacinto 21a63c8edf fix(distributed): drop a departed node's cached HTTP clients
A frontend keeps two caches of one http.Client per worker: the control
client's, built on the first verb issued to a node, and the HTTP file stager's,
built on the first file staged to it. Both are keyed by node ID and neither was
ever pruned. Their own comments said so and named what a fix would need, a
signal that a node has left, which did not exist when they were written and
does now.

The map slot is the smaller half. Each entry holds an http.Transport whose idle
connections are streams on that worker's tunnel, kept until IdleConnTimeout
even after the tunnel is gone, so ForgetNode closes them rather than leaving
them to the collector. Both are registered on the deployment's one departure
notifier, as two subscribers and not one: a node can be in either cache without
being in the other, and a single hook would say only that some client was kept.

ForgetNode is on the FileStager interface rather than on the one implementation
that has state to drop, so a stager that grows a per-node map later cannot be
added without answering the question, and so registerDepartureEvictions can
take a FileStager and still fail to compile if the registration is deleted. The
S3 stager's is a documented no-op that deliberately does not forward to the
control client, which registers itself: forwarding would evict a cache it does
not own, twice per departure, and the second drop would not appear in the
subscriber names the wiring spec reads.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 05:06:21 +00:00
Ettore Di Giacinto 80f4da42ad fix(distributed): bound the health monitor's miss streaks to live rows
HealthMonitor.misses holds one consecutive-failed-probe count per (node,
model, replica) and nothing ever removed an entry whose row had gone. It is
the only per-node state in a frontend that grows on model churn rather than on
fleet size, so a deployment that loads and unloads models for months
accumulates an integer per tuple it ever probed and gives none back.

There are four ways a row stops being visible to the pass, not one. A node
departs and the pass skips its probes; a node goes offline or unhealthy on a
stale heartbeat and the pass skips it entirely; an operator sets a node
draining; or the row is removed by an unload, a scale-down or an eviction, and
nothing tells this monitor.

So the bound is the pass itself, and not a subscription on the departure
notifier. The notifier evicts the caches a DEPARTURE invalidates and it keeps
that one meaning; this reads a different fact, that there is no longer a row
to count misses against, and covers all four cases with one rule. A row the
pass could not probe is marked seen before the probe, so an unreachable worker
still leaves its streak exactly as it was rather than having it forgiven; a
pass that could not list the fleet prunes nothing, since it observed nothing.
Forgetting only ever delays a reap by up to the miss threshold and can never
cause one.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 05:06:09 +00:00
Ettore Di Giacinto 4b9cd31dd3 fix(worker): check a gRPC port is free before handing it out
The backend port allocator allocated from its own bookkeeping alone. That
bookkeeping records what this worker did with a port, and the collision it
cannot see is with something this worker never did: the default base port is
50051, inside Linux's default ephemeral range of 32768 to 60999, so the kernel
hands ports in this range to outbound connections and to anything that binds
port 0. A backend handed one of those dies on bind, and the frontend sees a
backend that will not start.

Every candidate is now probed by binding the exact address the backend will
listen on, in all four allocation branches: the key's own port, the free pool,
a grown port and a stolen one. Probing the free pool matters as much as
probing a grown port, because a port this worker released is exactly as
available to the kernel as one it never used.

A candidate that fails the probe is quarantined rather than blacklisted, since
whatever holds it is usually an ephemeral connection that gives it back, and
its affinity claim is dropped so an unbindable port does not stay reserved for
the key that last held it. Exhaustion now says how many candidates were
skipped, which is what tells an operator "something else is in my range" from
"my range is too narrow".

This does not remove the race and cannot: between the probe and the child's
bind the kernel can still give the port away. It removes the far larger window
in which the allocator hands out a port the kernel gave away minutes ago,
which was the whole of the observed one-in-three harness flake. The e2e
harness comment that recorded the missing check is corrected, and the docs say
how to move the range out of the ephemeral one entirely.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 05:05:58 +00:00
Ettore Di Giacinto f207976281 fix(distributed): say that skills and collections are replica-local
Skills and RAG collections had no cross-replica invalidation, and the two
builders that would have published one were deleted earlier in this branch
because nothing called them. Wiring one now would be wrong, not merely late.

Both features are derived entirely from the frontend's own state directory.
A skills.Service indexes <state dir>/skills, a collections backend enumerates
<state dir>/collections and holds one handle per collection it found there,
and no replica reads or writes another replica's copy of either. In
distributed mode PostgreSQL carries a skill's NAME and description in
skills_metadata, and nothing else: Get, Search, Export and the resource verbs
all read local files. So a peer told to drop a cache entry would rebuild it
from a directory that does not hold the change. For a postgres-engine
collection it would be worse than a no-op, since re-deriving one on a replica
with no local index file yields a collection that answers with an empty file
list against a populated vector store. What is missing is shared storage, not
a broadcast.

Recorded rather than left silent: the two cache fields say why nothing
invalidates them, a distributed frontend logs the limitation once at startup,
and the docs name the two deployments that avoid it. The new spec pins the
premise, so a change that moved either directory onto storage every replica
mounts reddens and the decision gets taken again.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 05:05:46 +00:00
Ettore Di Giacinto a1b5c177bc feat(cluster): make a peer prove which replica it is
GET /api/cluster/peer authenticated with the deployment's shared
registration token and took the dialling replica's id from ?id= on trust.
Every worker holds that token, so anything holding it could open a peer
link as any replica: relay through it to every worker tunnel that replica
owns, displace a real replica's inbound link by declaring its id, and
point the roughly 31 GiB per-session receive window at one replica.

Validating the id against the instances table does not fix this, because
the attack declares a real replica's id. So the route now checks two
credentials and needs both. The shared token still says the dialler
belongs to this deployment; a new per-replica credential says which
replica it is.

The credential follows the per-node worker credential rather than
inventing a second mechanism: crypto/rand.Text, stored only as a hex
SHA-256, compared in constant time, with no fallback to the shared token.
It differs in the stronger direction. A worker's credential is minted by
the frontend and handed over once; a replica writes its own instances
row, so it mints its own secret, publishes only the hash in the same
statement that publishes its address, and never sends the plaintext
anywhere but the peer dial.

A peer that presents no credential is refused, not waved through. An old
replica and an attacker holding the shared token send the same request,
so accepting the first accepts the second; there is no safe downgrade
here, only a quiet one. The refusal is made loud instead, on both sides,
naming the upgrade rather than the network. On the documented
frontend-first order a new replica still dials an old one; an old replica
cannot dial a new one, which costs relayed requests that land on a
not-yet-restarted replica and surfaces as no route, never as absence.

A rejected peer gets its own sentinel, ErrPeerRejected, whose unwrap
chain carries ErrPeerUnreachable as well and no absence sentinel at all.
Keeping the older sentinel means no existing consumer changes behaviour;
the cause stays out of the chain, so absence cannot escape through it and
nothing can read an authorization failure as a worker that went away.

One consequence beyond the fix: a replica with no advertised address has
no instances row, so it now cannot dial out either. It was already
unreachable inward. The startup error and the docs say so.

Registry.Register, NewMembership, NewPeerPool, PeerHandler and
RegisterClusterRoutes all gained required arguments, so the identity
cannot be dropped without a compile failure.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 04:05:56 +00:00
Ettore Di Giacinto 348b0860dc test(distributed): prove the fan-out carrier between two real replicas
Removing the broker left one thing carrying every broadcast family in the
product: PostgreSQL LISTEN/NOTIFY, in core/services/pgbus. It is covered
thoroughly in process by test-e2e-distributed, and it was covered nowhere at
all by real binaries: grepping the six Cluster spec files for pgbus,
bus_messages, LISTEN and NOTIFY returned zero hits. Registration, model staging
over the tunnel and inference through both the owner and the relay paths were
already proven by real processes; the carrier that now carries everything else
was not, so a deployment whose replicas each published to themselves and heard
nobody would have left every suite green.

Two specs, both on two frontends and no workers against one PostgreSQL,
publishing at frontend 0 and reading at frontend 1.

1. A gallery operation admitted at one replica, read out of the other, with the
   queued state observed before the terminal one.
2. A broadcast of about 9.3 kilobytes, which PostgreSQL refuses as a
   notification payload, making the round trip byte for byte through the
   bus_messages spill table.

The family is a gallery operation for one property nothing else on this carrier
has: the answer a peer gives is held in memory ALONE. GET /models/jobs/<id>
reads galleryop's statuses map, which on a peer is filled by the
gallery.*.progress subscriber and by nothing else, because the only other
filler, Hydrate, runs once at startup and every operation here is created long
afterwards. Every other family has a durable table behind it that a peer would
converge through anyway, and a spec on one of those cannot separate "the
broadcast arrived" from "the row was read".

That is then made checkable rather than argued. The gallery_operations row is
written when the gallery worker DEQUEUES an operation, so an operation still
waiting in the queue has NO row, and both specs assert zero rows while the peer
is already answering with the operation's own bytes. Both also read the
instances table and require the reading replica to be a different live instance
from the publishing one, so "the other replica" cannot decay into a spelling of
"this replica".

Holding the queue is what cluster.Options.Galleries is for. The gallery worker
runs one operation at a time on an unbuffered channel, so an install parked
inside a gated index fetch parks everything behind it; without that the
admission broadcast and the terminal one are separated by two database round
trips and no HTTP poller could see between them. The option also turns the
startup estimate warmer off, because a second fetcher filling the process-wide
index cache would leave the operation never blocking and the spec passing on an
ordering nothing enforced.

The spill spec is written against a failure this branch has shipped three
times: a size-limit spec that cannot fail. The oversized body is an ordinary
element name that the real consumer decodes and surfaces, so it is not a body
the decoder would have refused at any size. The size is ABSOLUTE at 9000 bytes
rather than derived from the cap, and a one-byte control operation in the same
run is required to leave no spill row, so moving the 8000-byte cap in either
direction reddens the spec. pgbus.FitsInline, which shares its encoder and its
comparison with Publish, is asked about both payloads and must answer
differently. The spilled row is then decoded and its element name compared byte
for byte against what frontend 1 answers.

The terminal assertion in spec 1 does not re-check the element name: a terminal
status does not carry one, because updateError in galleryop.Start builds a
fresh OpStatus holding only the error. It asserts the two fields that status
does carry, in the relation that one place writes them.

Attacks run, each alone, each reverted, each behaving as predicted. Neutering
the pg_notify in pgbus.Publish so every replica knows only what it did itself
reddens both specs at frontend 1, which answers 500 for an operation it was
never told about; the bus_messages row assertion still passes under it, which
is right, since the row is written before the notification. Releasing the queue
gate reddens spec 1 at the gallery_operations count, because the operation is
dequeued and the row appears. Shrinking the oversized name to 100 bytes reddens
spec 2 at FitsInline; inverting that guard so the run reaches the row check
reddens it there instead, with no bus_messages row written, which is what makes
the row a statement about size.

test-e2e-cluster is 26 specs in 933.8 seconds of Ginkgo time, 15m37s wall. The
two additions cost 7.0 seconds together, 5.0s and 2.0s: they start no workers,
so they pay for no registration, and what they wait on is a broadcast rather
than a threshold. test-e2e-distributed is unchanged at 223 plus 8 specs, 130.6
seconds. The budget comment and .agents/building-and-testing.md move from 24
specs at 897 to 907 seconds to 26 at 933.8.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 02:41:49 +00:00
Ettore Di Giacinto 18d20239df fix(ui): keep trace expansion stable during refresh
Squashed merge of #11278.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
pengmin bfd85ac1dc fix(diffusers): pass text prompts by keyword
Squashed merge of #11836.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
pengmin 718357219b fix(ui): send collection intervals as numbers
Squashed merge of #11819.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Anupam Mediratta 1479aef8cd fix(deps): upgrade path-to-regexp to 8.4.0 (CVE-2026-4926)
Squashed merge of #11633.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
pos-ei-don 4894056380 fix(sglang): force reasoning when the template prefills the think tag
Qwen3-style chat templates append the opening <think> tag to the *prompt*
when thinking is enabled. The model therefore never generates it and emits
only the reasoning text plus the closing </think>.

sglang's ReasoningParser keys off the opening tag:

    in_reasoning = self._in_reasoning or self.think_start_token in text
    if not in_reasoning:
        return StreamingParseResult(normal_text=text)

so with such a template the entire completion — reasoning and answer, the
raw </think> in between — is returned as content and reasoning_content
stays empty, no matter how reasoning_parser is configured.

sglang's own OpenAI server handles this via

    force_reasoning = (self.template_manager.force_reasoning
                       or self._get_reasoning_from_request(request))

This backend has no template manager, so derive the same signal from the
rendered prompt: if it ends with the detector's think_start_token, the tag
was prefilled and the parser is constructed with force_reasoning=True.

Structured decoding is the exception, and it matters: a grammar applies
from the first token, so the model cannot emit the closing tag even though
the template opened the block. The whole completion is schema output and
belongs in content — forcing there files it as reasoning and returns an
empty answer. Measured against a JSON-schema code audit: 10107 characters
of "reasoning", zero content. sglang's own server keeps the two apart for
the same reason; its grammar backend owns the reasoning prefix when a
reasoning parser is configured.

force_reasoning is only passed when it is meant to be True, so detector
defaults (DeepSeek-R1 already defaults to True) are untouched, and a
prompt without a prefilled tag behaves exactly as before — which matters,
because forcing unconditionally makes an answer generated with thinking
off disappear into reasoning_content.

The construction is factored into _new_reasoning_parser() so the streaming
and non-streaming paths, which previously built the parser separately,
cannot drift apart.

Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
2026-09-05 22:09:16 +00:00
Tai An 9319450aa6 fix(python-backends): re-attach media markers under use_tokenizer_template (#11621)
With `template.use_tokenizer_template: true` the sglang and vllm backends
render the prompt themselves via `tokenizer.apply_chat_template()`, and they
hand it plain string content. A chat template only emits the model's own media
tokens when the content is a list of parts, so the rendered prompt carries no
`<|vision_start|><|image_pad|><|vision_end|>`. The pixels do reach the engine
(`image_data` / `multi_modal_data`), but both engines locate them by scanning
the prompt for that token, so they are discarded silently: HTTP 200, no
warning, and the model answers as if no image had been attached.

Add `attach_media_parts()` to the shared `python_utils` helper and call it in
both backends: the last user turn is rebuilt as
`[{"type": "image"} * n, {"type": "video"} * n, {"type": "text", ...}]` before
templating, which makes the template emit the placeholders. The pixels keep
travelling out of band exactly as before.

Text-only requests are untouched - with no media the helper returns None and
the original string-content path runs unchanged. If a template cannot iterate
content parts (a text-only model), the parts render is caught and the request
falls back to the previous string-content prompt instead of failing.

Signed-off-by: Tai An <antai12232931@outlook.com>
2026-09-05 22:09:16 +00:00
pos-ei-don c4fe77a369 fix(vllm): tell the reasoning parser whether thinking was enabled
vLLM's engine-based reasoning parsers derive their initial state from the
chat template kwargs. Qwen3Parser:

    chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
    self.thinking_enabled = chat_kwargs.get("enable_thinking", True)

Constructed as ReasoningParser(tokenizer) the flag defaults to True, so the
parser starts in the REASONING state. A completion produced with thinking
disabled contains no tags at all, and every reasoning parser shape then
reports the whole answer as reasoning:

  - engine-based parsers classify it by initial state;
  - BaseThinkingReasoningParser hits its documented "may not generate start
    token" fallback and returns (model_output, None).

Either way `content = c if c is not None else generated_text` turns that
into a duplicate: a Qwen3 model answering "391" with thinking off comes back
as reasoning_content="391" AND content="391".

Measured against Qwen3.5-MoE on vLLM 0.28, non-streaming:

    before   thinking on   reasoning=202  content="391"
             thinking off  reasoning="391" content="391"   <- duplicated
    after    thinking on   reasoning=192  content="391"
             thinking off  reasoning=""    content="391"

Forward the kwargs the prompt was rendered with, which is what vLLM's own
OpenAI server does; parsers that do not accept the argument keep the plain
constructor.

_split_reasoning() covers the older parser shape, which has no initial state
to set. It only reclassifies when the parser exposes a start/end token pair
and neither the completion nor the prompt ever opened a reasoning block.
Truncated reasoning (block open, end token never arrived) stays reasoning,
and parsers without that token pair are left untouched.

Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
2026-09-05 22:09:16 +00:00
pos-ei-don 84db1e6430 fix(backends): preserve an explicit seed of 0 in sglang and vllm
#11772 exempted Temperature from the zero-filter in both backend adapters,
because proto3 has no field presence and an explicit 0 is indistinguishable
from "unset". Seed has exactly the same property and is still filtered:

    if proto_field != "Temperature" and value in (None, 0, 0.0, [], False, ""):
        continue

A caller pinning `"seed": 0` for a reproducible run therefore gets a random
seed instead, with no error and no log line — the one case where the failure
is invisible precisely because the request looked deliberate.

Both adapters now share a named tuple of fields whose zero is meaningful, so
the next one is added in one place rather than as a second special case.

Deliberately left filtered: top_k, top_p, min_p and the penalties. Their zero
is not a value a caller means — sglang disables top_k with -1, not 0, so
forwarding 0 there would turn a default into an invalid argument.

Verified on the sglang backend (Qwen3.5-MoE, arm64): with the temperature fix
alone, two identical requests at temperature 0 are byte-identical, but pinning
seed 0 has no effect until this change.

Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
2026-09-05 22:09:16 +00:00
Tai An 37e18a4bf3 fix(quantization): record job progress without an attached listener (#11874)
A quantization job that runs with no client on its progress stream stays
"queued" forever, in the API and in state.json, while the finished artifact
sits on disk. state.json was written once by StartJob, and the only code that
advanced a job afterwards lived inside the stream callback of StreamProgress,
so job state depended on somebody watching it.

The backend's progress stream cannot simply gain a second reader: each job owns
one queue.Queue and QuantizationProgress pops from it, so two consumers split
the updates rather than both seeing them. The stream has to be opened exactly
once per job.

StartJob now starts watchProgress on the application context (the request
context is done as soon as the handler returns). That goroutine is the single
reader: it applies each update to the job -- in the cross-replica store and in
state.json, terminal statuses still winning over late updates -- and republishes
it in-process. StreamProgress becomes a pure reader over that fan-out and no
longer loads a backend or opens a stream. A client attaching to a job that has
already finished, including one hydrated from disk after a restart, gets a final
event built from the stored job instead of blocking.

Two paths used to end a client's stream by breaking the gRPC connection and now
release it explicitly: StopJob kills the backend, so it publishes the stopped
event itself; and a stream that ends without a terminal update means the backend
is gone, so the job is recorded as failed rather than left running forever.

Signed-off-by: Tai An <antai12232931@outlook.com>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto ab3f7fc904 fix(qwen-asr): select Intel XPU devices
The Intel backend installs PyTorch XPU wheels, but Qwen ASR only
checked CUDA and MPS. Every Intel model therefore loaded on the CPU.

Select XPU when available and place the model on xpu:0. Keep the
existing CUDA, MPS, and CPU placement behavior.

Assisted-by: Codex:GPT-5 [apply_patch] [gh]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto 8744de44d4 fix(whisperx): reject unconfigured diarization
WhisperX silently returned a plain transcript when diarization lacked
the Hugging Face token required to load pyannote. Reject that request
clearly so callers do not mistake missing speaker labels for a
successful diarization.

Convert WhisperX seconds to the nanosecond duration unit used by the
transcription API.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
lei_lei 9ca516d6ef fix(nix): use legacy-peer-deps for react-ui
importNpmLock turns the same-version hono override into a file: tarball
that conflicts with the direct dependency (EOVERRIDE). Pass
--legacy-peer-deps so the flake build can proceed without
touching package.json (open #11633).

Fixes #11804

Signed-off-by: lei_lei <96427312+leilei3167@users.noreply.github.com>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto b2fdbdd52a feat(gallery): add LLM-jp 4 thinking
LLM-jp 4 provides a recent Japanese and English reasoning model on the supported Llama architecture. Add its official Q4 and BF16 GGUF builds so hosts can select the fidelity that fits.

Assisted-by: Codex:gpt-5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto f4bbc4a725 feat(gallery): add Dirk Qwen3.8 variants
Add Q4_K_XL and Q8_K_XL llama.cpp builds with the shared vision projector. Enable the preserved MTP head for speculative decoding.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto 5f783f97a3 feat(gallery): add Security-SLM Gemma 4
Add the text-only Q4_K_M build for private red-team, blue-team, and security operations workloads. Configure the supported Gemma 4 model for llama.cpp with its verified Hugging Face checksum.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto 4c1002cc78 feat(gallery): add mxbai embed large variants
Add Q4_K_M and Q8_0 llama.cpp builds plus the official F16 GGUF for Mixedbread mxbai-embed-large-v1.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto ca3b5dad8b feat(gallery): add Laguna S APEX variants
The new importance-matrix builds reduce Laguna S 2.1 from the existing 96 GB default to 73.9 GB or 54.4 GB. Add both as selectable llama.cpp variants.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto c96904ddf4 feat(gallery): add DFM Mimir for vLLM
DFM Mimir is a new permissively licensed Danish and English HRM-Text model. LocalAI already ships a vLLM version with native support for its architecture.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto 6324ee1dd9 feat(gallery): add VoxCPM2
Add the artifact-backed VoxCPM2 model for multilingual speech synthesis, voice design, and controllable voice cloning.

Assisted-by: Codex:gpt-5.6 [Codex]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto e2ef7d050d feat(gallery): add Qwen3.8 9B Distill variants
Add the Q4_K_M and Q8_0 GGUF builds for a compact Qwen3.5-based reasoning and tool-use distillation. The variant pair lets LocalAI choose higher fidelity when the host has enough memory while keeping the Q4 build as the installable fallback.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto cfafd6cd36 feat(gallery): add Qwen3.8 NVFP4 MTP variant
Add a compact NVFP4 build with its embedded MTP head and vision
projector. This gives Blackwell-capable llama.cpp hosts a speculative
option alongside the existing Q4 and Q8 builds.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto bb4432d2f2 feat(gallery): add IndexTTS 2.5
Add the original-dtype GGUF for LocalAI\x27s audio.cpp backend. The entry exposes multilingual voice cloning and records the model license.\n\nAssisted-by: Codex:gpt-5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto 6a5895cd7f feat(gallery): add LightOnOCR 2 1B variants
Add the compact LightOnOCR model to the OCR section with Q8_0 and F16 builds. Each build uses its matching vision projector.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
Ettore Di Giacinto 7ec55db327 feat(gallery): add Supra2 100M Instruct
Add the official F16 GGUF build of the compact Apache-2.0 Qwen3-family chat model with its native 2K context limit.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
陈志谦 dd1776a91f docs: correct documented env var and CLI flag names (#11886)
- api-errors.md documented LOCALAI_SUBTLEKEY_COMPARISON (missing the
  KEY underscore); the code defines LOCALAI_SUBTLE_KEY_COMPARISON, so
  the documented variable silently did nothing
- cli-reference.md documented a --csrf flag / $LOCALAI_CSRF env that
  do not exist, with inverted semantics; the actual flag is
  --disable-csrf (LOCALAI_DISABLE_CSRF), 'Disable CSRF middleware
  (enabled by default)'
2026-09-05 23:49:10 +02:00
陈志谦 d9d68c595e docs: fix dead links in the PR template and backends docs (#11884)
- the PR template's 'Signed commits' anchor pointed at a CONTRIBUTING
  section that does not exist; repointed at the Commit messages
  section
- the longcat-video backend README linked a docs page that was never
  committed; replaced the dead link with plain text
- formal-verification/README.md used ../../../ for five in-repo
  packages (escaping the repo root); fixed to ../
2026-09-05 23:48:01 +02:00
Ettore Di Giacinto afe7741773 test(distributed): prove the busless cluster on two frontends and two workers
Tasks 1 to 17 are proven by unit and integration specs and by two e2e passes
taken mid-flight. This is the pass that boots the real binaries with every
carrier in place and none of the old one, and it does so on the topology the
feature was built for rather than on the one-worker shape the rest of the
cluster suite uses.

Two frontends and two workers is the configuration that matters. With each
worker's tunnel landing on a different replica, the owner path and the relay
path are live at the same instant against one roster, one scheduler and one
health monitor, so a routing mistake has somewhere to show up instead of
hiding. It is also the only shape in which "killing a replica re-homes only ITS
worker" can be stated at all.

Three scenarios, all 2x2:

1. Both workers served from both replicas. No broker as a property of the
   ARTIFACT (debug/buildinfo reports no github.com/nats-io module, with the
   module count asserted non-zero so a stripped binary cannot pass vacuously),
   no broker in either worker's live /proc environment, and no advertised
   address on either worker. One completion over the owner path and one over
   the relay, plus the mirror image through the other replica, plus four
   control-plane listings covering both paths for both workers.
2. The replica owning worker 0's tunnel is killed with that tunnel blocked.
   Leg 1 asserts nothing and only waits for the killed instance to leave the
   live set, because before that it still reads as a live owner and the
   scenario is not yet about absence. Leg 2 then holds a window inside the
   reconnect grace requiring that nothing acted on the absence. Leg 3 requires
   the re-home and inference again. Worker 1 keeps serving throughout.
3. The suite-wide negative control. Both tunnel dials refused while
   registration and heartbeats flow, both workers refused at both replicas
   naming the routing fact and not a departure, nothing reaped and both
   heartbeats fresh. Then ONE tunnel is restored and exactly one worker
   recovers while the other stays refused.

Which worker served is read back from node_models rather than assumed: the two
models are pinned to one worker each through PUT /api/nodes/:id/labels and
POST /api/nodes/scheduling, and every assertion requires the model to be on the
expected node AND absent from the other. That the relay hit a non-owner is read
from the production Owner query before the request and re-read after it.

Attacks run, each alone, each reverted, each behaving as predicted:
hand a worker a broker URL reddens scenario 1's environment leg; point the
module check at gorm.io reddens its artifact leg; start one worker instead of
two reddens all three at the topology guard; delete the relay in
WorkerDialer.Dial reddens scenario 1 on exactly the request sent to the
non-owner while 2 and 3 stay green; a one-nanosecond reconnect grace reddens
scenario 2's leg 2 on the demotion while 1 and 3 stay green; lifting both
blocks at scenario 3's differential reddens its "still unreachable" half. The
brief's "restore the NatsURL validation" attack cannot be applied:
DistributedConfig has no such field left to validate.

Label-orphan arithmetic, counting non-skipped It nodes from --dry-run:
all 256, dist 231, cluster 24, vllm 1, and 231 + 24 + 1 = 256, so no spec is
orphaned by the label filters.

Three test-e2e-cluster runs: 897.0s, 897.8s and 906.6s of Ginkgo time, 24 specs,
15 minutes wall. The only failure across the three was a pre-existing spec dying
at cluster.Start with frontend-1 exiting status 2, which passed in the other two
and is reported as a port-allocation flake rather than a regression.
test-e2e-distributed is 223 plus 8 specs in 131.7s. The budget comment and
.agents/building-and-testing.md move from 21 specs at 800 to 830 seconds to 24
specs at 897 to 907.

The harness gains ProcessEnviron, which reads /proc for any of the three process
families; WorkerEnviron and FrontendEnviron become wrappers rather than being
deleted, so the specs that call them are not re-aimed for a rename.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Opus 5 [claude-code]
2026-09-05 21:47:35 +00:00
陈志谦 24f897cd09 docs: fix dead anchors and a dead section link (#11885)
- middleware.md: the 'default detector' link used #instance-wide-defaults;
  the heading is 'Instance-wide default detector'
- the advanced/reference landing pages linked an ../installation/
  directory that does not exist in docs/content; dropped the dead
  bullets (deployment content lives under getting-started)
2026-09-05 23:47:13 +02:00
Abdullah Mansour | عبد الله منصور a98501d6ce docs(llama-cpp): clarify multimodal speculative decoding (#11700)
* docs(llama-cpp): clarify multimodal speculative decoding

Update the speculative decoding guidance now that modern llama.cpp backends can combine mmproj-based vision with speculative decoding, including MTP. Document compatibility checks, draft acceptance statistics, VRAM tradeoffs, and a combined configuration example.

Assisted-by: Codex:GPT-5.6-Sol [gh] [OpenStack] [Docker]
Signed-off-by: Abdullah Mansour <abdullahmansour.marketing@gmail.com>

* docs(llama-cpp): clarify multimodal MTP references

Distinguish the upstream change that removed the general multimodal speculative restriction from the later change that added MTP with explicit vision compatibility.

Assisted-by: Codex:GPT-5.6-Sol [gh] [Docker]
Signed-off-by: Abdullah Mansour <abdullahmansour.marketing@gmail.com>

---------

Signed-off-by: Abdullah Mansour <abdullahmansour.marketing@gmail.com>
2026-09-05 23:46:24 +02:00
dependabot[bot] b217148bbc chore(deps): bump torch from 2.13.0+xpu to 2.14.0+xpu in /backend/python/common/template (#11859)
chore(deps): bump torch in /backend/python/common/template

Bumps torch from 2.13.0+xpu to 2.14.0+xpu.

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 11:27:01 +02:00
localai-org-maint-botandmudler 32875a7089 chore: ⬆️ Update CrispStrobe/CrispASR to c6d4265ae2ee2b8931b09d7d25d5c65c75c36a41 (#11862)
⬆️ 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-09-05 11:26:30 +02:00
localai-org-maint-botandmudler 7ad4384cbe chore: ⬆️ Update ggml-org/llama.cpp to 427291b5b34cd914a31b3fd3b61a68f6184f4b9f (#11866)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-05 11:26:11 +02:00
dependabot[bot] ee2920d619 chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates (#11869)
Bumps the npm_and_yarn group with 3 updates in the /core/http/react-ui directory: [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node), [fast-uri](https://github.com/fastify/fast-uri) and [qs](https://github.com/ljharb/qs).


Updates `@humanfs/node` from 0.16.7 to 0.16.8
- [Release notes](https://github.com/humanwhocodes/humanfs/releases)
- [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node)

Updates `fast-uri` from 3.1.5 to 3.1.7
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

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

---
updated-dependencies:
- dependency-name: "@humanfs/node"
  dependency-version: 0.16.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: qs
  dependency-version: 6.16.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 11:25:54 +02:00
localai-org-maint-botandmudler 7ab7a3136b chore(model gallery): 🤖 add 1 new models via gallery agent (#11880)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-05 11:25:24 +02:00
localai-org-maint-botandmudler f09d6092ed chore: ⬆️ Update 0xShug0/audio.cpp to cd98afb3264044aeff33618df486c81de1bee7e7 (#11878)
⬆️ 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-09-05 11:24:25 +02:00
Ettore Di Giacinto 730d259976 chore(distributed): take the nats-io modules out of the build
Distributed mode has not dialled a message broker since the control plane
moved onto the workers' own outward tunnels and every fan-out family moved
onto PostgreSQL LISTEN/NOTIFY. What was left was the dependency itself, and
the code that existed only to feed it.

Dropped from go.mod: nats-io/jwt/v2, nats-io/nats.go, nats-io/nkeys,
nats-io/nuid and testcontainers-go/modules/nats, along with the fourteen
indirect requires that only the NATS testcontainer pulled in. go.sum carries
no nats line either, so the removal is not the partial kind where the require
goes and the checksum stays.

Deleted with them: pkg/natsauth in full, the broker client's remaining
options and TLS files, the per-node JWT minting on both the register and the
approve path, and the natsauth.Config parameter threaded through the node
routes. The credential manager is renamed and stripped rather than deleted,
because it still holds the tunnel token that every re-registration rotates.

The bus flags stay accepted and ignored, and are now hidden, on every command
that had them, so an existing unit file, compose file or Helm values file
still starts on the day of the upgrade. What is not kept is the validation
that REQUIRED one: a distributed frontend started with no bus URL is no
longer fatal. The TLS paths lose type:"existingfile" deliberately, so a
certificate deleted along with the broker cannot fail a startup.

One operator-visible behaviour change: --nats-require-auth no longer makes an
agent worker wait through admin approval. Ask for that wait with
--distributed-require-auth, which already implied it. It is documented in the
migration section and pinned from both sides.

A deployment now needs PostgreSQL and the frontends' own HTTP listener, and
nothing else.

coverage-baseline.txt moves from 54.2 to 62.0.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 03:27:17 +00:00
dependabot[bot] 3e98ec935a chore(deps): bump grpcio from 1.82.1 to 1.83.1 in /backend/python/common/template (#11858)
chore(deps): bump grpcio in /backend/python/common/template

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 01:03:02 +02:00
localai-org-maint-botandmudler 583992a76f chore: ⬆️ Update ggml-org/whisper.cpp to 52a939a2a762224e255d366c1182b2af4dd1a032 (#11877)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-05 01:02:43 +02:00
localai-org-maint-botandmudler 87d15e7108 chore: ⬆️ Update NVIDIA/NeMo-Speech.cpp to 69d7fd46b253043ed6649bf35d68407448912a8e (#11863)
⬆️ Update NVIDIA/NeMo-Speech.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-09-04 16:57:26 +02:00
localai-org-maint-botandmudler a742c87f76 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to 78ce63c90d36b94a765073e8cbe0d241fc114590 (#11865)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-04 16:53:28 +02:00
Ettore Di Giacinto d3dfad90b9 chore(distributed): stop telling an operator to run a NATS cluster
Every carrier had already moved and no process opened a bus connection, but
the surface an operator reads still described a deployment with a broker in
it: a compose service, a 220-line credential-generation script, two CI steps
pulling a container nothing started, two flag tables offering --nats-url, an
architecture diagram with a NATS box wired to the workers, a join-command
generator in the Nodes page that emitted --nats-url for agent workers, and a
test suite that stood a NATS server up for specs that no longer used it.

That is the one way this programme could still fail invisibly. Every test
passes, every binary works, and every production deployment goes on running
and paying for infrastructure that carries nothing.

Nothing in this repository starts a NATS server any more. The compose file is
four services, the docs say to shut the broker down and what to keep, and the
e2e suite runs on one PostgreSQL container.

The three LOCALAI_NATS_*_TIMEOUT env vars are KEPT, and are now documented
twice as being kept. They were never broker settings: each names a control-RPC
budget the frontend applies to a worker, still read and still enforced. They
carry the prefix only because they arrived with the bus, and renaming them
would break every existing deployment for cosmetics.

The agent worker's join command was the last surface still emitting the flag,
two tasks after the agent worker stopped dialling. The Playwright spec that
covered it asserted the opposite of what is now true, so it is inverted rather
than deleted, and it reads the rendered command string rather than the
component's variables: the variables are what the fix removes, so a spec
reading them would have stopped compiling instead of failing, and a compile
error is not evidence about what an operator is shown.

nats_jwt_test.go and its helpers are deleted. They pinned a real server
ENFORCING the minted permissions. The CONTENT of those allow lists is still
pinned, untouched, by pkg/natsauth's own suites, including the spec that
refuses to let the agent lists go empty, since an empty allow list in NATS
means unrestricted. The enforcement half is retired rather than moved:
enforcement is a property of a connection, and nothing opens one.

The suite's own NATS container goes with them, which the brief left for the
next task. Removing the pre-pull while BeforeSuite still ran the image would
have defeated the step rather than cleaned it up, and this change removes the
last reader of TestInfra.NC. agent_native_executor_test.go and
mcp_ci_job_test.go are moved onto infra.Bus() instead of deleted: they were
the last two specs building a bridge and a dispatcher on a client nobody uses,
which is exactly the drift TestInfra.Bus's own comment warns about.

cluster.Options.NatsURL is now fed a deliberately dead address rather than a
live container's. Frontends and agent workers still receive LOCALAI_NATS_URL,
because that is the coverage for the promise that an existing command line
still starts; sourcing it from a running server would have let a regression
that actually dialled it pass. The control in cluster_control_test.go keeps
its assertion and loses its explanation, which claimed the deployment had a
bus and no longer could.

One latent spec race surfaced and is fixed: the background-run spec waited for
a COUNT of events and then read a snapshot for the terminal status, which is
the last event of a run and therefore always arrives after the count is met.
Its immediate twin had already been fixed this way. Nothing in production
changed.

pkg/natsauth keeps its files. It is reachable from production only through the
natsauth.Config parameter thread, and that thread is the next task's.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-04 07:46:08 +00:00
localai-org-maint-botandmudler 1f2f931f53 chore: ⬆️ Update 0xShug0/audio.cpp to 8fe1ab805555c72aaf06d83828b523dfc6ed796d (#11861)
⬆️ 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-09-04 09:16:30 +02:00
localai-org-maint-botandmudler 5e6a83694d chore(model-gallery): ⬆️ update checksum (#11868)
⬆️ 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-09-04 09:00:41 +02:00
dependabot[bot] 5485a2e62b chore(deps): bump grpcio from 1.83.0 to 1.83.1 in /backend/python/vllm (#11853)
Bumps [grpcio](https://github.com/grpc/grpc) from 1.83.0 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.83.0...v1.83.1)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-version: 1.83.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 09:00:22 +02:00
dependabot[bot] 072e367df8 chore(deps): bump grpcio from 1.82.1 to 1.83.1 in /backend/python/rerankers (#11852)
chore(deps): bump grpcio in /backend/python/rerankers

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 09:00:00 +02:00
dependabot[bot] 8b31f5d6ad chore(deps): bump grpcio from 1.83.0 to 1.83.1 in /backend/python/coqui (#11856)
Bumps [grpcio](https://github.com/grpc/grpc) from 1.83.0 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.83.0...v1.83.1)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-version: 1.83.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 08:51:51 +02:00
localai-org-maint-botandmudler b35fdf0142 chore: ⬆️ Update ikawrakow/ik_llama.cpp to fe215a8ccdce6b844d2a3a3bbde08ae76a6284bf (#11864)
⬆️ 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-09-04 08:49:55 +02:00
Ettore Di Giacinto a6b2d7c0ec fix(distributed): read a departed agent tunnel as the routing fact it is
Task 4 gave agent workers tunnels and deliberately left the NodeType skip in
HealthMonitor.tunnelDeparted, with a spec asserting that an agent node whose
presence reader answers PresenceGone is NOT marked unhealthy. That spec was
scaffolding. It was true while an agent worker took its jobs and its verbs over
the message bus: a departure row for one said nothing about whether it could
work, and an early bug in the new tunnel client could otherwise have demoted a
fleet of healthy agent workers.

There is no bus. An agent worker is reachable through its tunnel and through
nothing else, so a departed agent tunnel means exactly what a departed backend
tunnel means: no live replica holds it, the departure has outlived the reconnect
grace, and that is a routing fact the scheduler and a reaper may act on. The
skip would now hide the only symptom an unreachable agent worker has. This is
the deliberate removal Task 4's M6 predicted, and task-4-report.md is where that
mutation already stands recorded red against the spec this commit deletes.

The skip existed at ONE site. router_liveness.go has none: its candidates come
from queries that already filter node_type = 'backend'. The two skips in
managers_distributed.go stay, because an agent worker still runs no backend
processes, so it has no backend to list and no backend op to apply.

Two node types can depart now, which is why the second half exists. Before this,
one type could depart and every per-node cache a departure left stale was
dropped from wherever its owner happened to notice, so a reader could not tell
which caches a demotion invalidated by reading the demotion path. Departure gets
ONE notification point. DepartureNotifier is edge triggered, because the monitor
runs on a ticker and a departed node stays departed; its subscribers are NAMED,
because what has to be caught is a forgotten cache and a count can say only that
one of four is missing; and NewHealthMonitor takes it as a required positional
argument, so a caller that does not pass one fails to compile.

Four caches subscribe: prefix-cache affinity in every model, probe freshness at
every address, in-flight staging operations, and the per-node breakdown of every
open gallery operation. The prefix-cache one is registered only when
prefix-cache routing is enabled, so --distributed-prefix-cache=false stays a
true no-op. The notification carries the node's name as well as its id, because
the staging tracker keys on the name and the other two key on the id, and a
subscriber should not have to read the registry from inside an eviction hook.

A departure notification is an act on absence, so it fires only on the routing
fact. A tunnel lost inside the grace, a worker that never dialled, a presence
query that failed and a stale heartbeat all announce nothing, asserted per node
type. The stale-heartbeat branch is excluded on purpose: it already marks the
node offline, which deletes its rows and runs the registry's replica-removed
hooks, so firing there too would double-evict and make the notification mean two
different things at its subscribers.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-04 06:33:14 +00:00
Ettore Di Giacinto 3b4858851a feat(distributed): carry an agent cancel on the worker's own tunnel
agent.<name>.cancel was the last family on a message bus, and the only
reason an agent worker dialled one. Its subscriber is the worker running
the execution, and a worker has no database, so the family could not move
to the PostgreSQL fan-out carrier: a cancel published there would reach no
worker while reporting that it had been sent.

It is a control verb now. An agent worker mounts workerctl.PathAgentCancel
on the loopback control plane behind its tunnel and applies the cancel to
the same registry the executor registers a run on. The frontend issues it
through nodes.AgentControlClient.CancelAgentRun.

That call is a FAN-OUT and not a pick, because nothing records which worker
holds a given execution: the claim row names the claiming replica, and it
is deleted when the run ends. Every agent worker a live replica can reach
is asked over its own tunnel, relayed by the peer mesh when a peer holds
it, and each worker answers only for itself.

The answers stay apart, which is why this family was held back. A cancel a
worker made is nil. A cancel some worker could not be asked is
ErrAgentCancelUndelivered, which is neither a refusal nor a missing run. A
cancel every reachable worker declined to own is ErrAgentRunNotOnAnyWorker.
A deployment with no agent worker is ErrNoAgentWorker. Neither new sentinel
wraps ErrWorkerUnroutable and neither is a worker answer, so nothing is
reaped, demoted or evicted because of a cancel.

A worker in the ABSENT CONNECTION condition, one whose tunnel was lost
inside the reconnect grace, counts as undelivered. It is not retried in the
call and not queued: a retry would spend a budget the caller did not
choose, and a queue would need durable state whose only consumer is a run
whose control stream went with the tunnel. A worker whose departure has
outlived the grace is the one routing fact a caller may act on and is
excluded, or a single retired agent node would make every cancel
undelivered for ever.

The fan-out reads a different node set from the pick. A draining worker
takes no new work but is still finishing what it holds, so it is offered
the cancel; a pending one is refused by the tunnel route on every dial and
is not.

With that, nothing in LocalAI connects to NATS. The agent worker's dial,
its credential ladder and its refresh loop are gone, and so is the
frontend's cancel carrier. LOCALAI_NATS_URL is accepted and ignored
everywhere, and distributed mode no longer requires it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-04 05:18:02 +00:00
Ettore Di Giacinto b45076c5f7 refactor(distributed): delete MessagingClient and shrink the NATS client to fan-out
Nothing in the tree publishes, subscribes, queue-subscribes or requests through
the MessagingClient interface any more, so it is deleted rather than shrunk to
Broadcaster: two exported names for one method set in one package is an
invitation for the next author to pick whichever the surrounding file already
imported.

    $ grep -rn 'messaging\.MessagingClient' --include='*.go' .
    core/services/syncstate/syncstate.go:54:  // It is messaging.Broadcaster rather than messaging.MessagingClient because

(one hit, a comment; no live referent. The naive grep in the plan also matches
prose and the local test type names fakeMessagingClient and
countingMessagingClient, so it can never be empty.)

*messaging.Client is shrunk to exactly Broadcaster plus its own lifecycle.
QueueSubscribe, QueueSubscribeReply, SubscribeReply, Request, Conn and the
package helpers QueueSubscribeJSON and RequestJSON go with it; none had a
production caller. Deleting the methods rather than only the call sites is what
makes putting a family back on this carrier a build error instead of a line that
compiles, publishes successfully, and is delivered onto a carrier the deployment
is being taken off. Conn is in that list because while it existed every other
name was one c.Conn().X() away; the flush-and-verdict that its real consumers
needed is now ConfirmRoundTrip, which keeps the NATS JWT permission specs armed.

The client, its options and its TLS plumbing are NOT deleted, and both processes
stay on the bus. agent.<name>.cancel is the one fan-out family that could not
move: its only subscriber is the agent worker, which has no database and cannot
join the PostgreSQL carrier at all, so a cancel published there would reach no
worker and be reported as sent. The frontend passes the client to
newFanoutBridges as its cancelCarrier and the worker subscribes on it, so
--nats-url stays required on agent-worker. Both go with the tunnel cancel verb.

The struct field is renamed Nats -> CancelCarrier to say what it is for, and
agentpool loses the messaging.Publisher it held only to be non-nil: it never
published on it, and it was gating whether a frontend runs agents distributed or
in an in-process pool. Retiring the bus would have flipped every replica back to
the in-process pool silently. The gate now reads the agent store, which is the
dependency the mode actually requires.

Also deletes four subject builders with no production publisher
(SubjectFineTuneProgress, SubjectFineTuneCancel, SubjectCacheInvalidateSkills,
SubjectCacheInvalidateCollection), the queue and request/reply halves of the
shared test double, and the e2e specs that were their only callers. Every
surviving subject is now pinned to its exact literal, because a subject is a
cross-version wire format and a rename that looks internal stops half a fleet
hearing the other half.

Docs: distributed-mode.md and cli-reference.md no longer claim NATS carries the
agent-worker job subjects, the frontend's cross-replica events, or an agent
worker's real work.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-04 03:51:06 +00:00
Ettore Di Giacinto 44cb495169 feat(distributed): move the last nine fan-out families onto PostgreSQL
Gallery progress and cancel, the operation cache's start and end, the model
and backend cache invalidations, staging progress, and the prefix cache's
observations and invalidations now travel on the LISTEN/NOTIFY carrier. No
subject is published or subscribed on messaging.Client anywhere in the tree,
which is what makes retiring that package a deletion rather than a migration:

  $ grep -rn 'natsClient\.Publish\|nats\.Publish\|\.Nats\.Publish\|QueueSubscribe\|SubscribeReply\|\.Request(' \
      --include='*.go' core/ pkg/ | grep -v _test \
    | grep -v 'c\.Request()\|ctx\.Request()\|Request()\.Context' \
    | grep -v 'core/services/testutil/fakebus.go'
  core/services/messaging/client.go:168,170,172,227,234,236,250,252,254,268,269,287
  core/services/messaging/interfaces.go:21,22,23

Every remaining hit is inside core/services/messaging itself. The production
reads of the NATS client are now three, all of them the documented agent-worker
exception: Close on shutdown, the agent pool's publisher, and the agent-cancel
carrier passed to newFanoutBridges.

Prefix-cache observations publish like every other family rather than through a
method that refuses a message too large for a notification. The plan proposed
such a refusal on the reasoning that a long prompt makes a chain of thousands of
entries; ExtractChain caps a chain at Config.MaxDepth blocks, MaxDepth is a
constant with no operator knob, and the chain reaching Sync.Observe has one
source, the router's own extraction hook. A worst-case observation is a few
kilobytes against an 8000-byte cap, so the hot-path spill the refusal was
designed to avoid cannot occur, and shipping it would have added the programme's
only deliberate message drop to guard a condition that cannot arise. pgbus gains
FitsInline instead, a predicate that shares one size decision with Publish and
decides nothing, and core/application refuses at startup to wire a prefix cache
whose configured depth would put every observation over the cap.

The carrier choice is no longer stated at four sites. StagingTracker.SetPublisher
and SubscribeBroadcasts become one SetBroadcaster, so a tracker that publishes
where its peers are not listening cannot be spelled; prefixcache.Sync gains
SubscribeBroadcasts, which reads the carrier it publishes on; and the gallery
service and the operation cache are wired by methods on DistributedServices that
name no carrier at all, so the NATS client beside it cannot be handed over.
OpCache.SetMessagingClient and GalleryService.SetNATSClient are renamed to
SetBroadcaster so a missed call site fails to compile.

Two pre-existing defects that the two-real-carrier specs surfaced are fixed. A
progress tick published before a cancel and delivered after it cleared Cancelled
and left the operation reading as still running on that replica; mergeStatus now
drops a stale tick rather than merging it. GetStatus and GetAllStatus handed out
the stored OpStatus pointer while the broadcast subscribers mutated it in place,
so an /api/operations response could be marshalled mid-write; both now copy.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-04 02:55:15 +00:00
Ettore Di Giacinto 5a95bb3c0a refactor(distributed): move job and agent fan-out onto the PostgreSQL carrier
Five of the six families whose subscriber is an open HTTP response rather
than a process-lifetime cache now travel on pgbus: jobs.<id>.progress,
jobs.<id>.result, jobs.<id>.cancel, agent.<name>.events.<user> and
responses.<id>.cancel. Both ends of each move together, so there is no
state where a publisher is on one carrier and its subscriber on the other.

agent.<name>.cancel does NOT move, and the plan was wrong about why. Its
only subscriber in the tree is the agent worker, which has no database and
so cannot join the PostgreSQL carrier at all. Publishing that cancel on
pgbus would have lost every cancel of a worker-run agent while returning
nil, which reports a cancel that reached nobody as a cancel that was sent.
EventBridge now names its cancel carrier separately, a frontend replica
sets it to the carrier the worker reads, and it stays there until a cancel
rides the worker's tunnel like every other verb addressed to a worker.

The carrier drops at 256 rather than blocking, which is not safe on its own
for a result: a lost result has no successor message. It is not the only
path. The claiming replica persists the terminal line before it releases
the claim, and an open progress stream re-reads the job row once after
subscribing and then periodically, so a dropped terminal broadcast costs
promptness and never the answer.

Both per-request subscriptions close in a defer instead of on one return
path, and pgbus grows Subscribers() so the leak they would otherwise cause
can be asserted. It has no other symptom: only the first subscriber of a
channel issues a LISTEN, so a leaked filter just adds one closure per
notification for every stream the replica has ever served. Subscribe now
issues its LISTEN before it registers, which makes that count a readiness
signal rather than a figure to compare against itself.

Two rules that were stated at several sites and pinned at none are now one
each. The re-broadcaster is built beside the dispatcher and the bridge and
handed to the dispatch loop, so no line is left that can point it at a
carrier nobody subscribes to while every spec stays green. The set of
statuses a job never leaves is one exported set that the SSE bridge and the
store both read. The last hand-written subject filter in production code
became messaging.SubjectAgentEventsWildcard.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-04 01:47:34 +00:00
Ettore Di Giacinto 245010f2f6 feat(distributed): carry the state.*.delta families on PostgreSQL
syncstate.Config held one carrier field typed as the NATS client, so a
pgbus.Bus could not be handed to a SyncedMap at all: it satisfies
messaging.Broadcaster and not MessagingClient. The durable re-hydration
path built for the responses map therefore had a NATS-only consumer and
nothing in the build said so.

The field becomes Bus messaging.Broadcaster, SubscribeJSON moves to its
own file and relaxes its parameter to Broadcaster, and the four adopters
fan out over PostgreSQL LISTEN/NOTIFY: fine-tune jobs, quantization jobs,
agent tasks with their per-tenant children, and Open Responses metadata.
A new spec proves it on a real database, over two Bus instances on two
pinned listener connections: a Set and a Delete carry, a payload past the
8000-byte notification cap comes back byte identical through the spill
row, two families sharing one LISTEN channel stay separate, and a
terminated listener re-hydrates a row written while it was gone.

The five sites that each chose a carrier for an adopter are collapsed
into one DistributedServices.Broadcast() accessor. Five field reads were
five chances to leave one family on NATS with nothing failing, because
messaging.Client satisfies Broadcaster too. The accessor also refuses to
hand out a nil pgbus.Bus wrapped in a non-nil interface, which every
adopter would read as "broadcast" and dereference on the first Set.
SetTaskSyncNATS and SetJobSyncNATS are renamed to SetTaskSyncBus and
SetJobSyncBus so a missed wiring site fails to compile.

The response metadata table gains a retention of its own, defaulting to
24 hours. It inherited the Open Responses store TTL, which defaults to 0
meaning no expiration. Zero is defensible for a map that dies with the
process and is not for a table: the table grew for the life of the
deployment and a restarting replica re-hydrated every response the
cluster had ever created. A row that names its own expiry is still judged
on that column alone, and "this row is dead" now has one SQL spelling
that PurgeExpired deletes by and ListUnexpired is the negation of, so a
hydrate cannot resurrect what a sweep has already retired.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 22:50:54 +00:00
Ettore Di Giacinto 09acb3823a fix(distributed): give one tenant's agent tasks a subject of their own
Every AgentJobService built its tasks SyncedMap with the name "agent.tasks",
and there is one service per user. So every tenant published on and subscribed
to the same subject, state.agent-tasks.delta, and SyncedMap.apply scopes
nothing: a task tenant A created was written into tenant B's in-memory map on
every replica, and ListTasks reads that map. Nothing repaired it short of a
process restart.

The subject now carries the tenant in a token of its own,
state.<name>.<tenant>.delta. Four tokens where the unscoped builder makes
three, deliberately: SubjectMatches compares token count before anything else,
so a tenant's subject and the cluster-wide one cannot cross-match, and neither
can two tenants. Putting the tenant inside the name token would not do that,
because the sanitizer folds '.' to '-' and the only filter that could then span
tenants is state.*.delta, which spans every other family too.

The rule is stated once. subscribeFilters calls publishSubject rather than
restating the subject, so a map cannot end up publishing scoped and subscribing
unscoped, which would leak exactly as before while every publish assertion
passed. The one case that decides on its own is the cluster-wide administrative
view: it hydrates from every tenant's rows, so it also takes the per-tenant
wildcard, or it would be stale the moment any tenant wrote. A tenant hydrates
from its own rows and applies only its own deltas. PerTenant defaults to false,
so finetune, quantization and the responses store keep the subject they have.

The second half of the same defect was the delete. taskStoreAdapter.Delete
called DeleteTask(id) and JobStore deleted by primary key with no user
predicate, reachable from DELETE /api/agent/tasks/:id, which takes the id off
the URL. A tenant who learned another tenant's task id destroyed that tenant's
row. The user id now travels with the id and lands as a user_id predicate.
Empty stays the administrative any-owner scope, the same thing an empty id
already means for ListTasks and ListJobs. A foreign delete removes nothing and
returns no error: not yours and not there are the same answer to the caller,
and neither is a store failure.

SetUserID rebuilds the tasks map for the same reason SetTaskSyncNATS does.
GetJobs happens to set the user id first, nothing enforced it, and with the
order reversed the map would be built with an empty tenant and put that user's
tasks back on the cluster-wide subject.

Both halves predate this programme; they are surfaced here rather than caused.
Neither is fully closed for a deployment with the agent pool off, where the
task routes are still served by one cluster-wide service that every
authenticated caller shares; that is a separate gap and it is documented.

testutil.FakeBus grew a real defect this was the first change to trip:
Unsubscribe matched on the filter string, so with two subscribers on one
filter, closing one deafened the other. Subscriptions now carry an id.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 21:54:42 +00:00
dependabot[bot] 99a2e855a8 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#11835)
Bumps the npm_and_yarn group with 2 updates in the /core/http/react-ui directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) and [browserslist](https://github.com/browserslist/browserslist).


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

Updates `browserslist` from 4.28.2 to 4.28.8
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.18.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 23:38:23 +02:00
Ettore Di Giacinto 43f7a5d108 feat(distributed): give responses.metadata something to re-hydrate from
The responses.metadata SyncedMap had no durable Store, so its reconnect
re-hydrate replaced nothing. That was survivable while responses converged
through deltas on a broker that mostly stayed up. It is not survivable on a
carrier whose listener is one pinned PostgreSQL session: every response created
while the subscription was down stays invisible on that replica forever, and the
symptom is a 404 from one replica and a 200 from another for the same
response_id.

State that must survive a gap now lives in a response_metadata table, and the
notification only says it changed. The map writes through on a Set and reads the
table on hydrate, on reconnect and on reconcile, so the gap closes instead of
becoming permanent.

The row carries the whole projection as JSON rather than one column per field. A
column-per-field schema would be a second definition of what a peer may act on,
and the two would drift the first time syncedResponse gained a field: the map
would broadcast the new field and hydrate without it, so a replica that had
reconnected would serve a different response body from one that had not, with
nothing failing anywhere. Only PayloadJSON is ever decoded; owner_replica and
owner are indexed copies for an operator reading the table by hand.

A missing row and an unreachable database are different facts. Every store and
adapter method returns a driver failure as an error and never as an empty
result, and syncstate replaces nothing when its source errors, so an outage
leaves the map holding what it had rather than blanking it into a cluster-wide
404.

Liveness is the database's clock, spelled expires_at IS NULL OR expires_at >
now(), because every replica hydrating from this table must agree on which rows
are live and a Go-side cutoff makes that a property of whichever process asked.
The test container shares the host clock, so no behavioural spec can tell the
two apart; the statement shape is pinned instead. The constructor refuses a
non-PostgreSQL handle, because an unguarded now() on the single-binary path
reads as a missing migration.

A ticker sweeps expired rows every five minutes on each replica, and Close waits
for it rather than racing it. Note that the sweep removes nothing while
LOCALAI_OPEN_RESPONSES_STORE_TTL is 0, which is the default: with no TTL nothing
ever expires and the table grows for the life of the deployment. The docs say so
plainly.

EnableDistributed takes the store positionally and last, so a call site that
forgets it fails to compile rather than silently restoring the deltas-only map
this change exists to replace. A nil store there is refused by name: it is
reached only from the distributed branch of route registration, so it is a
wiring bug and not a deployment shape.

What still never leaves the owning replica is unchanged: the resume buffer and
the CancelFunc. The write-through is one row per response state change, not one
per generated token.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 21:11:59 +00:00
localai-org-maint-botandEttore Di Giacinto 44de82e7c7 docs(dco): let maintainer-operated automation sign off (#11850)
The AI-assistant policy says an AI agent must never add a Signed-off-by
trailer, because only a human can certify the DCO. That is right for the
case it was written for: an assistant helping a contributor who then
signs off themselves.

It does not fit automation a maintainer runs. Those pull requests have no
human submitter, so nothing ever signs and the DCO check blocks them
permanently. Sixty-one open pull requests from the maintenance bot are in
exactly that state, every one of them correctly following the documented
rule.

Carve out the case: automation a maintainer operates signs off with that
maintainer's identity. The maintainer certifies the DCO, as they do for a
commit they typed by hand, because they configured the automation, own
its output, and take responsibility on merge. The Assisted-by trailer
still records that a model wrote the code, so provenance is unchanged.

Keep the exception narrow. An assistant helping an outside contributor
still must not sign off, and a bot must not sign for anyone but its
operator, including on a contributor's branch it pushes to.

Assisted-by: Claude:claude-opus-5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 23:08:38 +02:00
localai-org-maint-botandmudler 211487c7f3 chore: ⬆️ Update ggml-org/llama.cpp to 9cffdcc801582616250520966699cb5b25d28243 (#11845)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-03 23:06:10 +02:00
Ettore Di Giacinto 78f4ff7934 feat(distributed): dispatch queued work as a claim queue
The three NATS queue groups jobs.new, jobs.mcp-ci.new and agent.execute are
gone. Dispatching work is now a row in a work_claims table, taken by one
frontend replica with SELECT ... FOR UPDATE SKIP LOCKED and driven on an agent
worker as a streaming control RPC over that worker's tunnel. Exactly-one
delivery among competing consumers is a database problem, not a broker feature.
An agent worker has no database, so it never claims; it executes what the
claiming replica hands it.

A claim must not outlive the replica that took it. The reap releases a claim
whose owner is no longer a live replica in the instances table, on the database
clock, and never asks how long the claim has been held. A job that legitimately
runs for an hour on a heartbeating replica is left alone, while a claim whose
owner stopped heartbeating becomes claimable again within one liveness window.
A replica with no advertised address has no instances row at all, so it refuses
to claim rather than have its work reaped out from under it mid-run.

The settle rule is stated once, in settleClaim, and every exit path calls it. A
transport failure releases the claim and never completes or discards it; only a
decoded reply line completes it. That line is deliberately not
cluster.IsWorkerAnswer, which accepts the stream refusals a worker's tunnel
writes before any request body reaches its control server: completing on those
would discard work that never ran.

The terminal line is persisted before the claim is completed, so a store that
refuses leaves the claim standing rather than leaving the job running for ever.
That is the dropped-result defect fixed structurally rather than by retry.

This also surfaces a pre-existing gap rather than causing one: no worker has
ever served plain task jobs, and publishing them into an empty queue group left
them running with no trace. Such a claim is now failed with a reason.

Removes QueueWorkers, --agent-subject and --agent-queue, and narrows an agent
worker's minted JWT by agent.execute and jobs.mcp-ci.new.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 20:22:34 +00:00
Ettore Di Giacinto fcb93b128e feat(distributed): let a worker name a broadcast the frontend decides on
A worker has no database and cannot NOTIFY, and it does not need to: every
message it sends is produced inside a handler the frontend invoked, so
there is always an open control response to write on. This adds the two
ends of that, and the authorization decision that sits between them, and
nothing that dispatches yet.

workerctl.Envelope gains Subject, a REQUEST and not an instruction. Empty
means the line is for this caller alone, which is what every pre-existing
progress line is, and omitempty keeps those lines byte-identical for an
older reader. It qualifies a progress line and never a reply line: a reply
is the worker's verdict about the work, and there is no version of
"publish my verdict for me" this control plane has to carry.

nodes.MayBroadcast is the replacement for pkg/natsauth's allow list, and
the inversion is the point. NATS read an EMPTY allow list as NO
RESTRICTION, which is why phase 3 refused to delete the backend branch and
spelled it {"_INBOX.>"}. This one reads an empty list, and an absent node
type, as DENY EVERYTHING, and a table-driven spec pins that by emptying
the agent entry and asserting all three of its subjects are then refused.
Matching goes through messaging.SubjectMatches, the one definition in the
tree, so a filter that fires here fires on the carrier.

nodes.Rebroadcaster.Handle returns a bool and never an error. A refused or
failed re-broadcast is logged and the RPC continues, because the RPC's
outcome is the worker's verdict about the work and a publish failure says
nothing about it. The return shape is asserted at compile time in the file
that states the rule, so changing it to an error does not compile.

ControlClient.CallStreaming's progress callback now takes the line's
subject alongside its raw bytes. The client no longer decodes progress at
all: what a line means is a question about the verb and whether a named
broadcast may be made is a question about the node, and it knows neither.
Both moved into installProgressBridge, which every one of the three
streaming call sites in unloader.go goes through. A line naming a subject
is dropped there rather than delivered as install progress, because
backend.install and backend.upgrade are a backend worker's verbs and a
backend worker is allowed no subjects.

agents.StreamPublisher is the producing end, a messaging.Publisher writing
NDJSON envelopes onto an in-flight control response and flushing each one,
so a tick reaches the frontend while the handler is still running.
Serialized, because two concurrent encodes on one http.ResponseWriter
interleave bytes and tear the framing.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 17:31:13 +00:00
Ettore Di Giacinto e5ad2e18c4 test(e2e): build the cluster binary and drop a load-sensitive budget
Two things made every end-to-end result on this branch unreliable.

make test-e2e-cluster did not rebuild ./local-ai. It only checked that a
file by that name existed, so an edit that was never rebuilt ran the whole
suite against an older build while reporting on the working tree, and a
missing binary skipped every spec and exited 0 with "Test Suite Passed".
The target now depends on a new e2e-binary target, which is the plain go
build CI already used rather than make build, since that one also builds
the React UI this suite never touches. The harness carries the other half:
localAIBinary now FAILS, locally as well as under CI, when the binary is
older than the newest non-test Go source in the tree, which covers a run
started with LOCALAI_E2E_BINARY or by invoking ginkgo directly. Test files
are excluded from that scan because they compile into the ginkgo suite and
never into local-ai. The CI job drops its own build step and the env var so
that one place owns the build and it happens after protogen-go.

test-e2e-distributed was audited for the same hole and has none: ginkgo
compiles that suite from the working tree on every run and it execs no
prebuilt binary. build-mock-backend already rebuilt unconditionally.

"Worker tunnel under load" bounded the worst probe inside the bulk transfer
window against the worst probe under the empty-load window. A max over n
samples is a biased estimator when the two n differ, and here they always
do: the bulk window is by construction longer and draws several times as
many chances at an unrelated scheduling outlier. Anything loading the box
widens that gap, so the spec reddened on what else was running: 255ms
against a 161ms budget with make lint beside it, 57ms alone. It now bounds
the probe COMPLETION RATE instead, which is the statistic a serialised
session actually moves and a mean over dozens of samples in both terms, so
a uniform slowdown cancels in the ratio. Measured with make lint running:
0.97 direct and 0.60 relayed against a floor of 0.125.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 17:30:54 +00:00
Ettore Di Giacinto 9d7a2457d9 feat(distributed): stop a backend on one route, whatever the worker is
nodes.<id>.backend.stop was the last worker-facing NATS subject, and it existed
only because ONE publisher had not moved. An agent worker already mounted
workerctl.PathBackendStop on the tunnel it holds, and a backend worker already
took its stop there, so RemoteUnloaderAdapter branched on NodeType to pick a
carrier for a verb both kinds of worker served the same way.

The branch is gone, and with it nodeTypeOf and its NodeTypeBackend default,
which removes one of the ten NodeType branches left to sweep. The adapter loses
its messaging.MessagingClient outright rather than keeping an unused field: it
now holds no publisher, so re-routing any verb back onto the bus is a change to
the struct and to every caller of the constructor, and does not compile until
all of them agree. messaging.SubjectNodeBackendStop and subjectNodePrefix are
deleted, the agent worker's subscription with them.

pkg/natsauth drops the per-node backend.stop grant from the agent SUB list. That
is a narrowing of eleven entries to ten, never to nothing: NATS reads an EMPTY
allow list as unrestricted, so the coverage spec asserts both that the retired
subject is no longer covered and that the queue subjects an agent worker lives
on still are. The e2e half proves it against a real enforcing server: one spec
subscribes successfully on an agent-minted JWT, the next is refused the retired
subject on a JWT minted the same way.

Both halves of the old split were pinned, so both pins are re-aimed rather than
deleted, and the two node types are asserted separately rather than as one
parameterised case, because only two cases can show that the two used to differ.
Three assertions that the adapter published nothing are deleted instead: with no
publisher to hold, no change could ever redden them.

The CLI's handler set moves into agentWorkerControlHandlers so a spec can stand
it up and post to it. That wiring was a bare literal no spec pinned, and
deleting the subscription made it the ONLY carrier for backend.stop: a dropped
field would have been a 404 the frontend reads as a worker too old to serve the
verb, and nothing in the repo would have noticed.

Mutations: the agent branch restored off the control route reddens two specs;
the backend branch restored, separately, reddens five; PathBackendDelete in
place of PathBackendStop reddens nine across both node types; dropping the CLI
wiring line reddens the new wiring table; re-adding the allow-list entry reddens
the unit spec and the JWT e2e spec; and restoring the publisher for real does
not compile.

Four comments this change falsified are fixed, in core/cli, pkg/model and the
distributed-mode docs, which now say both kinds of worker serve
POST /v1/control/backend/stop and what each does with it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 16:41:59 +00:00
localai-org-maint-botandEttore Di Giacinto 7a234473e8 fix(ci): unbreak the e2e build and the darwin vllm-metal pin (#11849)
Two independent breakages on master make every open pull request red,
for reasons unrelated to the changes under review.

The e2e backend suite stopped compiling. Reply.message is `bytes` in
backend.proto, so res.GetMessage() returns []byte, and strings.ToUpper
wants a string. Every other call site in the file already converts.
tests/e2e-backends sits behind a build tag, so `go build ./...` never
compiled it and the breakage reached master unnoticed.

The darwin vllm build stopped resolving. Upstream vllm-metal deleted
its old dev tags and re-versioned to track the vLLM release it targets,
so the pinned wheel 404s. The coupled vLLM release also moved out of
upstream's install.sh into .github/vllm-release-tag.commit, and the
wheel's platform tag moved from macosx_11_0 to macosx_15_0.

Read the wheel name from the release's own asset listing rather than
composing it from a hardcoded platform segment, so a platform-tag
change cannot silently 404 again, and resolve the vLLM version from
the new metadata file with a fallback to the legacy installer. The
bump script and the extractor learn the same two-source lookup, so the
next nightly run converges on the pin checked in here instead of
reintroducing the break.

Assisted-by: Claude:claude-opus-5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 18:32:02 +02:00
Tai An 9901103aac fix(downloader): make file:// installs reachable again (#11701) (#11734)
fix(downloader): make file:// installs reachable again

DownloadFileWithContext already has a branch that copies from a local
file, but it could never run. Before reaching it the function decides
whether the destination is fetchable with

    } else if !os.IsNotExist(err) || !URI(url).LooksLikeHTTPURL() {

and LooksLikeHTTPURL is http(s) only, so any URI resolving to a local
path is rejected there. Falling through requires the destination to be
missing AND the source to be an HTTP URL, which a file:// source never
is -- leaving the local-source branch below unreachable.

A first import always has a missing destination, so importing
file:///path/to/model.gguf always failed, with an error that listed
file:// among the supported schemes (#11701).

Name the local-source condition once as URI.hasLocalSource and use it
both to admit the destination and to pick the source, so the two cannot
drift apart again.

Signed-off-by: Tai An <antai12232931@outlook.com>
2026-09-03 18:30:31 +02:00
Dimitris Karakasilis 8aeea4cdde fix(gallery): persist inference defaults where the loader reads them (#11232)
The recommended sampling parameters for a model family were applied at
install and then never took effect. Two things went wrong on the way to
disk.

They were written as top level keys. ModelConfig embeds PredictionOptions
under the "parameters" yaml key, so temperature, top_p, top_k, min_p,
repeat_penalty and presence_penalty are only read from there. At the top
level they parse without error and are then ignored for the life of the
model.

They were also merged in after the YAML had already been marshalled. The
only re-marshal sat behind the artifact binding, which an entry carrying
files: never reaches, so for those entries the defaults were computed and
then dropped before anything was written.

Neither failure was visible in normal use. ApplyInferenceDefaults runs
again at load time and fills the same values from the same table, so the
model ends up tuned correctly while the file on disk pins nothing. It
surfaces when someone edits one of those values expecting it to win, or
when a family is absent from inference_defaults.json and there is nothing
to refill from.

Both install paths are covered: an entry carrying files:, and one that
binds a primary artifact instead.

The empty base spec asserted that the authored parameters block landed
verbatim. It now checks the authored keys individually, because the family
defaults are merged into that same block.

Assisted-by: Claude:claude-opus-5

Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>
2026-09-03 18:30:26 +02:00
localai-org-maint-botandmudler 49945fdd75 chore: ⬆️ Update 0xShug0/audio.cpp to c18b7f737aac0a2855e9f963a427498739ad40fe (#11843)
⬆️ 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-09-03 18:03:20 +02:00
Ettore Di Giacinto 1a8384a8e1 test(distributed): name the replica endpoint the router now requires
Two specs in the distributed e2e suite have been red since 1cf847f29, which
stopped workers advertising an address and removed every fall-back to the
node's own endpoint. After that commit a replica row must name the loopback
endpoint of its own backend process: DistributedModelStore.Range refuses to
list a replica whose backend process is unnamed, and SmartRouter treats an
unnamed warm row as naming no process and cold-loads instead.

1cf847f29 updated the unit specs under core/services/nodes for the new
contract but not tests/e2e/distributed, and the phase's closing verification
ran test-e2e-cluster rather than test-e2e-distributed, so nothing reported it.
The task brief named six later commits as candidates and called 671785621
known good; both are wrong, and 671785621 fails these two specs as well.

The fix is the scripted input, not the guard. Both specs wrote an empty
address, which is a row this release cannot produce, since installBackendOnNode
refuses an install that names no address.

The dedup spec is repaired rather than merely un-reddened. With an empty
address its DB row was dropped by the unnamed-replica guard before Range ever
consulted the seen-set, so deleting the dedup check left it green: it asserted
nothing. With the endpoint named, removing that check reddens it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 15:32:23 +00:00
Ettore Di Giacinto 5effa47527 feat(distributed): make MCP execution and discovery a selection
mcp.tools.execute and mcp.discovery were the only NATS subjects that
combined a queue group with a reply, and no carrier in this design
provides both. They never needed one: a queue group is a way of choosing
a subscriber, and choosing is a query.

The frontend now lists the approved, non-draining agent nodes, asks the
node_connections table in one joined statement which of those tunnels a
live replica holds, prefers one this replica holds so the call skips the
relay hop, and issues an ordinary control RPC on the path task 4 already
mounted. A peer-held tunnel is reached through the relay. That is a
choice a broker's hidden balancing could not make.

The selection reads presence and nothing else. It is filtered only on
node type and on the two statuses an operator controls, never on a health
verdict written on another clock, because refusing a worker that is
connected and answering is the same defect as picking one that is gone.
An empty fleet answers ErrNoAgentWorker, which is deliberately neither
ErrWorkerUnroutable nor anything cluster.IsWorkerAnswer accepts: nothing
was asked of any worker, so no reap guard may act on it.

A reply carrying an Error is the worker's own answer and is returned
unchanged; it is never offered to a second worker, which would turn "this
MCP server rejected your arguments" into "the fleet is broken" and could
run a tool twice. A call that never reached a worker is retried against a
different pick, at most three times, and whatever error is finally
returned is returned unwrapped so its identity survives the loop.

MCP prompts and resources now answer 501 in distributed mode instead of
an empty 200. They are served only from sessions the frontend holds, and
in distributed mode it holds none. That gap predates the removal of the
bus and is not closed by it; this only stops it being silent.

Agent workers keep every other subject, including nodes.<id>.backend.stop.
Their minted JWT loses the two MCP subjects and keeps a non-empty allow
list, because NATS reads an empty one as no restriction at all.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 12:04:41 +00:00
Ettore Di Giacinto 64059cd7d7 feat(distributed): give agent workers a tunnel of their own
Phase 2 gated agent nodes out of tunnel credentials at the mint site.
That was right while nothing dialled into an agent worker: a credential
would have replaced nothing, and the gate was structural rather than a
second check that could drift. It is wrong now that the frontend needs to
reach an agent worker by RPC.

attachTunnelToken mints for backend and agent nodes and CLEARS for
anything else, through one tunnelEligible predicate rather than two
conditions that can be widened separately. ConnectHandler still never
reads NodeType, so an empty hash is still what refuses an ineligible
node.

An agent worker now starts a loopback control server behind the same
bearer check a backend worker uses, and holds one tunnel whose only
stream tag is http: it runs no backend processes, so the grpc tag has
nothing to route to and is not offered. Its MCP tool, MCP discovery and
backend.stop verbs are served from ONE implementation reached by both the
bus and the tunnel, so a frontend cannot get different bytes depending on
which carrier delivered.

The tunnel is an ADDITION. --nats-url is still required, and agent jobs,
MCP execution, MCP CI jobs and nodes.<id>.backend.stop all still travel
on the bus.

Absence semantics are unchanged. An agent node now has a real
node_connections row whose departure ages past the grace, so the node
type check in HealthMonitor.tunnelDeparted stopped being an optimisation
and became the rule; its comment says so, and the spec that pins it is
shown red under a mutation that deletes the check. The scheduler needed
no change: every placement query already filters node_type = backend, so
an agent node never reaches nodeMayTakeWork.

Shared rules moved to one site each. The request bounds, the POST-only
check and the unknown-path 404 live in workerctl and are called by both
worker packages; the bearer check that guards every extra route is one
function in core/services/nodes used by both server constructors.
workerctl.AllPaths splits into BackendPaths and AgentPaths, with AllPaths
as their deduped union, because a backend worker does not mount the agent
verbs and asserting otherwise would fail a correct worker.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 11:22:38 +00:00
Claudio Maradonna 9e831d7709 fix(ds4): build CUDA kernels for the target architecture (#11840)
* fix(ds4): build CUDA kernels for the target architecture

The ds4 backend compiled its CUDA objects with no -arch. Upstream's Makefile
leaves CUDA_ARCH empty and its `cuda` target refuses to build without one,
offering `cuda-spark` (sm_121) and `cuda-generic` (native) instead. We invoke
its object targets directly, which bypasses that guard, so nvcc fell back to
its default architecture and the kernels ran as JIT'd PTX on the real GPU.

On GB10 (sm_121) that silently corrupted inference: any prompt over roughly 128
tokens produced text unrelated to the input and never closed its thinking
block, so content came back empty and the chat showed only reasoning; longer
prompts failed with "cuda decode failed". It also cost close to two orders of
magnitude of prefill throughput. Measured on one box, same model, same prompt,
same GPU, upstream ds4 at the pinned commit, differing only in the nvcc flags:

  make -B ds4      (archless, as we build it)   garbage output    4.21 t/s
  make cuda-spark  (compute_121a/sm_121a)       correct output  325.70 t/s

Select an architecture list from CUDA_MAJOR_VERSION, which the backend matrix
already declares for both ds4 cublas entries but Dockerfile.ds4 never forwarded.
Upstream's CUDA_ARCH takes a single value, so it cannot express the fat binary
these images need; NVCC_ARCH_FLAGS is overridden instead, since a command-line
assignment wins over its `:=`. The lists are copied from vllm-cpp rather than
invented so the two CUDA images cover the same GPUs, with l4t/arm64 covering
Orin, Thor and GB10. An empty CUDA_MAJOR_VERSION keeps upstream's `native`
behaviour for local developer builds, and no CI runner has a GPU to enumerate.

DS4_CUDA_HAVE_MXF4 is deliberately left unset: upstream defines it only for
single-arch sm_120/sm_121 builds and guards it with a plain #ifdef rather than
__CUDA_ARCH__, so it cannot be combined with older archs. It gates an optional
MXFP4 indexer fast path whose #ifndef branch returns 0 and falls back cleanly,
so omitting it costs speed on GB10, not correctness.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>

* test(ds4): cover the multi-batch prefill regression

The architecture fix has no automated guard: every existing e2e spec uses a
short prompt, and the miscompiled backend answered short prompts correctly.
The corruption only appears once a prompt spans more than one prefill batch,
so the whole suite passed against a backend that produced garbage in normal
use.

Add an opt-in "long_prefill" capability to the backend e2e suite that sends a
prompt well past one batch with a known needle and asserts the answer still
reflects it, and document in the ds4 guide why the build must never omit an
nvcc architecture, how to check which flags a configuration resolves to
without compiling, and how to run the new spec.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>

---------

Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>
2026-09-03 13:03:44 +02:00
Claudio Maradonna 335acce21f fix(ds4): cancel abandoned inference (#11822)
Propagate gRPC cancellation into DS4 prompt synchronization and poll it at decode boundaries.

Stop on failed stream writes and skip parser finalization and KV persistence for abandoned partial requests.

Assisted-by: Codex:gpt-5.6-sol

Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>
2026-09-03 13:02:33 +02:00
localai-org-maint-botandmudler e9ba60ba57 chore: ⬆️ Update CrispStrobe/CrispASR to ff3945c94cab9191199a5d531a32c4e9535c094b (#11829)
⬆️ 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-09-03 13:02:00 +02:00
localai-org-maint-botandmudler 09f42db913 chore: ⬆️ Update NVIDIA/NeMo-Speech.cpp to 56b60d432f1731d6d5b28a4c5a31cbaf871daba1 (#11846)
⬆️ Update NVIDIA/NeMo-Speech.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-09-03 13:01:45 +02:00
localai-org-maint-botandmudler 1f4de9c809 chore: ⬆️ Update ikawrakow/ik_llama.cpp to caf7eae5282d840d77e9f91a56df7d2ef28fa612 (#11842)
⬆️ 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-09-03 13:01:32 +02:00
Ettore Di Giacinto 49128cf486 feat(distributed): keep the listener draining and let it come back
PostgreSQL holds undelivered notifications in a queue it shares with every
session on the server, and it kills a listener that stops draining. Two failure
modes follow, and both are silent: a carrier that blocked on a slow resolver
would lose its connection and with it every later broadcast, and a carrier that
reconnected without re-registering would be connected and deaf.

The receive and dispatch halves were already separate. What was missing is
everything around them. The listener path moves into listener.go and gains a
carrier-level Dropped() so a replica that is behind can be seen; the queue depth
and the spill retention become Config fields with exported defaults; the LISTEN
session gets an application_name so an operator can count listeners in
pg_stat_activity and a spec can drop exactly one of them; and OnReconnect fires
after the re-LISTEN, on a goroutine of its own, because a callback re-hydrates
from a database and must never run on the path whose only job is to drain.

That callback is reached through an optional interface assertion, so deleting
its invocation compiles and every adopter silently stops converging. The spec is
the only guard, and it is named in a comment at the site.

The slow consumer is proved through the transport rather than a seam: an ACCESS
EXCLUSIVE lock on bus_messages stalls the resolver's spill SELECT for exactly as
long as the spec holds it, and the listener is shown still draining and dropping
while it does. The dropped connection is a pg_terminate_backend matched on the
carrier's own application name.

Neither Dropped nor IsConnected is on messaging.Broadcaster, and a spec asserts
that over the interface type. Both are facts about a frontend; the conditions a
scheduler acts on are facts about a worker, and no consumer holding the
interface can read one as the other.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 10:23:40 +00:00
Ettore Di Giacinto 8f71c08d94 fix(distributed): order LISTEN and UNLISTEN on one lock
Unsubscribe decided a channel had lost its last subscriber under one
lock and issued the UNLISTEN after releasing it. A Subscribe on the same
root could decide to LISTEN in that window, and the two reached the
connection in the wrong order: the root ended up not listened with a
live subscription on it. It does not heal, because the next Subscribe
sees the registration already there and never re-LISTENs, so the whole
root stays deaf on that replica until the connection drops.

The decision and the statement it implies now happen under one lock,
held across both, at both call sites. A second lock and not the
registration lock: issuing waits on the listener goroutine, delivery
takes the registration lock, and holding that across the wait deadlocks
the carrier.

The race is spec'd through a barrier seam rather than by racing
goroutines. The natural window is microseconds wide, and a spec that
waits for it to open passes by luck; the seam scripts the interleaving,
so the spec decides in both directions.

Resolving a spilled message moved off the listener. PostgreSQL keeps
undelivered notifications in a shared, fixed-size queue, so a listener
that stops draining it can block COMMIT for every publisher on the
server, not only this one. The listener now only drains; one resolver
goroutine reads the row back and dispatches, which also keeps a spilled
message and an inline one on the same subject in the order they were
published.

Three wiring lines that could be deleted with the suite staying green:
the sweeper's start is now pinned by a Config interval, and the two
lines that carry the bus into the deployment now refuse to boot when
either is missing. A subscription can also report what it dropped, so
the party that missed a message is the party that can see it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 07:45:56 +00:00
Ettore Di Giacinto cf619fc91b feat(distributed): carry fan-out on PostgreSQL LISTEN/NOTIFY
Distributed mode needs an operator to run a NATS cluster. This adds the
carrier that replaces its fan-out half, so a deployment eventually needs
PostgreSQL and its own HTTP listener and nothing else.

pgbus holds one PostgreSQL session per replica, pinned for the life of
the process because LISTEN registrations belong to one backend session
and a pooled handle would lose them on the next checkout. Publishes go
out on the pool with pg_notify.

Subjects map onto a channel by their first token, from a closed set of
roots. A subject outside the set is refused at publish AND at subscribe
rather than mapped to a channel of its own: a channel name is capped at
63 bytes, and one LISTEN per job id would be unbounded. Refused rather
than dropped, because a subject that goes nowhere and reports nothing is
the class of defect this work exists to remove.

PostgreSQL refuses a notify payload of 8000 bytes or more, and several
subjects on this bus exceed that in normal operation: a job result
carries a whole LLM output, a gallery progress event carries one entry
per node. Those are written to a row and the notification carries the
id. What is measured against the cap is the ENCODED notification, not
the caller's payload, because the subject and the envelope travel too.

The filter grammar is not respelled here. Subscribe asks
messaging.ValidFilter and delivery asks messaging.SubjectMatches, which
makes this the first production caller of a matcher that had only test
doubles. New refuses a DSN that names a different database from the
pool: that pairing publishes successfully, delivers nothing, on every
replica, and reports no error anywhere.

Nothing publishes on it and nothing subscribes yet. The construction is
wired anyway, because the DSN has exactly one legitimate source and a
setting that decides whether any broadcast is delivered should not be
invented by whichever call site is migrated first.

Delivery is at-most-once, like NATS core. Nothing downstream may read a
message it did not receive as evidence about a node: a carrier that
cannot deliver is not a worker that is gone.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 06:28:03 +00:00
Ettore Di Giacinto cbdd495850 refactor(distributed): one subject matcher, shared by the carrier and the doubles
Three copies of "does this filter match this subject" lived in the tree: one
in testutil.FakeBus, a byte-identical second inside galleryop's own private
fakeBus, and a third in pkg/natsauth with different semantics. The first two
are doubles the specs publish through, and the carrier the pgbus work is about
to add needs the same rule in production. Two spellings drift, and the drift
reads as a peer that receives an event on one replica and not on another.

messaging.SubjectMatches is now the only definition either double uses. The
natsauth copy stays: it matches a NATS server allow list, so it has to
implement the '>' tail wildcard this one deliberately refuses, and Task 16
deletes that package anyway.

'>' is refused rather than implemented because no surviving subscription uses
it, and a caller who writes one must get no messages rather than silently
getting every message on the prefix. The refusal is checked BEFORE the
filter == subject fast path: a verbatim port checks equality first, and then
the filter "a.>" matches the literal subject "a.>", which is the contract
leaking. One table row pins that ordering and it is the only row that does.

messaging.ValidFilter refuses an empty filter, a '>' filter and an empty token
so a subscriber is told at subscribe time instead of staying silently empty for
the life of the process. FakeBus.Subscribe calls it, which is what keeps the
double honest about what the carrier will do, and three new testutil specs pin
that wiring: the previous state of the tree had no spec at all that failed when
the double's wildcard routing was replaced by exact matching in any package the
plan named.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 06:02:20 +00:00
Ettore Di Giacinto 6717856212 test(distributed): prove phase 3 end to end, under real processes
Five cluster specs that run the binaries an operator runs, plus the repair
of eighteen specs phase 2 left red.

The eighteen were router_tracking and full_flow, failing since 1cf847f29 on
"reported backend installed but named no address for the process". Two
contracts had changed under them: an install reply that names no
worker-local address is refused rather than substituted, and a frontend with
no worker dialer reaches no backend at all. Nobody noticed for a phase
because phase 2 verified with --label-filter='Cluster', which excludes both
suites. ServeBackendLifecycle and tunnelBackendClients state both facts once
for every spec.

The transport double is the part that matters. It translates a refused
connect into cluster.ErrStreamTargetUnavailable, which is what a real worker
answers when its backend process has died and what IsWorkerAnswer lets a
reap guard act on. A bare ECONNREFUSED reaches those guards as "no route" and
reaps nothing, so a double returning the raw syscall error could never fail
the way production fails; putting it back reddens the stale-record spec and
nothing else.

The new specs cover: a backend worker with no bus URL in its /proc environ
registering, being scheduled onto and serving inference; a backend install
and a backend listing driven through the replica that does NOT own the
worker, with the owner read through the production Owner query and re-read
after; that install's progress proven to arrive before its terminal reply,
made deterministic by a gallery server that holds the worker's fetch open so
a reply cannot exist yet; a worker whose tunnel is genuinely gone, waited for
rather than assumed, losing nothing inside the reconnect grace and re-homing
after; a heartbeating worker with a permanently dead tunnel losing its
healthy status while an agent worker in the same cluster keeps it; and the
suite's negative control, where a control RPC to a tunnel-less worker fails
naming the missing route, reaps nothing, and succeeds the moment the tunnel
returns.

Every scenario was attacked. The churn one was WRONG on the first attempt
and only the attack found it: its hold window sat entirely inside
cluster.InstanceLiveness, so a killed replica still read as a live owner
throughout, presence was "connected", and the spec passed with the reconnect
grace set to a nanosecond. It now blocks the tunnel before the kill and waits
for the ownership row to actually empty. Attacks that redden the rest:
posting at the owner, writing the install reply before the work, collapsing
PresenceReconnecting into PresenceGone, removing the non-backend node-type
guard, and not blocking the tunnel. Agent workers turn out to be protected
twice over; no single mutation reaches them.

Harness: Options.AgentWorkers and Options.ReconnectGrace, WorkerEnviron
(read from /proc, because Cmd.Env is the harness agreeing with itself),
NatsURL, FrontendBackendsDir, AgentWorkerName, PostJSON, and a node String()
so a failing roster assertion is readable instead of several hundred bytes
rendered as numbers.

Budget: 20 specs at 787 to 808 seconds over three runs, up from phase 2's 591
to 612. --timeout goes to 30m so a loaded runner reports a cause rather than
a spec name.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 03:03:53 +00:00
Ettore Di Giacinto dd9aff58ff feat(distributed): take the backend worker off NATS entirely
A local-ai worker no longer opens a bus connection. connectNATS and its
spec are gone; Run registers once, starts its tunnel, arms /readyz on that
tunnel, and heartbeats. The worker's bus credential flags (--nats-jwt,
--nats-user-seed, --nats-require-auth, the three TLS flags) and
Config.NatsAuthRequired go with it. --nats-url stays, accepted and
ignored, so an existing worker command line still parses.

/readyz was the thing most likely to wedge a tunnel-only worker: it
required a live NATS link, so a worker with no bus would have reported
itself unready forever. nodes.NATSReadiness becomes nodes.TunnelReadiness
over a local interface{ Connected() bool }, and worker.Tunnel gains
Connected(), backed by a mutex-guarded session field the loop publishes
and clears. A closed-but-not-yet-cleared session reads as disconnected:
the loop waits for every in-flight stream before it clears the field, and
the probe must answer not-ready through that wait.

The heartbeat gate is DELETED rather than re-pointed at the tunnel. The
heartbeat is the worker's own answer that its process is alive; whether
the frontend can reach it is a separate fact the frontend already holds
and ages against LOCALAI_WORKER_RECONNECT_GRACE. Withholding the
heartbeat would report an unreachable worker as an absent one on the one
path with no grace, where the health monitor marks it offline and its
pending backend ops are deleted behind it. heartbeatLoop is given no view
of the tunnel, so a gate cannot be added back without changing its
signature.

Removing the NATS credential manager from this path also removes a defect
it carried: its refresh loop re-registered on a timer to renew a JWT, and
Register CLEARS a node's NodeModel rows. Any backend worker running on
frontend-minted credentials had its replica rows deleted roughly every
18 hours.

Of core/cli/workerregistry, everything survives. The manager is still
used in full by core/cli/agent_worker.go, which still needs NATS: Acquire,
Provider, RefreshLoop, HasCredentials and TunnelToken are all untouched.
The backend worker simply calls RegisterFullWithRetry directly now.

WorkerPermissions is documented as serving agent nodes, and its non-agent
branch narrowed to _INBOX.> on both sides. It is NOT deleted: NATS reads
an empty allow list as no restriction, so returning nil would upgrade
every JWT the frontend still mints for a backend node from its own inbox
to the whole account.

Agent workers keep the bus everywhere: their CLI flags, their
subscriptions, the agent branch of WorkerPermissions, and the compose
service with its LOCALAI_NATS_URL and depends_on: nats.

Also corrected two flags the Nodes page advertised that do not exist
(--distributed-nats, --distributed-db), and a log line plus several
comments that still named a bus the code no longer touches.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 01:14:29 +00:00
Ettore Di Giacinto 749cc7ad81 fix(distributed): let absence be decided by something, at all three call sites
Removing "Presence: clusterRegistry" from the options literal in
initDistributed left all seven suites and tests/e2e/distributed green.
The predicate was right and its input was silently nil, which returns the
deployment to absence being decided by nothing, with no log line and no
failing request. That is the fourth finding of this exact shape in this
phase.

The two assignments move out of a twenty-field literal into
distributedSchedulerOptions, a named function a unit spec can reach.
Deleting either is now red. The health monitor takes its presence reader
and grace as a required positional pair instead, so deleting those does
not compile at all. requireAbsenceWiring then refuses to start a
distributed frontend whose scheduler or health monitor has no source of
absence, because refusing to boot is the only symptom either failure has.

With a fresh heartbeat and a permanently gone tunnel there was no reaper
at all. A heartbeat says the worker's supervisor is alive; it says
nothing about whether anything here can reach that worker's backends,
because those are reached over the tunnel. A proxy that stops upgrading
WebSockets, a rotated registration credential or a reconnect loop longer
than the grace left a node listed healthy forever while every request for
a model already loaded on it failed "no route to that worker", and every
reaper keyed on the heartbeat. The health monitor now reads presence from
the same place and against the same window as the scheduler and demotes
such a node. That also ends the 15s re-promotion: the demotion arm
returns before the recovery arm, so the scheduler's demotion is no longer
undone on the next tick, and recovery needs the tunnel back rather than
just the heartbeat.

The demotion is status-only. MarkOffline would DELETE the node's rows,
and deleting rows on a presence read would give any future defect in that
read the widest blast radius in the system for nothing the demotion does
not already deliver.

LRU eviction is the third path that commits work to a node, and it read
only the stored status. A node full enough to be an eviction target is
exactly the node the VRAM and idle selectors never offer, so
pickReachableNode structurally cannot cover it. It now runs its chosen
node through the same nodeMayTakeWork predicate, demotes it and evicts
again rather than handing back an install that cannot land. Presence is
read after the transaction and not inside it: reading it inside would
hold a FOR UPDATE lock across a query needing a second pooled connection,
which is how concurrent evictions deadlock a pool.

Also: a router built with a presence reader and no grace now has its
documented default pinned by a spec rather than only claimed by a
comment; ageDeparture asserts RowsAffected, since an UPDATE matching
nothing succeeds and the inside-the-grace spec returned the same verdict
either way; the scheduler comment that still described the bus is
corrected; the docs stop conflating heartbeat recovery with tunnel
recovery and name the third reader; and an overlong rewrapped line in
membership.go is folded.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 00:33:21 +00:00
Ettore Di Giacinto 93af91419b feat(distributed): read worker absence from the database, not from a bus timeout
The scheduler decided whether a worker had gone away from
nats.ErrNoResponders: one frontend's observation that nobody answered IT
within a request budget. Two replicas asking in the same moment could
disagree and demote each other's workers, and a worker re-homing its
tunnel between replicas looked identical to one that had died.

SmartRouter now reads cluster.Presence instead. Only PresenceGone -- no
live replica holds the tunnel AND the departure has outlived the
reconnect grace -- excludes a node from placement, and it is a fact every
replica reads identically from the database. PresenceReconnecting,
PresenceUnknown and a failed presence query are all non-verdicts and
place work as normal: excluding on a database hiccup would cost the fleet
its capacity for a reason that has nothing to do with any worker.

nodeAnswersOnBus is deleted. It excluded on a sentinel no control RPC can
produce, so it decided nothing while PingNode cost a relayed round trip
per scheduling decision to feed it. PingNode goes with it, from the
adapter and from NodeCommandSender. isRequestTimeout drops nats.ErrTimeout:
every verb this adapter sends now travels over the worker's tunnel.

The predicate is named nodeMayTakeWork rather than nodeHasRoute. "Route"
is ErrWorkerUnroutable in this package, the condition nobody may act on;
PresenceGone is the one a scheduler may. Spelling them the same way is
the collapse this work exists to prevent.

Also folds in ReapStale's return rename: it counts connection rows
CLEARED, never rows deleted, and reading it as a delete count would make
a worker that is re-dialling right now look forgotten.

The spec pinning that a message merely quoting "nats: timeout" is not a
timeout was scripting a SUCCESSFUL reply carrying the phrase, which comes
back with a nil error and never reaches the classifier. Restoring the
string match left it green. It now scripts a 5xx whose body carries the
phrase, and asserts that the phrase reaches the classifier as a
precondition.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 22:28:49 +00:00
Ettore Di Giacinto 4c3e0deb19 test(distributed): pin the unreadable-request rule at all three file verbs
The rule "a body this worker could not parse is a non-2xx, never the
worker's answer" is written at three exits in control_files.go and only
ensure was pinned. Turning stage's or listdir's decode exit into a
200-with-error left worker and nodes entirely green, and what that
converts is a frontend's malformed request into the worker's own verdict
about a file, which passes cluster.IsWorkerAnswer and reaches a reap
guard. The production code was already right; nothing held it there.

The e2e NATS JWT spec was asserting the opposite of the code and passing.
It published nodes.<id>.files.in and called it an allowed subject after
that grant was deleted, and it could not tell: a permission violation
does not close the connection, so FlushTimeout and IsConnected both stay
happy. It now reads LastError, the way its sibling always has, and
asserts the denial plus the one publish right a backend worker has left.

Also pinned, each mutation-verified alone: the CreateTemp branch (an
existing staging-tmp at 0500 reaches it without a seam), the walk's
context check (a caller that gave up must fail the listing, never be
answered with a short one), and the cache and data directory layout.

The data directory was derived twice, once in worker.go and once for the
listdir verb; worker.go now reads the same helper, so a move cannot leave
a verb listing files the file server does not serve. The per-verb RPC
ceiling moves from an argument at five call sites into fileRPCBudget,
so no site can name the wrong one, and the two values are asserted.

The body-cap table now holds both directions locally and with two
different claims: a body exactly at the cap proves the bound is a
ceiling and not an off-by-one, and an absolute megabyte proves the cap
stays above real gallery traffic. Only the second notices a cap shrunk
to 64 KiB.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 21:54:04 +00:00
Ettore Di Giacinto 7fc617c8ed feat(distributed): serve file staging over the worker tunnel
The four nodes.<id>.files.* subjects were the last commands a
serve-backend worker took off the bus. They are now HTTP routes under
workerctl.Prefix, on the same loopback server and behind the same bearer
check as the ten lifecycle verbs, so the frontend reaches them through
the worker's tunnel.

files.listdir is the verb this matters most for. Its reply had to fit a
payload the bus would carry, which put a wide model directory close to
the limit; a response body has no such ceiling, so nothing truncates the
listing at either end. A short listing reads to the frontend as files
the worker does not have.

S3NATSFileStager becomes S3FileStager and calls ControlClient, which
means every failure now lands in the bucket phase 3 exists to keep
straight: a route this frontend could not use is unroutable and nothing
may act on it, while the worker's own answer, including "that file is
not there", is evidence a caller may act on. Each RPC's deadline is
DERIVED FROM the caller's context rather than started fresh, at every
one of the five call sites, so a caller that gave up stops the RPC too.

A worker started without an object store mounts no file verb at all and
answers 404, which is the same answer a build too old to know them
gives. The subjects and the backend worker's files.> publish grant go
with them; a backend worker now publishes nowhere but its own inbox.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 21:15:19 +00:00
Ettore Di Giacinto 5880f4e6dd test(distributed): pin the no-demotion rule at every call site it is stated
Review fix round 1. Two blocking findings and seven non-blocking; both
blocking ones are M12's shape again, and this time on the invariant
itself. No production behaviour changes here: everything below was
already correct and merely unpinned, so re-inserting the defect left all
679 specs green. The only non-comment edits are one struct-field comment
and one log message.

"A failed control RPC no longer demotes a node" is stated three times in
this package and was pinned once, at ListBackends. Putting MarkUnhealthy
back at either op-drain site passed. What that buys in production is the
fleet-wide eviction this phase exists to prevent: MarkUnhealthy removes a
node from ListDuePendingBackendOps AND from scheduling, so a frontend
replica that has just lost its tunnels demotes every node it holds an op
for, for a reason that is about the frontend. The reconciler's is the
worse of the two, being a background loop nobody is watching. Both now
have a spec, each with the recorded op failure as its negative control so
"still healthy" cannot pass by nothing having happened.

The sweep the review asked for found four more rules stated at more call
sites than they were pinned at, and two the review had not:

The still-installing surfacing at the manager layer has two call sites
and was pinned at InstallBackend. Dropping it from UpgradeBackend
reported a spent budget as GREEN SUCCESS: the admin sees the upgrade
finished while the worker is still re-pulling gigabytes.

The agent-node skip has two call sites and was pinned at ListBackends.
Without it the fan-out enqueues a row for every agent node, and an agent
worker serves no control plane, so that row can never drain: it retries
until the dead-letter cap.

The still-installing conversion has three call sites and was pinned at
two; the legacy force-install fallback was the gap. Its budget was
unpinned too, so the new spec asserts both, on the upgrade budget rather
than the install one, since the fallback re-fires an install as part of
an upgrade.

The carrier split has two call sites and was pinned at one. Hardcoding
NodeTypeBackend in UnloadRemoteModelContext passed, and an agent node
holding a node_models row would then have its stop sent over a tunnel it
does not hold, fail, and leave the row behind. The new spec unloads a
model held by one node of each kind and asserts each stop went to that
node's own carrier and to no other.

router_nats_liveness_test.go asserted demote-on-absence, which production
can no longer produce, and its header described the pre-cutover world.
The exclusion is unreachable by construction rather than by argument:
cluster, the package supplying every control-path dial error, does not
link nats.go at all. The file now says that, and gains the assertion that
IS load-bearing, a table naming each sentinel a control RPC can answer
with and requiring that none of them excludes. Widening the exclusion to
ErrWorkerUnroutable reddens four of its entries plus the real-adapter
scheduling spec.

unroutable keeps no budget-first guard and the reason is now written at
it: unlike controlFailure it reads one already-recorded error rather than
racing a live deadline, and an expiry is not in streamRefusals, so it
falls to the umbrella without one. The two implement the same split at
two layers and each now names the other.

Fourteen comments still described the bus. Among them the reconciler
saying a drain would "churn NATS every tick", a spec comment naming a
subject builder this branch deleted, and the agent-skip comment
explaining the skip by a subscription that no longer exists.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 20:45:01 +00:00
Ettore Di Giacinto 44f12b2adb feat(distributed): call the worker's control routes instead of the bus
The ten backend and model lifecycle verbs stop being NATS requests and
become HTTP calls on the worker's own control routes, reached through
that worker's tunnel on the `http` stream tag that already carries file
staging. Nine subject builders and the per-op install-progress subject
are deleted with their entries in the worker's NATS permissions; the
request and reply DTOs are untouched, so a body on the wire is byte for
byte what the subject carried.

This closes the merge gate Task 3 left open, which was worse than lost
commands. Once the worker stopped subscribing, PingNode was still asking
nodes.<id>.backend.list and nodes.<id>.models.running, so EVERY healthy
worker answered no-responders, nodeAnswersOnBus read it as absence and
pickReachableNode demoted it on the scheduling path. PingNode is a
control RPC now, and no control RPC can produce ErrNoResponders, which
is the only error that exclusion acts on. Two specs drive
pickReachableNode against a real adapter and a worker answering over its
control plane, which is the only arrangement that can see the difference:
the router's own double never touches a transport and stayed green for
the whole window the defect was live.

How a control RPC FAILS is the whole of this change, so it is decided in
ONE function reading ONE table. A worker's answer passes through
unwrapped, so cluster.IsWorkerAnswer still sees it and a reap guard may
act on it; everything else is wrapped in ErrWorkerUnroutable so nothing
can. There is no third branch, because a third branch is how the eight
collapses on this branch happened: each was a site that decided for
itself which errors were evidence. A 404 under the prefix is its own
sentinel, because it is the worker stating a deployment fact about
ITSELF rather than a verdict about a backend, and only the legacy
upgrade fallback may act on it.

The caller's budget is checked FIRST. A timeout is not a verdict: a
refusal arriving in the instant a deadline expires would otherwise be
reported as the worker's non-transient answer, which reaps a row, and
nothing orders the two timers.

A 5xx and an undecodable body are transport failures, not answers. An
empty ModelsRunningReply means "this worker is running nothing", which
the reconciler acts on, so it must never be manufactured from a body
that would not parse. A stream that ends before its reply line is the
same rule one layer up: a tunnel dying mid-install is not the worker
saying the install failed.

backend.stop is split by node type rather than moved. Agent workers hold
no tunnel, so they have no control plane to serve, and they still
subscribe to nodes.<id>.backend.stop to drop cached MCP sessions; that
subject and its agent permission both survive. It is the honest
intermediate state until agent workers hold tunnels too.

A failed control RPC no longer demotes a node anywhere. ErrNoResponders
meant "not on the bus"; a control failure means "this frontend could not
route to it", which is equally what a healthy worker re-homing its tunnel
between replicas produces. Absence is a fact read from the database, and
the scheduler starts reading it in a later task.

The rolling-update fallback re-fires a DESTRUCTIVE force-reinstall, so
it runs only on the worker's own 404. Its negative direction was pinned
at the admin call site and unpinned at the reconciler's, where widening
the condition to any error left all 676 specs green: a background drain
nobody is watching would then force-reinstall every queued backend the
moment a replica lost its tunnels. Three specs cover it, arranged so the
force install IS reachable in the negative case and a fallback that
fired would show as a call and a drained row.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 19:43:53 +00:00
Ettore Di Giacinto f9d0d4c5c6 fix(worker): pin the rune cut, answer unload honestly, drop the dead publisher
Review fix round 1. Seven non-blocking findings; the blocking one is a
merge gate for Task 4 rather than anything in this diff, and the report's
concern about it is corrected: until Task 4 lands, PingNode probes two
subjects no serve-backend worker subscribes to any more, so every healthy
worker reads as absent and is marked unhealthy on the scheduling path.

The rune-boundary cut in truncate was true behaviour with nothing
holding it: a byte-wise mutation survived all 201 specs. isRuneStart is
replaced by utf8.RuneStart, the same predicate the cluster package uses
for this rule, and two specs pin it, one with a rune straddling the
bound and one with a rune ending exactly on it so the fix cannot be
"always walk back".

unloadModel answered Success:true whatever Free did. That is the worker
saying "done" about work it did not do, and the frontend's only caller
is EvictLRU, so a false yes told the scheduler VRAM had been released
and let it place the next model on a node still holding the old one. It
now reports the failure, following stopModelExact, which is the honest
pattern already in this package. Still a 200: the worker answered, only
its verdict is negative. An address with nothing loaded still answers
success, which is a true answer rather than a claim about work done.

NewDebouncedInstallProgressPublisher had no production caller after the
last commit, only its own spec. Deleted rather than wired: wiring it
would publish every event on two carriers, which is what the carrier
decision exists to avoid. Its specs now run against the sink, plus one
that pins the identity stamped on each event, since the subject used to
carry the op and node id and now nothing but the body does.

The install progress wiring was exercised by no spec, because with no
gallery nothing ever invokes the download callback. The guard moves into
startProgress, shared by install and upgrade, which also emits one
resolving event before any gallery work. That is worth having on its
own: a cold install spends minutes on a manifest and a progress stream
with nothing on it is indistinguishable from a broken one. It also makes
the wiring observable end to end, and four specs now drive the real
installBackend and upgradeBackend over HTTP with no override.

model/stop and backend/stop keep taking Background rather than the
caller's context, and the sites now say why. model/stop is the
acknowledged stop path: it reserves the process, frees it, kills it,
waits for exit and releases the port, and abandoning that because the
caller hung up would leave a process marked stopping, a port not
returned to the allocator and a row nothing reconciles. In
stopBackendExact the Free is a courtesy before a kill that happens
anyway. model/unload differs because Free IS the operation there.

A route set with no prefix or no registrar is now a startup error rather
than a silent no-op: a server that comes up healthy while every route
the caller registered answers 404 is, through a tunnel, indistinguishable
from a version skew. And the AllPaths spec no longer claims to catch a
constant that was never added to the set, which it cannot; it asserts
the whole set instead, which catches a verb dropped from it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 16:21:41 +00:00
Ettore Di Giacinto 49b3d22974 feat(worker): serve the control plane over the tunnel, not over NATS
Ten NATS subscriptions on the worker become ten HTTP routes under
/v1/control/, served on the loopback HTTP server the worker already runs
and reached only through the tunnel's existing `http` stream tag.

The carrier is the tag that already exists rather than a new one. A new
tag would have had to invent correlation, per-request deadlines,
unbounded payloads and a progress stream, and each of those is a place
this branch has already put a defect. It would also have added a fifth
entry to the worker's stream-refusal vocabulary, which decides what a
frontend reaps on and took eight fixes to settle. Riding `http` means a
control RPC to a worker another replica holds takes the same relay the
inference path takes, which is the path that has been measured.

The request and reply DTOs are untouched, so a body on a control route
is byte-for-byte what the corresponding subject carried. No subject was
deleted: agent workers still subscribe to nodes.<id>.backend.stop.

Install and upgrade stream. They answer application/x-ndjson: zero or
more {"progress":...} lines carrying the same event the per-op NATS
subject carried, then exactly one {"reply":...} line, always last. That
deletes the 8000-byte notification cap structurally instead of
reproducing it on a new carrier: a progress line is written into the
response the caller is already reading, so there is nothing to size and
no subscribe-before-request window. The debouncer is shared with the
NATS publisher rather than forked, so the ~4/s tick bound is one fact.

A verb's own failure is a 200 with Error set, never a 5xx. The frontend
maps a transport failure onto "no route to that worker", which nothing
may act on, and the worker's answer onto evidence a reap guard may act
on; answering 500 for a failed install would put the worker's verdict
in the bucket reserved for a broken link. Only a request that could not
be read or routed is non-2xx.

Control RPCs carry the caller's budget. r.Context() replaces four
context.Background() calls at the gallery-install sites, and the one
pre-existing fixed timeout on model.unload is now derived from the
caller's context so a shorter budget is honoured. No timeout is invented.

The inner `go func()` in the install and upgrade handlers is deleted
rather than nested: it existed because one subscription served every
install, and over HTTP each request already has its own goroutine.
Per-backend serialization stays lockBackend, which is what actually
prevented two requests racing the gallery directory.

Bounds against a boundary the worker now serves: every body is capped at
8 MiB before any decode; the 404 echoes at most 128 bytes of the request
path, cut on a rune boundary so a half rune cannot travel downstream as
a replacement character; non-POST is refused before the body is read so
a probe cannot fire a command; the streaming responses set nosniff.

The routes mount through nodes.AuthenticatedRoutes, which hands the
registrar a private mux and puts the whole prefix behind the same
constant-time bearer check as the file routes. The worker's HTTP server
now takes the supervisor as a required parameter, so there is no way to
start it without the control plane mounted.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 15:51:58 +00:00
Ettore Di Giacinto 6e50f060a3 fix(cluster): pin the derived retention to the sweep that applies it
The retention a worker's departure is kept for is now derived from the
reconnect grace, so a purge can never outrun the window Presence measures
against. Nothing pinned that. The sweep could be reverted to pass the constant,
or the setter emptied out, and the suite stayed green either way: the specs
covered the arithmetic helper, and the fix is the wiring.

The loop now has a spec of its own. It departs two workers either side of the
difference between the floor and the derived retention, and the row that must
go is what witnesses the sweep running at all, so the row that must stay cannot
survive by nothing happening.

The default grace goes from 60s to 90s. Two of the worker's ceiling backoffs is
60s, but the failed dial between them costs its handshake timeout too, which
puts the worst case at 70s, and the backoff resets only after a session long
enough that a replica accepting a dial and then dying denies it. So the ceiling
is reachable exactly during the rolling restart this window exists for, and 60s
sat on the edge of it. Too short reports a live worker as gone and costs a
model reload; too long reaps a dead one later. The cheaper mistake is the long
one.

A held row whose owner is dead and whose stamp is stale is the state a rolling
upgrade actually produces, and it was the one state no spec built. It has an
answer now, and the two ways to get this wrong land either side of it: reading
the stamp first says gone, reading held-ness without the liveness join says
connected.

Two comments claimed more than the code did. There IS a grace at which a live
worker is reported as gone, which is the point of it being a duration; and the
switch that reads held-ness first is only a partial second gate, since with the
SQL gate gone and a dead owner it answers gone rather than reconnecting.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 15:21:04 +00:00
Ettore Di Giacinto 6b4ce58207 feat(cluster): answer presence with four values on the database clock
A worker whose tunnel is gone is not, by that fact, a worker that has left.
Absence is what makes the scheduler stop placing work, reap the worker's rows
and evict its models, and one of those paths runs during inference, so the
deployment needs to tell a worker re-homing between frontend replicas from one
that is really gone before anything acts.

Registry.Presence answers that in one joined statement, with four values and
not a boolean: unknown when there is no row at all (this package cannot tell a
worker that has never dialled from one whose departure aged out, and must not
guess), connected while a live replica holds the tunnel, reconnecting while the
departure is inside the grace, and gone once it is older. Only the last is a
verdict a caller may act on.

Held-ness is asked FIRST and the departure only refines it, in the SQL and
again in the switch that reads it. Every writer here clears disconnected_at in
the statement that writes the owner, but that is a property of these writers
rather than of the table: a replica running a binary from before the column
existed re-claims without clearing the stamp, so during a rolling upgrade a
held row carries an old departure, and a read that consults the stamp first
reports a connected worker as gone for the whole upgrade.

Both windows are computed by the database, for the reason every other window in
this package is: they are compared across replicas, and replicas disagreeing
about whether a worker is gone is the flapping this branch exists to remove. No
behavioural spec can see the difference, since the test container shares the
host clock, so the statement shape is pinned instead.

The grace is an operator's knob, defaulting to twice the worker tunnel's
maximum reconnect backoff. That made the fixed departure retention wrong: an
operator raising the grace past it gets a purge that deletes departures before
the grace elapses, so a worker that is gone reads as unknown forever and
nothing ever reaps it. The retention is now derived from the grace, with the
old constant as its floor.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 14:44:50 +00:00
Ettore Di Giacinto d99f7ff7ab fix(cluster): pin the departure retention to the loop that applies it
The membership tick's call to PurgeDepartedBefore was the only production
wiring this change introduced, and removing it left the suite green. A
retention nothing applies is a departure that never ages out, which is
the state the sweep's held-ness filter exists to make reachable at all.
A spec now ages a released row past DepartedRetention on the database
clock, starts a real Membership, and waits for the row to go.

Release and Deregister leaned on "no owner id is ever empty" to avoid
touching an already-departed row, which is the accident Owner refuses to
lean on. A departed row keeps its epoch and carries an empty owner, so a
release or a deregistration naming an empty id matched it and stamped a
fresh departure over the old one, making a worker that left long ago look
like one that has only just gone. Both now filter on connectionIsHeld.

The comment on DisconnectedAt claimed a held row never carries a
departure. A binary from before this column existed claims without
clearing the stamp, so a rolling upgrade produces exactly that row. The
comment now says what holds, and says to ask held-ness first and read the
stamp second.

The sweep's vocabulary follows the code: it records departures where the
comments still said it deleted rows, and its log line separates the
instance rows it deleted from the connection rows it left behind.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 14:26:38 +00:00
Ettore Di Giacinto 49e77447fb feat(cluster): record a departure instead of erasing the connection
Releasing a worker tunnel deleted its node_connections row, so "this
worker's link dropped a moment ago" and "this worker has never connected
here" were one observation: no row. Nothing above could tell a worker
re-homing between replicas from a worker that is gone, and any grace
period built on top would have had nothing to measure from.

The row now survives a departure. Release clears owner_instance_id and
stamps disconnected_at on the database clock; the membership sweep and
Deregister do the same for every connection a dead or departing replica
held; Claim clears the stamp in the same upsert that writes the owner, so
a reconnect is never observed half-applied. PurgeDepartedBefore deletes a
departure once it is older than DepartedRetention, and the membership
tick owns that schedule.

Owner and OwnerRow report a departed row as ErrNoConnection, through the
one predicate connectionIsHeld, the way instanceIsLive is the one
predicate for replica liveness. This change records the departure and
does not interpret it: how long ago it happened is nobody's answer yet.

The sweep only clears rows that are still held. An empty owner is in no
instance's id, so without that filter every heartbeat would restamp every
departed row and no departure could ever age out.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 12:03:55 +00:00
lei_lei 7c921aa020 fix(ui): omit empty system prompt so model default applies (#11838)
Web Chat Settings left the System Prompt field empty but still treated a
blank/whitespace value as an explicit system turn. That satisfied
tokenizer chat templates' messages[0].role == system check and suppressed
the model YAML system_prompt on fresh chats.

Omit empty/whitespace system messages in the React and Alpine UIs, strip
them server-side, and inject config.SystemPrompt for tokenizer-template
models when the request has no real system turn.

Fixes #11834

Signed-off-by: lei_lei <96427312+leilei3167@users.noreply.github.com>
2026-09-02 11:58:23 +00:00
localai-org-maint-botandEttore Di Giacinto 9afe10ba21 fix(distributed): survive a slow control-plane database (#11837)
* fix(distributed): evict only when a node is known to be full

scheduleNewModel asked the registry for a free replica slot and treated
every error as "this node is full", so a control-plane database slow
enough to time out the lookup evicted a healthy loaded model. The
evicted process died, a peer frontend still holding its address dialled
the dead port and retried, and the model thrashed between nodes. The
comment on the branch already said it meant a full node; the code never
tested for it.

Evict only on ErrNoFreeSlot. Any other error now returns and names the
lookup that failed, so a slow database degrades into a diagnosable
load failure instead of into lost work.

An audit of the rest of the router found one branch of the same shape:
node selection discarded the error from its last-resort finder, so a
database timeout there also produced a nil node and evicted for it.
That path now returns unless the finder said gorm.ErrRecordNotFound,
which is the only answer that means the cluster had no node to give.
No other destructive branch in router.go fires on a generic error.

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

* fix(distributed): checkpoint heartbeat writes instead of writing every beat

Every heartbeat UPDATEd backend_nodes. Six nodes at a ten second beat is
roughly 52,000 writes a day against a six-row table, and that churn is
what turned a blocked autovacuum into a 460 MB table whose six-row scan
cost 867 ms and timed out the queries that place models.

A beat carrying only a fresher timestamp now waits for the checkpoint
interval. Each reported field is compared against the value last
persisted rather than tested for presence, because a worker sends its
disk figures on every beat and presence alone would suppress nothing.
A node's first beat, a changed total VRAM, total disk or GPU vendor,
and a free VRAM, RAM or disk reading that has moved more than 256 MiB
from the persisted value all still write at once. A node that is not
active is never suppressed, because it recovers only when the health
monitor sees a fresh timestamp.

The persisted column is up to one interval stale by design, so the
stale-node threshold moves from 60s to 5m to cover it.

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

* fix(distributed): fail worker readiness when a held backend is unreachable

The readiness gate tracked only the NATS link, so a worker whose backend
processes had died still answered /readyz with 200 and kept receiving
loads. One node did exactly that during an incident: it reported healthy
while its backend port refused connections, and every load routed to it
failed.

Readiness is now the NATS link and, for each backend process the worker
believes it is running, a short dial of its recorded address. A worker
holding no backends stays ready, because idle is a healthy state.

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

* fix(distributed): keep a starting backend out of the readiness dial set

A backend process is inserted into the supervisor map with its gRPC
address already recorded, but the address refuses connections until the
gRPC server binds, which the startup poll allows up to 30 seconds for and
which takes 10 to 15 seconds on a slow node. The new data-path readiness
probe dialled that address straight away, so a worker answered /readyz
with 503 for the whole of every cold backend start. The container
HEALTHCHECK absorbs that, but a Kubernetes readinessProbe at 10s does
not, and the worker would leave rotation each time it loaded a model.

The skip for a stopping process had no counterpart at the other end of
the lifecycle. Backend processes now carry a serving flag, set where the
startup health-check gate succeeds, and the probe dials only processes
that are serving and not yet stopping. backendStartStillValid becomes
markBackendServing: the check and the mark must share one lock hold, so
the flag can only ever land on the entry the key currently owns.

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

* feat(distributed): export control-plane database health gauges

Four transactions wedged on a corrupt index held the vacuum horizon open
for 42 days. Nothing measured it, so the first symptom anyone saw was
models failing to load six weeks later, by which time a six-row table
had grown to 460 MB.

Export the oldest xmin age, the longest open transaction, and the dead
tuple ratio on the registry tables. The first is the number that would
have caught it: it sits near zero in health and was 21,002,291.

Sampling is scrape-driven behind a cache, and a failed sample reports
the last good values rather than failing the scrape, because these
gauges matter most when the database is already struggling.

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

* fix(distributed): rate-limit failed control-plane database samples

The cache advanced its clock only on a successful sample, so once the
database started failing every scrape retried the query immediately.
That turned the cache off in the one regime it exists for: a retry
storm at scrape cadence aimed at a database already in trouble. A
catalog read that consistently exceeds the 5 second timeout also paid
that cost on every scrape, with all scrapes serialised behind the
sampler mutex.

Time every attempt rather than every success, so failures and timeouts
cost the same interval as good samples. Whether a good sample exists
moves to its own field, keeping the gauges absent until the first
success and holding the last good values through later failures.

Also note in the runbook that pg_stat_activity cannot see prepared
transactions or replication slot xmins, so a healthy-looking xmin age
does not by itself rule out a blocked horizon.

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

* test(distributed): pin that a failing database evicts nothing

Exercises the real distributed stack against a control-plane database that
refuses the router's slot lookup, and asserts the scheduler reports the
lookup it could not answer instead of falling through to eviction.

The failure is injected with privileges rather than a statement timeout. A
timeout set with ALTER DATABASE also breaks AutoMigrate, and it leaks into
every later spec in the suite unless it is reset, so the spec would end up
testing the migration rather than the scheduler. Instead the spec creates a
dedicated login role, points a second gorm handle at it, and revokes that
role's SELECT on node_models.replica_index. This has to be a separate role:
the test container's owner is a PostgreSQL superuser, and superusers bypass
every privilege check, so revoking from CURRENT_USER is recorded and then
ignored.

The revoke is scoped to one column on purpose. Revoking the whole table
would also blind node selection, which runs first and has a guard of its
own, so the scheduler would never reach the slot lookup this spec is about.
Leaving every other column readable lets selection succeed and lands the
refusal exactly on NextFreeReplicaIndex, which plucks replica_index. The
grant is restored from BeforeEach via DeferCleanup, so a failing assertion
or a panic cannot hand the next spec a role that cannot read.

Reverting the eviction guard fails this spec, which is the point of it: the
router then reports "no replica slot on keeper and eviction failed" for an
error that was never evidence the node was full. The surviving-row
assertions are secondary under this injection, because the eviction path
reads whole node_models rows and the same revoke blinds it too; a comment
in the spec says so, so nobody mistakes them for the load-bearing ones.

Also documents why the vector store and the control plane must not share a
database: the removable-tuple cutoff is per database, not per table, so one
transaction left open anywhere stops autovacuum reclaiming the node
registry, and a six-row table bloats into hundreds of megabytes. The note
names LOCALAI_AUTH_DATABASE_URL and LOCALAI_AGENT_POOL_DATABASE_URL as the
two knobs that must differ, and the localai_control_plane_oldest_xmin_age
gauge as the way to see it coming.

grep for StaleNodeThreshold and HealthCheckInterval in
core/config/runtime_settings_registry.go returns no matches: the
distributed duration knobs are not exposed as runtime settings, so the new
heartbeat checkpoint interval follows them and needs no registry entry.

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

* fix(distributed): close the review gaps in the heartbeat and health path

The stale-node threshold moved from 60 seconds to 5 minutes in this branch
because checkpointing makes last_heartbeat up to one checkpoint interval
behind by design. Two things were left inconsistent with that. NewHealthMonitor
still fell back to a hardcoded 60 seconds when handed a zero threshold, so any
future caller that stopped passing the configured value would mark every
healthy, beating node offline on every cycle. And the threshold itself had a
flag-name constant but no AppOption, no CLI field and no env binding, so an
operator who widened --node-heartbeat-checkpoint had no way to widen the
threshold to match. The fallback now tracks config.DefaultStaleNodeThreshold,
and --stale-node-threshold / LOCALAI_STALE_NODE_THRESHOLD is wired the same
way its sibling is.

Heartbeat suppression compared the RAW reported free VRAM against the
snapshot, but the column persists capAvailable(raw, ceiling). On any node with
a VRAM budget set, whose actual free VRAM oscillates above that ceiling, every
beat looked material while the persisted value never moved: suppression was
defeated on exactly the nodes an operator had configured, and the write
amplification this branch exists to remove came straight back there. The
comparison and the snapshot now both hold the capped figure, so they measure
the same quantity as the column.

Fixing that needs the ceiling, and reading it cost a SELECT on every beat,
including suppressed ones. The skip decision therefore moved ahead of the
updates map and now reuses the ceiling cached on the last durable write, while
the write path still re-reads it before capping anything. A ceiling that
changed inside the checkpoint window can cost one extra or one late write; it
cannot persist a wrong figure. A suppressed beat now costs no query at all.

Also: the operations section now says to grant pg_read_all_stats to the
LocalAI role, because PostgreSQL blanks backend_xmin and xact_start for
sessions owned by other roles, and the transaction that wedged the horizon in
the incident was a co-located vector store connecting as a different role, so
without the grant the new gauge sees only our own sessions. The compose
healthcheck comment now describes readiness covering the backend data path,
and the control-plane gauge registration records the otel.SetMeterProvider
ordering it depends on.

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

* fix(distributed): resolve the gauge's table names through gorm

The dead-tuple gauge queried pg_stat_user_tables against a hardcoded list
of three table names. Those three do not agree on where their name comes
from: BackendNode and NodeModel take gorm's default pluralisation, while
GalleryOperationRecord overrides TableName, and gallery_operations
already had a constant of its own that the list duplicated.

A literal list keeps compiling after any of that moves, and the query
then matches nothing. The failure is silent and it points the wrong way:
a dead-tuple ratio that matched no rows reports the same numbers as a
cluster with no bloat, so the gauge would look healthiest exactly when it
had stopped working.

Ask gorm what each model is stored as instead, which follows a TableName
override and the default pluralisation alike. A spec pins that the
override really is consulted: naive pluralisation of the type would give
gallery_operation_records, so the resolution cannot quietly stop asking
the model.

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-09-02 12:37:12 +02:00
Claudio Maradonna 30e53f8d9f fix(ds4): enforce generation boundaries (#11821)
Clamp requested generation to the usable context after prompt sync while preserving the legacy 256-token fallback for omitted limits.

Constrain each speculative MTP cycle to the remaining request budget so accepted tokens cannot advance beyond the visible output limit.

Assisted-by: Codex:gpt-5.6-sol

Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>
2026-09-02 12:36:10 +02:00
localai-org-maint-botandmudler 38d12f1ba4 chore: ⬆️ Update mudler/vllm.cpp to 6bf3abb580982f4fd2e4525ef37802ee0ce28981 (#11828)
⬆️ 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-09-02 10:54:51 +02:00
localai-org-maint-botandmudler 35cfb4f433 chore: ⬆️ Update 0xShug0/audio.cpp to f334cff70a68ea3d2e40d6638733e8c1ec434164 (#11830)
⬆️ 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-09-02 10:54:38 +02:00
Ettore Di Giacinto 6b712e76db fix(cluster): keep the refusal vocabulary in one table
The worker re-classified a failure a local service had already classified.
classifyServiceFailure preserved exactly one of the four refusal codes,
which was faithful to its own comment for as long as there was one worth
keeping; once ErrStreamNotServed existed, a service returning the code
whose whole job is to say "I learned nothing" had it promoted to
ErrStreamTargetUnavailable, which every reap guard acts on.
ErrStreamTagUnknown was promoted too, and cost nothing only because both
sides of that one reap. No in-tree service produces either, which is the
same "unreachable, therefore safe" argument that let the request-frame
merge survive a whole phase, and LocalService is exported.

The cause was a fifth site enumerating the vocabulary by hand, so the fix
is one table. streamRefusals pairs each sentinel with its wire code and
with whether a frontend may act on it as evidence about a backend, and the
writer, the reader, IsWorkerAnswer and the new IsStreamRefusal all read it.
A fifth code is now taught to every one of them at once.

The codes are also pinned against literals written out in a spec, the way
this branch already pinned the NATS vocabulary. The round-trip table
cannot see a rename, because a rename moves the writer and the reader
together; an unrecognised code is deliberately not the worker's answer, so
renaming "unavailable" would turn every crashed backend on a tunnelled
worker into a row nothing can ever reap, silently and with the suite green.

Three comments the previous fix falsified, corrected:

- tunnelHeaderTimeout still said the window bounds only framing the
  frontend writes immediately after opening the stream. That is true on the
  direct path and false on the relay path, and it was the argument for
  treating an expiry as the frontend's fault.
- classifyServiceFailure's deny-list is three causes, not two: on a dial
  error net.Error.Timeout also covers ETIMEDOUT and EAGAIN. Both are kept
  deliberately, because reaping a wedged or resource-starved backend is the
  eviction this phase exists to prevent, and ECONNREFUSED still reaps.
  isReadTimeout is renamed reportsTimeout, which is what it asks.
- The operator table named three refusals and said a refusal is acted on.
  It now lists four, with when each is sent and whether the row is reaped.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 06:10:26 +00:00
Ettore Di Giacinto c19ed5ab32 fix(cluster): stop a late request frame reading as the worker's verdict
Making a worker's refusal reaping evidence created a defect one layer
along, at the producer. The worker refused a ReadStreamRequest failure with
ErrStreamRequestInvalid and its own comment said "Includes the deadline
above expiring", which was harmless while every refusal reached the
frontend as "no route" and became a reap the moment one of them did not. So
a request frame that had merely not ARRIVED yet was reported as a
non-transient verdict about a backend.

It is reachable on the relay path, which carries most production traffic:
the worker's header timer starts when the OWNING replica opens the stream,
while the frame is written by the DIALLING replica only after the relay's
acceptance travels back to it, so a whole peer-link round trip runs inside
that window, on a link this design deliberately loads with multi-gigabyte
artifacts beside token streams. For a long-deadline caller the endpoint is
ConnectionEvictingClient, which stops the model across the fleet. It also
falsified the "neither clears on its own" argument that licensed the reap.

There is now a fourth refusal, ErrStreamNotServed, for what a worker could
not serve for a reason of its OWN. It is deliberately outside
IsWorkerAnswer, so it reaches a consumer under the no-route umbrella and
reaps nothing, which is the same treatment an unrecognised code already
gets. Four producers move onto it: a request frame that timed out (a
malformed one stays a verdict, because that is a frontend bug no retry
fixes), both SetReadDeadline failures, which are facts about the stream and
not about a target nothing has dialled yet, and WriteStreamRefusal's
default for a reason nobody classified.

classifyServiceFailure keeps ErrStreamTargetUnavailable as its default on
purpose: inverting it would make errno enumeration the single point of
failure for the reap, and a miss there is a row nothing can ever delete.
What it gains is a deny-list of two causes that are provably this worker's
own clock or its own context.

Also:

- The read-site caller-deadline guard in the handshake was unpinned: the
  existing seam spends the budget before the handshake starts, so only the
  write could ever fail. A spec whose deadline falls between the request and
  the reply pins it, and each guard now reddens on its own.
- The documented worker-first failure line omitted the JSON error envelope
  the old frontend returns, so an operator grepping it found nothing.
- The peer-link disclosure names the aimable per-session receive window in
  all four places, and LastDialErrorOf records why a third consumer must go
  through IsWorkerAnswer rather than roll its own list.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 04:48:25 +00:00
Ettore Di Giacinto d26263f9c0 fix(distributed): let a worker's own refusal be evidence about its backend
A worker that refuses a stream has answered, and cluster.Dial keeps the three
tunnelproto sentinels out of the ErrNoRoute umbrella precisely so a consumer
can act on that. No consumer did. Since workers stopped listening, a backend
process that crashed on a healthy worker is no longer a dead listener's
codes.Unavailable: the worker refuses the stream with
ErrStreamTargetUnavailable, gRPC flattens it into Unavailable anyway, and
nodes.unroutable reported the whole thing as "this frontend has no route".
Every reap path then answered ProbeUnknown and left the row, so the replica
slot never freed and at the default MaxReplicasPerModel=1 the only cleanup
left was LRU eviction of models that were working.

isWorkerAnswer is exported as cluster.IsWorkerAnswer, so the errors the dialer
keeps out of the umbrella are by construction the errors the consumers treat
as the worker answering. nodes.unroutable and pkg/model's transportFailure
both use it; ConnectionEvictingClient, the site reached during inference, goes
through transportFailure rather than asking the transport directly. A reply
code this frontend does not recognise is still not an answer, so a newer
worker's vocabulary costs a retry and not a replica.

The reap guards keep the allow-list rather than requiring ErrNoRoute: an
unrecognised dial error must mean "no route", never "the backend is gone".

Also in this final pass over the branch:

- Docs: recommend upgrading FRONTENDS first, with the symptom of each order.
  Workers-first fails now that a 4xx registration is a verdict rather than an
  outage, so an old frontend's "address is required for backend workers" makes
  each restarted worker exit and drains the fleet a node per restart.
- Docs: LOCALAI_WORKER_TUNNEL=false is a fatal startup error, not a degraded
  mode, in both places that described it; and a frontend rollback needs every
  worker restarted, because re-registration force-clears the address columns.
- A replica with no advertised address now says so every five minutes and
  names the workers only it can reach, instead of one startup warning for a
  cost paid for the life of the process.
- callerRanOut's rule now holds at all three siblings, so an expired caller
  deadline stops reading as a broken tunnel; probeHealth's withdrawn reason
  for using the raw client is corrected; the dead DoOrCached is deleted and
  its coverage kept on DoOrCachedResult; sweepLeakedInFlight enumerates the
  outcomes that reach it.
- The peer route's self-declared id is recorded as a phase-3 deferral, in the
  handler, in the isolation claim it narrows, and in the operator docs.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 00:59:36 +00:00
Ettore Di Giacinto 0dc6ebd525 fix(cluster): stop blaming a peer for the caller's own expired deadline
Review round 1 on the end-to-end proof. Zero blocking items, eleven
non-blocking, and three of them turned out to be production defects rather
than notes on the report.

The one that matters is a misclassification the phase is built to prevent. A
dial carries the caller's deadline down to the socket, so when the budget runs
out the socket's timer fires and the error travels back up through the
WebSocket handshake and the multiplexer. The context's cancellation is a
separate timer whose func the scheduler has to run before ctx.Err() stops
returning nil, and nothing orders the two. Under contention the socket's error
is back in PeerPool.Open first, ctx.Err() reads nil, and a peer that is
listening and healthy is reported as ErrPeerUnreachable to a caller that simply
ran out of time. An unreachable peer is a fact a caller may act on and an
expired deadline is not, and core/services/nodes routes around a replica it is
told is unreachable.

callerRanOut answers that question in one place: ctx.Err() when it is set, and
otherwise the wall clock against the caller's own deadline. That is sound
because it is the same instant the socket compared itself against, so if the
socket's timer fired this comparison is past it too. The ambiguous instant
resolves towards the caller, which is the direction that never blames a peer.

The spec that caught it, peerlink_test.go's "blames the caller's deadline",
was red in three of seven -race runs and had been since Task 5, which is often
enough to read as noise and is why single-run verification never saw it. Rather
than leave the proof to a coin flip, a second spec makes the window
deterministic: Open is handed a context whose deadline has passed and whose
cancellation has not been delivered, against an address nothing is listening
on, so the dial fails for real. It reddens without the fix.

The peer link's yamux windows were applied to one end only. A receive window is
advertised by the side that RECEIVES, so configuring the dialler alone tunes
exactly one direction, and the direction left on the 256 KiB default is the one
that carries a relayed model artifact INTO the replica that owns the worker's
tunnel. That is the largest thing the link ever moves and it is the direction
the load measurement exercises: the review read it as flowing toward the
dialler and it does not. PeerLinkConfig is now exported and used on both ends.
Measured, same box, 128 MiB staged through the relay against the same transfer
without one: the relayed path cost 1.6x to 2.0x the direct path's transfer
window before, and 1.06x to 1.25x after.

The SSRF reachability spec could be fooled into reporting an SSRF that did not
happen. It bound the victim on 127.0.0.2 at an ephemeral port and required
127.0.0.1 at the same port to refuse, so any other spec in the run holding that
number made the dial succeed; red one run in seven, green five of five in
isolation. It now picks from below the kernel's ephemeral range, the same fix
the harness got for the adjacent-port collision.

The rest are the specs and the report saying what they mean.

Scenario 1's advertisement assertion could not tell "the worker advertises
nothing" from "the JSON key moved", which matters because removing the
advertisement is the change it covers. It was green against a renamed key. The
roster now keeps the raw key set beside the decoded fields and the spec
requires both keys present before reading them as empty.

Scenario 4's refusal-body check was a four-way disjunction admitting bare
"tunnel", "not connected" and "unroutable". Those alternatives were inert and
each would be satisfied by refusals that say nothing about routing, in the one
assertion the whole negative control rests on. It is "no route" alone.

The head-of-line gate bounded the worst probe by the whole transfer window,
which admits about eightfold degradation and loosens as the box slows. It is
now half the window, plus a scale-free ratio against the worst probe under the
SAME cold load with nothing to transfer, which is the control that isolates the
transfer from the load. Not tighter than that, and the reason is measured
rather than cautious: under a concurrent -race suite the worst relayed probe
reached a fifth of its window, so a quarter-window gate would have had 1.2x of
margin, and a spec that fails one run in three is worse than no spec.

The report entry printed p90 and p99 off samples of twenty, where both land on
the same element and p99 often lands on the max, so one number appeared three
times under three names. A quantile is now printed only when the sample can
separate it.

Two claims in the report were wrong and are withdrawn rather than softened.
Scenario 2's race is closed by the trailing re-read of the owner, not by the
pre-assertion the report credited: a move to the non-owner mid-request would
serve directly and still return 200, and only the trailing read reddens on it.
And "the median request is unchanged" holds on this box and not on the
reviewer's, where the relayed median rises up to 82% and p99 up to 3.5x. What
survives on both is structural: the worst probe is a small fraction of the
window in which bytes are moving, so the session interleaves rather than
serialising. Sharing a session with a bulk transfer costs latency; it does not
cost service.

The disk footprint note undercounted, and the reviewer lost a run to a full
disk on this box, so it is worth having right: two bulk models seeded into two
frontends and staged to the worker is about 768 MiB, not 512 MiB.

Left alone deliberately: the worker's backend port allocator still hands out
ports without checking they are free, and its default range still overlaps the
kernel's ephemeral range. It is confirmed, it is out of scope here, and it is
being tracked as a named follow-up rather than fixed under an e2e task.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 23:30:10 +00:00
localai-org-maint-botandmudler 2bbcfb3ec6 chore(model-gallery): ⬆️ update checksum (#11831)
⬆️ 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-09-01 23:13:48 +02:00
e760eb236f chore: ⬆️ Update ggml-org/llama.cpp to 3466812d1f06728effe7c0f3c0671117f461672d (#11798)
* ⬆️ Update ggml-org/llama.cpp

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

* fix(llama-cpp): link librdma from the static ggml-rpc build

ggml-rpc gained an Apple RDMA transport in this llama.cpp range and
declares its librdma dependency with target_link_options(ggml-rpc
PRIVATE "LINKER:-weak_library,..."). Link options are not a usage
requirement of a static library, so the llama-cpp-grpc variant, which
builds with BUILD_SHARED_LIBS=OFF, dropped the flag and left every
ibv_* symbol of transport-apple.cpp undefined when grpc-server linked
on darwin.

prepare.sh now re-declares the same weak link as INTERFACE on the
ggml-rpc target, so the flag reaches whoever links the static library.
The append is guarded on a marker for repeat runs, and on
GGML_RPC_RDMA_APPLE, which the turboquant and bonsai forks lack.

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

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 23:13:11 +02:00
Ettore Di Giacinto 0683fb1579 test(distributed): prove the worker tunnel end to end, under real inference
Everything this phase built was proven by unit and integration specs. This is
the first run of it against the real binaries: a frontend replica per process,
a worker that binds nothing routable, real inference over the result.

Four scenarios, each with the question "what would make this pass if the tunnel
were doing nothing" answered rather than left open.

A worker with no advertised address is reached through its tunnel. The roster
is asserted to report it advertising nothing, so there is no address a frontend
could have dialled instead, and node_connections is asserted to name the
replica that serves the request.

A request landing on the replica that does NOT own the worker is relayed to the
one that does. With N replicas behind round robin that is (N-1)/N of production
traffic, so it gets the FIRST request for its model: the backend install, the
file staging on the http tag, and the gRPC load and predict all cross the
relay. Which replica owns the tunnel is read from the ownership table through
the production Owner query and mapped to a frontend index through the address
the harness pins per replica; the non-owner is derived from that reading and
asserted to be a non-owner immediately before the request, rather than assumed
from the harness default. Sending the same request to the owner reddens it.

Killing the owning replica re-homes the worker onto the survivor. The worker
dials a balancer rather than a replica, because LOCALAI_REGISTER_TO is resolved
once at boot and is the tunnel endpoint as well as the registration one: aimed
at a single replica, a worker has nowhere to reconnect to when that replica
dies, and the re-home cannot happen at all. Removing the kill reddens it.

And the negative control for the whole suite, which is why the other three mean
anything. Frontend and worker share a host here, so every backend port the
frontend names in a stream target is one it could have dialled directly; if it
did, the first three would pass with the tunnel inert. LOCALAI_WORKER_TUNNEL is
no longer usable for this, because it is a fatal startup error and a worker that
never started says nothing about a worker reachable some other way. The balancer
answers the tunnel connect path itself instead, leaving a worker that registers,
heartbeats, reports healthy and holds no tunnel. It is asserted to have dialled
and been refused, asserted to be held by nobody, and then asserted unreachable
with the refusal naming the missing route. Then the block is lifted, nothing
else changes, and the same request succeeds: that is what attributes the refusal
to the tunnel rather than to any of the ordinary reasons an e2e inference fails.

The fifth spec measures the head-of-line blocking this phase deferred three
times. 128 MiB crosses the session while a warm model is probed back to back,
direct and relayed. Median latency is unchanged, the worst probe is about 3x the
baseline median and about a seventeenth of the transfer window, and the transfer
runs at 415-490 MB/s direct and 222-268 MB/s relayed. A session that
head-of-line blocked would park a probe for the length of the window. Leave the
yamux windows untuned; and note this is loopback, so it says the multiplexing
does not serialise and says nothing about a link with a bandwidth-delay product.

The load spec is measured against a control that the first version did not have.
It passed with the bulk artifact cut to 4 KiB, because the window it read probes
against was mostly cold-load overhead: it would have reported a clean bill on a
session carrying no large message. The same cold load now runs twice, once
empty and once bulk, and the difference between the windows is asserted to be
real before any latency is read from it.

Two defects on the base commit came out of this.

cluster_peerlink_test.go has been red since the relay landed, deterministically,
in isolation and in the suite. It asserted that an accepted peer stream is
refused at once, on the premise that phase 1 installs no relay. The relay
correctly waits fifteen seconds for a frame naming the worker, and the spec's
budget was five. It now writes a relay request for a node no replica holds and
asserts the refusal is ErrNotOwner and specifically not ErrNoConnection, which
is a stronger spec than the one it replaces and the only thing in the e2e suite
that exercises the relay's refusal path.

The harness handed a worker's own HTTP port to a backend process. It took two
ports from freeport and used one as the gRPC base and the other for the file
transfer server; freeport returns adjacent ports often, and the backend
allocator hands out base, base+1, base+2, so the second backend started on a
worker was regularly given the HTTP server's port and died with EADDRINUSE. No
spec had started two backends on one worker before, so it had never fired; the
load spec starts five and it failed about one run in three. Each worker now
reserves a contiguous bind-probed block laid out the way production lays it out,
below the kernel's ephemeral range, with LOCALAI_GRPC_MAX_PORT bounding the
allocator to it. The underlying production defect is not fixed here and is
recorded in the report: allocatePort never checks that a port is free, and its
default range overlaps the ephemeral range on every Linux box.

Constraint 6, whether distributed mode should now refuse to start without an
advertised address, is DEFERRED, and the comment and the docs that described the
cost were understating it. A replica with no advertised address writes no
instances row, and Owner joins a connection against a live instance, so a worker
whose tunnel lands there is unroutable from every OTHER replica while being
registered and healthy. Refusing to start would still be wrong, because the
deployments it would break are single-host ones with no peers to be unreachable
by, and telling those apart at startup is a design with its own specs. Both
places now say what actually happens.

Suite wall clock 592s for 15 specs, up from 502s for 10 of which 2 were red. The
CI budget of 20 minutes does not move.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 20:56:57 +00:00
Ettore Di Giacinto 3338d7bc56 fix(distributed): refuse a worker that cannot tunnel, and say why it was refused
Review round 1 on the change that stopped workers listening. One blocking item
and seven notes.

LOCALAI_WORKER_TUNNEL=false was the blocking one, and the ruling was to make it
fatal rather than to correct the comment that still promised it fell back to the
advertised address. There is no fallback left: a worker on this branch
advertises nothing and binds only loopback, so turning the tunnel off leaves it
reachable by nothing while it registers, heartbeats and reports healthy, and the
scheduler keeps placing models on it. That is the worst available failure shape,
so a new Config.validateStartup refuses it before prefetch, registration and
NATS, while the worker is still invisible to the cluster. It absorbs the
pre-existing empty-registration-token check, which had the same shape and no
spec. The flag is kept rather than deleted so an operator who set it is told the
promise is gone instead of having the setting ignored, and the guard around
StartTunnel is removed, because a branch nothing can take reads as a supported
no-tunnel mode that does not exist.

The justification for erroring on an install that names no address was wrong,
and the review is right that this is the dangerous form of overclaiming, because
the conclusion holds and the mechanism does not. It said the resulting empty
target would be refused as an invalid stream and that the refusal would read as
the worker answering about its backend. Nothing in this repo branches on
cluster.ErrNoRoute, and nodes.unroutable treats any recorded dial error as
unroutable, so that refusal reaches every reap guard as ProbeUnknown and deletes
nothing. The site now stands on what holds, that an install naming no port
produced nothing routable and the failure belongs to the install rather than to
a later probe, and records the retracted claim so nobody re-derives it. This
retracts the same paragraph in the body of 1cf847f29.

The reviewer deleted the whole tryWarmPath unnamed-replica guard and the suite
stayed green, including the reservation release. It is specced now, and the
asymmetry the review asked about is decided at the site: the row stays, unlike
the sibling !alive branch which removes it. That branch has observed a backend
dead; this one has observed only that the row is unreadable, which says nothing
about whether a process is running, and the row is the last record that one
might be, since the acknowledged stop path refuses a stop whose ExpectedAddress
does not match and an empty one cannot be cleaned up through it either.

The cross-version wire claim rested on two struct tags nobody asserted:
renaming only the json keys survived mutation while the gorm column rename went
red through raw SQL. Both keys are pinned now, marshal and unmarshal, per
struct.

A worker-first upgrade showed the operator a status code and not the reason. The
registration client discarded the body, so "address is required for backend
workers" was read off the socket and thrown away, and the ladder then spent four
minutes on a verdict the frontend reached instantly. Refusals now quote the body
and carry ErrRegistrationRejected, and both the ladder and the credential
manager's Acquire stop on the first one. Acquire matters more than the ladder:
it is the default path and its bound is 100 attempts, not 10. 408 and 429 are
deliberately not refusals, since both are the frontend asking for the same
request again.

Also: the stale "not blocked by firewalls" troubleshooting line, which now names
the real cause and the knobs that move the port range; and the inert address
fields on the MCP Node DTO, which the Assistant was still being handed. The
review named http_address there and I removed address too, because it is inert
by the same argument and leaving one of a pair is arbitrary.

Five mutations, all red. Deleting the warm-path guard reddens four specs and
falsifying only its reservation release reddens one, so the two halves are
pinned separately. Renaming only the json keys reddens both wire suites.
Discarding the refusal body reddens two. Dropping the rejection classification
does not fail the suite, it hangs it, which is the operator-visible symptom, so
it is recorded red under a ginkgo timeout.

The verify list is now derived from the diff rather than from the brief, which
is what let the previous round ship a spec asserting 200 where the endpoint
returns 201: nine ginkgo suites, the e2e vet, route auth coverage, the leaf
check, build, the healthcheck shell suite and lint. The two jsx files have no
harness in this worktree and are recorded as the one unverified surface.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 19:36:35 +00:00
Ettore Di Giacinto 1cf847f29e feat(distributed): stop workers listening, and stop them advertising
A worker now opens no listener on a routable interface and states no endpoint at
registration. Backend processes and the file-transfer server bind loopback, and
the frontend reaches both through the tunnel the worker dials. The bind address
is built from loopbackHost, the same constant the tunnel's grpc tag dials, so
"the worker binds where its tunnel dials" is one fact in one place rather than
two literals that can drift.

All three advertisement sites are closed, not one: the registration body,
RegisterNodeRequest, and the per-backend address in the install reply.

That third one was hiding a live bug. stopModelExact refuses a stop whose
ExpectedAddress does not match what the worker recorded for the process. The
worker recorded 127.0.0.1:port; handleBackendInstall reported advertiseHost:port;
the router stored the reported one and sent it straight back. On any worker whose
advertise host was not 127.0.0.1, every acknowledged model stop failed with an
address mismatch. Nothing caught it because the e2e harness set
LOCALAI_ADVERTISE_ADDR=127.0.0.1, which made the rewrite a no-op. Removing the
rewrite makes the two strings the same by construction.

The brief was wrong about two of the four functions it called dead.
effectiveBasePort is the base of the backend port allocator and resolveHTTPAddr
is the file server's bind address; deleting them would have deleted the port
allocator and the file server. Only the two advertise* helpers were dead, and
addr_test.go is rewritten rather than deleted, because the port arithmetic it
pinned still needs pinning.

NodeModel.Address survives with a narrowed meaning and is renamed
WorkerLocalAddress, along with the install reply field that feeds it. The
frontend still has to say WHICH backend process on a worker it means, and the
port in this string is how it says it: it travels as a stream target and the
worker dials its own loopback. The gorm column and the json key stay "address",
so neither a migration nor an API break rides along. Every fall-back to the
node's address is gone. installBackendOnNode now errors when a worker reports
success without naming one, because substituting the now-always-empty node
address would name an empty target, and the worker refuses that as an invalid
stream, which is classified as the worker answering about its backend. That is
the "a present worker reads as something it is not" class this phase forbids.

DistributedModelStore.Range had the same shape and was already wrong: it built
each remote model's client from the node's base gRPC port, never the port a
backend process listens on, so Free and Status went to the wrong place. It uses
the replica's address now.

BackendNode.Address and HTTPAddress are kept but made provably inert: no writer,
no reader that acts on them, and Register force-clears both on re-registration so
an upgraded worker's stale advertisement does not outlive its own upgrade in the
API and the Nodes page. Dropping the columns is a ~90-site edit across the specs,
the e2e suite, the MCP dto and the UI; it is recorded as a follow-up rather than
folded in here.

A persistent tunnel 401 still does not trigger re-registration, and now for a
reason rather than a deferral. Register CLEARS the node's replica rows, so
re-registering on a 401 would delete a live worker's rows on every retry, and
under the name collision that causes the 401 the two workers would take turns
doing it forever: a credential failure causing model reclamation. It also cannot
fix the named cause, since a collision is indistinguishable from a restart. The
401 log now names both causes and says nothing can reach this worker, which is
true only now that it has no listener.

The container healthcheck did not break the way the brief expected, since the
listener still exists on loopback and the probe runs inside the container. It did
have a real #10987 defect that this change makes the common case: it read
LOCALAI_SERVE_ADDR only, while effectiveBasePort reads LOCALAI_ADDR first, so a
worker on a non-default base port was probed on 50050 and reported unhealthy
while working. It follows the same precedence now.

Docs, the compose file and the e2e harness are updated in step: no inbound rule
or published port is needed for a worker, the two advertise variables are gone,
the remaining address variables are read for their port only, the
firewall-the-file-transfer-port warning is narrowed to the LOCALAI_HTTP_ADDR
opt-out, and the upgrade-order note no longer claims the worker still listens.
The Nodes page showed node.address, which is now always blank, so it shows the
node id instead.

Eight mutations, all red on a named spec, including reverting the loopback bind,
re-adding the address to the registration body, restoring both node-address
fall-backs, dropping the force-clear, storing the endpoint's address again, and
un-fixing the healthcheck. One of them caught a defect in a spec I had just
written: it asserted 200 where the endpoint returns 201, which went unnoticed
because core/http/endpoints/localai is not on the task's verify list. It is run
here.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 18:47:37 +00:00
Ettore Di Giacinto ed9a4b6b52 docs(grpc): withdraw the lint-cost claim, and make the one nolint tidy-proof
I reported that enabling gocritic pushed make lint past 600s. That was wrong,
and it was wrong in a way worth naming: those runs happened right after I
changed pkg/grpc's and core/services/nodes' interfaces, so the Go build cache
was cold for essentially the whole repository including every backend, and test
suites were running concurrently on the same machine. I attributed a cold-cache
full-repo typecheck under load to the linter I had just enabled, and raised it
as a cost without ever timing it against a baseline. A number with no control is
not a measurement.

Measured properly, with the golangci cache cleaned before every run and isolated
GOCACHE directories for the cold ones so the shared cache was not wiped: warm,
base 15s then 7s and current 8s then 7s; cold, base 87s and current 78s running
base first, base 136s and current 79s running current first. The spread between
the two cold base runs is larger than any gap between base and current, so
gocritic with only the ruleguard checker costs nothing measurable.

So the rule stays, unscoped. Scoping it to core and pkg was the fallback for a
cost that does not exist, and adding that configuration would buy nothing.

The one override gets the protection it needs instead. InFlightTrackingClient's
nolint is exactly the kind of thing a later reader tidies away, so it now opens
by saying not to, and states what breaks rather than what is intended:
WrappedBackend embeds the full Backend interface, so adopting it there would
promote every inference method as untracked pass-through, the build would stay
GREEN, and in-flight accounting would silently stop covering whatever was added
next. WrappedBackend's own doc carries the counterpart warning so a reader
arriving from either side finds it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 18:03:52 +00:00
Ettore Di Giacinto b8d47cc29b test(nodes): make the joiner spec deterministic, and gate the wrapper shape in lint
Re-review round 2. One blocking item, and it was a spec I wrote: eight
goroutines raced at the probe cache and nothing made them coalesce, so a
straggler that missed the flight re-entered the probe and double-closed a
channel. It panicked about one run in three and took the four-suite race block
down. The green verification I reported was not reproducible, which means one
green run was never evidence for a spec that coordinates goroutines. Its comment
claimed the probe blocked until every goroutine was inside flight.Do, and that
gap was exactly the panic: the comment described the design intended rather than
the one written.

It is deterministic now rather than tolerant. singleflight.DoChan registers its
channel on an in-flight call under the group's own mutex and returns without
running its function, so calling it while the leader is provably parked inside
the probe joins that exact flight with no window and no dependence on the
scheduler. The spec asserts the join really happened, that the joiner got the
reason and not only the answer, and that the probe ran once; the entered channel
is sent on rather than closed so a second probe fails an assertion instead of
panicking. Twenty runs green under race against the committed code, five out of
five red on the mutation back to a closed-over variable.

The future-decorator gap is closed in the lint gate, but not the way the review
suggested, and the reason is worth recording. HasMethod rejects inline
signatures outright, its method-reference form needs a package ruleguard's own
typechecker can import and that typechecker cannot import this module, and
Implements tests the value method set while every Unwrap is on a pointer
receiver, so it fired on all three wrappers that already had one.

So the safe shape is structural instead. grpc.WrappedBackend gives the same
pass-through method set plus Unwrap on a value receiver, and a decorator that
embeds it is transparent by construction; forgetting stops being expressible
rather than merely discouraged, which is the move loopbackService already makes
in the worker. FileStagingClient and ConnectionEvictingClient embed it and their
hand-written Unwrap methods are gone. The ruleguard rule then only has to catch
the raw embedding, needs no type filter, and cannot misfire. It was verified to
fire on a throwaway wrapper and stay silent on a correct one, and reports
nothing across core and pkg with the baseline disabled.

InFlightTrackingClient is the one exception and says why in a nolint: it embeds
ControlBackend deliberately so that leaving an inference method unwrapped breaks
the build, and WrappedBackend embeds the full interface, so adopting it would
silently restore pass-through for every inference method and delete that
guarantee.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 17:46:50 +00:00
Ettore Di Giacinto b4d8e23abb fix(grpc): let the transport answer through the wrappers, not only past gRPC
Re-review round 2. One blocking defect, and it was the concern I filed myself
last round and mis-scoped as a future trap. It was live, and it sat on the most
destructive reaping path of the five.

RouteResult.Client is an InFlightTrackingClient, over a FileStagingClient when a
stager is configured. model_router puts that on the cached remote model and
pkg/model's checkIsLoaded asks IT whether the transport failed. Both wrappers
embed grpc.Backend, which does not declare LastDialError, so the type assertion
read nil and the guard added last round fell straight through to the old
eviction. That eviction sends backend.stop over NATS to every node holding the
model and deletes every replica row, where the other sites delete one. The spec
covering it built a bare client by hand, which is why it passed while production
did not.

This is the third time in this task a correct fix was disarmed one layer out, so
the fix is a mechanism rather than two methods. BackendUnwrapper is one line per
decorator, LastDialErrorOf walks the chain, and both consumers now call it
instead of each keeping its own assertion. One implementation, no per-caller
policy to get wrong.

Sweeping every type that embeds or holds a grpc.Backend found a third decorator
the review had not named, and it is itself a reaping consumer of the same
collapsed signal. ConnectionEvictingClient is built for remote models in
initializers.go and its evict callback runs ShutdownModel; it fires during
INFERENCE rather than on a health check, so a tunnel blip mid-request was enough
to stop a model that was loaded and serving. It consults the transport first
now. A locally spawned backend has no custom transport, so that path is
unchanged byte for byte. Everything else touching a Backend is a consumer rather
than a decorator; there is no fourth.

The probe cache joiner shape is pinned. It was the right design last round with
nothing holding it: the mutation back to a closed-over variable passed all 602
specs in the package. Eight goroutines coalesced on a probe that blocks on a
channel now assert every joiner gets the leader's REASON and not just its
answer, which is the difference between a leader declining to reap and its seven
joiners reaping on the leader's own observation.

The LastDialError scope note claimed an exactness it does not have at
checkIsLoaded, which reads a shared long-lived client after releasing opMutex.
It now says which caller is not exact, why the imprecision is accepted there,
and what making it exact would cost.

The four-outcome table in the docs still said a worker with no live owner is
treated as absent and rescheduled, contradicting the code and the paragraph nine
lines below it. None of those outcomes is absence any more, and the table says
so, names the fifth, and points at the heartbeat as the thing that does decide
presence.

Five mutations, each reddening named specs, including the two the reviewer found
surviving.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 16:33:59 +00:00
Ettore Di Giacinto a8ac2af167 fix(cluster): make "no route" a condition of its own, and let it out of the package
Review round 1 on task 6. Five blocking findings, all with the same root: the
conditions the dialer kept apart were erased one layer out, because every one of
them arrived at core/services/nodes as a gRPC codes.Unavailable, which is also
what a backend process that died produces. Four call sites acted on that by
deleting a replica row, one of them after a single failed probe.

The fifth condition is ErrNoRoute: this replica could not get a request to a
worker's backend, and no claim at all about the worker. A worker's presence is
its HEARTBEAT, which nodes owns; a route is a separate fact that cluster owns,
and the two now differ. They differ in normal operation, not exotically: a
worker that has not dialled its tunnel yet after a frontend-first upgrade is
unroutable on every request while it heartbeats and serves.

Two properties, both mutation-tested. Every failure to resolve or open a route
carries ErrNoRoute, so a consumer has one check to make. No failure carries an
absence sentinel: routeFailure is the single place that rule lives, and it keeps
ErrNoConnection and ErrInstanceNotFound in the message and out of the unwrap
chain, the guarantee unreachableError already made for peers. Everything else
stays matchable, so ErrNotOwner and ErrPeerUnreachable are unchanged for anyone
who can act on them. A worker's own refusal carries no umbrella, because a
worker that answers has demonstrated it is there and that is the only real
evidence on the path.

Crossing the boundary needed a value, not a code. NewClientWithDialer wraps the
dialer and records each outcome; LastDialError hands it back behind a narrow
interface, and nodes.unroutable turns it into ErrWorkerUnroutable with the
cluster sentinels still in the chain. A spec asserts a dial failing with
ErrNoRoute plus ErrPeerUnreachable arrives matching all three and matching
neither absence sentinel.

The sweep found a fourth site the review had not named: pkg/model checkIsLoaded
evicts a remote model on a connection error, and a tunnel dial failure is one.
Four other reap sites were cleared with reasons - inflight and the worker
authoritative pass reap only on semantic answers, scale-down is driven by
last_used, abandoned loads decide on the node's heartbeat. Every fixed site also
grew the opposite spec, so the new check cannot pass by never reaping.

probeCache carries the reason through singleflight rather than a closed-over
variable. A variable is only written by the goroutine that runs the probe, so
the leader would correctly decline to reap while every joiner reaped on the
leader's own observation; a mutation reproduces exactly that.

The docs sentence promising LOCALAI_WORKER_TUNNEL=false restores direct dialling
is gone. There is no such path, so it said the operator could take a worker dark
and call it a rollback. Replaced with the upgrade order that is actually safe.

The deadline spec the reviewer found vacuous now waits on the dial context's own
Done channel before touching the stream, so the armed deadline has really
expired; the mutation that survived for the reviewer reddens it.

Nine mutations, each reddening a named spec, including both halves of
isAbsenceClaim independently.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 16:09:26 +00:00
Ettore Di Giacinto 75953d9f63 feat(cluster): reach every worker through its tunnel, never its address
The tunnel, the fence, the registry and the relay were all built and none of
them carried a byte: every dial from the frontend still went to the address a
worker registered. This is where that stops. One WorkerDialer resolves where a
worker's tunnel is held, opens a stream on it locally or relays through the
owning replica, and hands back a conn past both handshakes; gRPC, the file
stager's HTTP client and the log-streaming WebSocket are all pointed at it.

A worker's address stops being somewhere to connect to and becomes the name of
which backend process a stream is for. It still appears in URLs, logs and
errors, because that is what identifies the process; what it no longer decides
is where the bytes go.

Nothing falls back to dialling it. BackendClientFactory now has exactly one
method, NewClientForNode, and returns an error where there is no way to reach
the worker. The direct-dial constructor was removed rather than kept beside it,
because leaving one on the interface keeps the bypass one word away from every
call site that holds an address, which is all of them.

The second construction path is closed too. DistributedModelStore built remote
models with a nil client, and pkg/model.Model.GRPC then dialled the raw address
lazily on first use - reached in production by ShutdownModel's Free and by the
backend monitor's Status. Those models now carry the tunnel-backed client, and
a model that cannot be given one is logged and not listed.

Four conditions stay unmixable, and one path produces absence: the dialer
answers ErrNoConnection only where Owner's liveness join did. A peer that will
not answer, a stale ownership row, a worker's own refusal and a missing relay
path are each reported as themselves. This matters because nodes ACTS on
absence, and the collapse would have it reclaim the models of a worker that is
connected and busy.

That is not hypothetical. Writing the mutation for it exposed the bug in this
change's own first draft: probeHealth returned bare false when it could not
build a client, and tryWarmPath deletes the replica row on a false probe. A
frontend whose dialer broke would have emptied node_models for the whole
deployment while every model kept running. probeHealth now returns alive and
probed separately, the reconciler gets a ProbeUnknown outcome that neither
advances nor clears a failure streak, and the health monitor skips rather than
counting a miss.

Task 5 left the relay's open timeout at a fixed 15s and said so: no operator
has the information to set it, because the number that matters is the original
client's remaining budget, which is invisible on the relay side. The dialer has
that budget, so it now states it in the relay request frame and the owner takes
the smaller of the two. It can only shorten - a patient client must not be able
to park a relay goroutine and a stream slot on a worker that stopped accepting.
Zero is written as no budget at all, since on the far side the number zero is a
caller with nothing left and would refuse healthy traffic.

Seven mutations, each reddening a named spec: peer-unreachable as absence; the
local-failure guard dropped; max instead of min on the budget; the nil-client
model restored; ProbeUnknown falling through to the reaper; OwnerRow instead of
Owner; probed collapsed into alive. The first budget spec passed for the wrong
reason - a handshake deadline, not the relay - and was replaced by three that
each assert one link, including one where the spec plays the owning replica and
reads the budget out of the frame instead of inferring it from a clock.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 15:27:10 +00:00
Ettore Di Giacinto 1036f5664e fix(cluster): express the splice policy once, and pin the relay's budgets
Review round 1 on task 5. Eight non-blocking items, all addressed.

The classifier read Remote in two predicates with a report-by-default
fallthrough behind them, so reverting either read left the whole suite green:
the error reached the same answer down the other path. A correctness argument
that rests on mutation evidence cannot afford a shape that cannot be mutated in
pieces, so the two predicates collapse into one muxVerdict deciding each error
type once. Falsifying either Remote read now reddens exactly one spec.

Three claims the comments made loudly and nothing tested:

  - clearing the header read deadline before the splice. Deleting the clear
    left all 49 focused specs green, while in production it is the difference
    between a relayed response that streams for an hour and one that dies after
    fifteen seconds of quiet;
  - the open budget bounding the open and nothing after it;
  - closing the worker-side stream when the acceptance reply cannot be
    written, which leaks one stream on the worker per failure.

All three are pinned now. The first two share a spec that sets both budgets to
50ms and then watches the conversation outlive them by ten times, which is an
assertion about an event that must not happen and so is the one wait a channel
cannot replace. The third drives the relay with a peer stream that delivers a
request and then fails every write, because no pair of live yamux sessions can
be made to fail that write on cue.

The disjoint-vocabulary argument was specced for the accepted frame only. Both
refusal directions are covered now, and asserted as "not one of the other hop's
sentinels" rather than merely "an error", since reading a relay refusal with the
tunnel's reader always errors and the question is whether it errors as the wrong
thing.

The open budget stays non-configurable, and says so: the number that matters is
how long the original client will wait, which is not known on this side and is
not something a deployment-wide constant can stand in for. The honest fix is the
caller's remaining budget travelling in the request frame, which belongs to the
dialler that has the budget.

Two comments corrected: nothing deadlines the peer stream after the clear, so
the tunnelled protocol's own deadlines cannot be what justifies clearing it; and
the membership sweep deletes departed replicas but reports only how many, so
identifying them is work that would have to be done, not knowledge waiting to be
plumbed. Recorded at muxVerdict: a remote RST that does not ride a
typeWindowUpdate frame yields the bare sentinel and is still silenced, which is
unreachable between two go-yamux peers but keeps the new rule from reading as
unconditional.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Opus 5 [claude-code]
2026-09-01 14:40:06 +00:00
Ettore Di Giacinto cce914b3fd feat(cluster): relay a peer's stream onto a worker tunnel held here
A worker holds ONE tunnel and it lands on ONE frontend replica, so with N
replicas behind a load balancer roughly (N-1)/N of requests arrive somewhere
that cannot reach the worker directly. This is the piece that carries them:
the SessionStore stream handler reads which worker a peer's stream is for,
opens a stream on the tunnel this replica holds, and splices the two.

Splice has had no production caller since phase 1. It has one now, and being
the first caller it settles the two endings phase 1 deliberately left open,
both of which read as normal termination until now:

  - a peer-initiated *StreamError{Remote: true}, which yamux builds only from
    an RST frame the far side sent (stream.go:432-449); a reset this side asks
    for carries Remote: false, and Splice never resets anything, its own Close
    sending a FIN;
  - a graceful ErrRemoteGoAway, which handleGoAway returns for code
    goAwayNormal (session.go:829-833) and close hands unwrapped to every live
    stream (session.go:328-337).

Both truncate whatever was in flight. Reporting them as normal termination is
how a half-finished inference comes to look like a short one that completed,
so both are now reported; the local forms stay silent, because those are the
teardown Splice provokes itself. The decision cannot live in a caller reading
Splice's result, since a result already mapped to nil carries nothing left to
reclassify, so it lives at the classifier with the reasoning beside it. The
relay logs it at debug: a client cancelling a relayed request produces one per
cancellation, and the truncation is separately visible to the frontend's own
gRPC or HTTP client.

The relay hop gets its own request and reply frames. They have to be distinct
from the worker tunnel's, because a relayed stream carries both hops' frames
back to back, and a vocabulary shared between them would let a reader applied
to the wrong hop hand back a plausible sentinel belonging to the other. Its
three refusals stay apart for the reason the worker's three do: ErrNotOwner is
a routing fact and the caller should resolve the owner again; unavailable is
infrastructure at this replica and a retry is worth something; bad-request is
the caller's bug. None of them is, or may be built over, an absence error.

One hop, always. A stream naming a worker this replica does not hold is
refused, never resolved and relayed onward, so a stale ownership row cannot
become a loop between two replicas each certain the other holds the worker.

PeerPool is constructed and closed alongside SessionStore, so both halves of
the peer mesh now have an owner and a shutdown.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 14:12:17 +00:00
Ettore Di Giacinto 3b6d32c1c4 fix(worker): make the tunnel credential's node-type gate actually structural
Re-review follow-up, three items. Two are the overclaiming-comment class again,
and the first is that class with a real defect underneath it.

attachTunnelToken said "enforcement is therefore structural": an ineligible node
never gets a credential, so its hash stays empty and the tunnel route's
empty-hash branch does the refusing. That was true for a node that had always
been an agent and false for one that had not. Register upserts by NAME, so a
backend node re-registering as an agent keeps its ID, and Register's struct
Updates zero-skips the credential column while writing the new node_type. The
early return left the credential the node earned as a backend sitting on a row
that is now an agent, and ConnectHandler never looks at node_type.

Fixed by making the claim true rather than by softening it, because the mint-site
gate was chosen precisely on the grounds that it was structural: an ineligible
node now has its column CLEARED, unconditionally, so the invariant does not
depend on what the row happened to contain. A spec pins it and was red before the
change. Same shape as the Register-upserts-by-name hazard already carried
forward: a name is not an identity.

Second, loopbackHost claimed to be the only host any tunnel stream is ever
dialled on. It is not: fixedService dials whatever Run built it from, which is
this worker's own LOCALAI_HTTP_ADDR, and loopbackAddr rewrites only a wildcard
bind, so an operator who binds the file-transfer server to a routable address
gets a routable dial. The property that matters is narrower and is what the
comment says now: the frontend cannot STEER the dial. The grpc tag builds its
address from a constant and a validated port with nothing from the wire reaching
the dialler, and the http tag ignores its target entirely. Worth stating exactly
rather than summarising, because the argument about what a stream can reach rests
on knowing which hosts are reachable, and an overstatement at that site is what
would let someone conclude the constant alone is doing the work.

Third, a spec named "without allocating it" measured no allocation. It now
asserts the mechanism the defence actually rests on, that the reader consumes the
two length bytes and not one byte of the body, through a counting reader. The
input carries a body on purpose: against input that ends after the header the
assertion would pass with the limit check deleted.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Opus 5 [claude-code]
2026-09-01 13:33:07 +00:00
Ettore Di Giacinto 5108be222d fix(worker): spec the tunnel's routing table, which was the SSRF boundary
Review follow-up. One blocking finding and seven others.

The blocking one first, and it is this project's recurring shape: the untested
path. loopbackService is the function whose comment calls the discarded host
"the security property this function exists for", and nothing tested it. The
reviewer replaced its body with a dial of whatever the frontend named, no port
range, and all 131 specs passed. Every spec installed the permissive test
dialler, so the real routing table was exercised nowhere.

It now has specs, and the property is stated as reachability rather than as a
property of the code: a listener on 127.0.0.2 that only the frontend's target
names must NOT be reached. Plus the port-range table, fixedService, loopbackAddr,
tunnelEndpoint, and the table itself, which moved out of Run into tunnelServices
so it can be built without starting a worker. One spec drives a real stream
through that table over the wire, so the routing rules are exercised end to end
at least once rather than only in isolation. The reviewer's mutation now reddens
ten specs, and six narrower ones redden between two and four each, so no spec is
riding on another.

The shape changed too, not only the coverage. The dial address is built from a
loopbackHost constant and strconv.Itoa of a validated int, so nothing derived
from the wire reaches DialContext at all: restoring the hole takes ADDING a data
flow, not deleting a check.

And a taxonomy fix found while specifying it. A port outside this worker's
allocator range was reported as unavailable, which tells a frontend to retry
something that can never work. It is a bad request now, and a backend that is
merely not listening yet stays unavailable, which is the retryable one.

Agent nodes no longer get a tunnel credential. Nothing dials into an agent
worker, so a tunnel replaces nothing for it and no client would open one, and
the gate is at the mint site rather than in the handler: with no credential
minted the hash stays empty and the existing empty-hash refusal covers it, so
enforcement is structural.

Two comments and one doc paragraph said an anonymous registrant gets a "working"
credential. With auto-approve off the node is pending and the credential is
inert, which is the distinction this same change argues three files away to
justify minting for pending nodes at all.

A refusal reason over the frame limit was cut on a byte boundary and could split
a rune. It cuts on a rune boundary now, and the code survives truncation, which
is what keeps a refusal classifiable.

Also: the pending-node spec asserted only that a credential was non-empty, so a
credential derived from the shared token passed it; it now pins per-node-ness the
way the headline spec does. The tunnel handler's citations into nodes.go were
stale before this branch landed, having been written against a file the same
commit was editing, and are by function name now. The static-NATS path says
plainly that an externally forced rotation locks it out until restart, and where
that gets fixed. tunnelproto gained direct specs, including that a read failure
is never reported as a refusal.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Opus 5 [claude-code]
2026-09-01 13:03:54 +00:00
Ettore Di Giacinto 29a2020f3d feat(worker): dial, hold and serve the tunnel, on a credential of its own
The worker end of the tunnel. It dials wss://<register-to>/api/cluster/connect,
holds one yamux session as the CLIENT, and serves every stream the frontend
opens on it. Nothing dials into the worker, which is the point: no inbound port,
no reachable address.

Each stream opens with a length-prefixed frame naming a tag and a target, and
the worker answers before either side speaks the tunnelled protocol. The reply
is sent on every stream, not only on refusal, because the protocols carried here
are client-speaks-first and a reply sent only sometimes would arrive interleaved
with a response body. Two tags today: grpc reaches a backend process, and only
on 127.0.0.1 within this worker's own backend port range, because a tunnel
terminates inside the worker and letting the frontend name a host would make
every worker a proxy into its own LAN; http reaches the worker's file-transfer
server, whose address the frontend is not asked about.

An unknown tag, an unreachable local service and an unparseable request are
three refusals and stay three on the wire. A frontend gives up on the first and
retries the second. Each is answered AND the stream is ended: a worker that says
why and leaves the stream open has parked the caller on a request nobody will
answer, and a deadline on the far side cannot tell that from a slow worker. The
specs assert the stream ends rather than that an error occurred, which is what
phase 1 shipped in three places and held in none.

Reconnects double from 500ms to a 30s ceiling, each wait drawn between half the
interval and all of it, and the interval returns to its floor only after a
session that LASTED. Resetting on connect is how a rolling restart, where every
dial succeeds and dies moments later, becomes a retry storm against the first
replica back up. Nothing is assumed to survive a reconnect: the credential is
read at dial time, never captured.

And the credential is now real. The tunnel endpoint advertised authenticating a
worker against its own secret, but registration stored the hash of the shared
registration token, so a leak plus a known node ID still opened a tunnel.
Registration now mints a per-node secret, returns the plaintext once as
tunnel_token, and stores only its SHA-256 in a new column; the endpoint compares
against that and does not fall back to the old one. Rotating on every
registration follows from storing only the hash, since a re-registering worker
cannot be told the secret it already holds; its live tunnel is unaffected,
because the credential is checked when a tunnel is dialled and never again.

Unlike the agent API key and the NATS JWT next to it, the credential IS issued
to a node awaiting approval: the tunnel route re-reads the node's status on
every dial and refuses a pending one, so it is inert until an admin acts, and
withholding it would strand every worker that registers exactly once.

A node that has not registered since this change cannot tunnel, and the column
cannot be back-filled because the plaintext only ever existed in the response
that minted it. The boot warning that said tunnels need LOCALAI_REGISTRATION_TOKEN
is replaced: it was true while the tunnel authenticated against that token's
hash, and says the wrong thing now. What is still true, and is what it warns
about instead, is that without one, registration itself is unauthenticated.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 12:28:00 +00:00
Ettore Di Giacinto 5b7d65e679 fix(testutil): clear the maintenance timeouts at connection startup
The guard added last commit was circular. It cleared the maintenance database's
timeouts by executing SET statement_timeout = 0 on a connection that had already
inherited that database's bound, so the statement clearing the bound ran under
the bound it was clearing. Under the white-box spec's deliberate 1ms that gave
it 1ms, and it failed roughly once in fifty at 8-way concurrency with SQLSTATE
57014. The guard against invisible load-dependent flakes had become one.

The clearing is now delivered as a connection startup option, options=-c
statement_timeout=0 -c lock_timeout=0 on the maintenance DSN, so there is no
statement left to abort. Raising the imposed bound would only have bought
headroom and left the circularity in place. pgx puts every URL query parameter
into settings, options is absent from notRuntimeParams so it becomes a runtime
parameter, and runtime parameters are copied into the startup message
(pgconn/config.go:340-378, 606-617; pgconn/pgconn.go:382-388).

The spec now discriminates on pg_settings.reset_val, the value in force when the
connection started: 0 for a startup option, 1ms for a session SET. A first
attempt using a deliberately slow first statement did NOT discriminate, because
under the circular design the SET is itself the first statement, so by the time
a spec runs anything the session is already unbounded. Reinstating the circular
clearing now reddens the spec deterministically rather than intermittently.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 11:43:23 +00:00
Ettore Di Giacinto a816bf9b84 fix(testutil): stop the shared-database change from disarming two regressions
Two advisory-lock specs named their database by literal, ALTER DATABASE testdb.
Once the test helper started handing every spec its own database on a shared
server, that statement landed on the maintenance database and did nothing to the
one the spec was holding, so both specs went green having never reproduced the
condition they exist for. They regress a model-load advisory-lock wedge that has
already shipped to production once, so the previous commit's de-flaking silently
disarmed a regression test for a real deployed bug.

Both sites now read the name back with current_database() and, more importantly,
assert the override actually landed before relying on it. A literal name can go
stale again; an assertion that the setting is in force cannot pass while it is
not. Removing either production override now fails the matching spec with the
real 55P03 and 57014 again.

That literal also meant every CREATE DATABASE and every DROP ... WITH (FORCE)
ran under the 300ms bound it set on the maintenance database, which is a new
load-dependent single-spec flake inside the change that was meant to remove one.
The helper's maintenance connections now pin one connection and clear both
timeouts on it, so no setting a spec makes can bound them, and a white-box spec
imposes the leak deliberately and proves it does not reach them.

Also pins the reclaimOne gate deferral the previous commit added without a test,
by panicking inside the re-claim's own claim statement, and drops the per-dial
empty-token log line to debug now that the boot warning says it once.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 11:17:48 +00:00
localai-org-maint-botandmudler aa1f0f8599 chore: ⬆️ Update mudler/vllm.cpp to 839ea1ceddb787778b6bd86a38a917a1aab74d8f (#11817)
⬆️ 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-09-01 13:00:02 +02:00
localai-org-maint-botandmudler 1da0dd7de2 chore: ⬆️ Update 0xShug0/audio.cpp to 3497b7cc44753e2c141d8fe60ac42cec433e3281 (#11818)
⬆️ 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-09-01 12:43:04 +02:00
localai-org-maint-botandmudler 34e9ad0cec chore: ⬆️ Update CrispStrobe/CrispASR to 78c545eb80409b91291642ddb23b3a6dc044fd34 (#11811)
⬆️ 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-09-01 12:38:24 +02:00
localai-org-maint-botandmudler 06ebc28ae6 chore: ⬆️ Update mudler/depth-anything.cpp to 02ba082274e001a63e50de5a1eb0ccc50c6af4b1 (#11810)
⬆️ Update mudler/depth-anything.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 12:37:10 +02:00
Ettore Di Giacinto 48ece89c63 fix(cluster): harden the worker tunnel, and stop starting a database per spec
Review follow-up. Twelve findings, none blocking, grouped here by what they
protect.

Panics. The handler now recovers between the WebSocket upgrade and the
hand-off, the way the peer link next door already did: net/http recovers the
panic but leaves the hijacked socket open, so without this a worker keeps a
session this replica has no entry for and will never detach. The claim gate in
Attach and reclaimOne is now released with defer, so a panic under Claim cannot
wedge one node's gate for the life of the process. SetTunnels gained the
nil-receiver guard its sibling Stop has.

Operability. A deployment with no registration token stores an empty token_hash
on every worker, so every tunnel dial 401s forever on a frontend that looks
correctly configured. That now warns at startup, logs its own line rather than
sharing the "wrong token" one, and is stated in the docs together with the fact
that setting the token later needs the workers to register again.

Authorization. A node still awaiting admin approval is refused with 403. The
rest of /api/node/ gates on nothing, but the two places that hand a node
something durable, its API key and its NATS credential, both refuse a pending
one, and a tunnel is that kind of grant. Draining and unhealthy nodes keep
their tunnels on purpose.

Comments that claimed more than the code. The global auth middleware does run
on this path and then declines to reject; the future per-node secret only lands
without a change here if it lands in TokenHash; the empty-hash guard is
defensive rather than deciding; ClusterPathPrefix is no longer only
replica-to-replica; the docs no longer say a reaped replica re-claims
unconditionally.

And the test harness. SetupTestDB started a PostgreSQL container per BeforeEach
with a readiness deadline it asserted on, which is one chance per spec to fail
one spec inside its setup, anywhere, never twice in the same place: the shape of
the flake seen twice here and never reproduced. It now starts one container per
process and creates a database per call, which is the pattern tests/e2e already
proved. Isolation is unchanged and is now asserted for the first time. All 69
call sites are untouched; the eleven consumer packages run 1404 specs green, and
jobs went from 34.3s to 3.3s, agents from 13.8s to 1.9s, cluster from 97.4s to
37.5s.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Opus 5 [claude-code]
2026-09-01 10:36:10 +00:00
Ettore Di Giacinto 6e55092a4b feat(cluster): open the door a worker dials its tunnel through
A worker needs no inbound port: it dials GET /api/cluster/connect, the
connection becomes one multiplexed yamux session, and the frontend opens a
stream on it per request. This adds the endpoint that accepts that dial and
attaches it to the tunnel registry.

The dial is authenticated against the NODE's own stored token hash rather than
the deployment's registration token. That is the mechanism, not yet the
isolation, since a worker still registers by presenting the shared token; what
it rules out is the shortcut of comparing against the configured value, which
would have to be unpicked the day workers get their own secrets.

Every refusal happens BEFORE the WebSocket upgrade, so a dialer reads an HTTP
status rather than a handshake error. The route is registered in every
deployment, single-binary ones included, which is what puts it in front of the
route-coverage test that holds that rule in place; with no node registry it
refuses every dial, and tells a credentialed one the frontend has no cluster
rather than that its token is wrong.

A lookup that FAILED is answered as a failure. Reporting a database that could
not be read as "unauthorized" would send a worker re-registering, throwing away
the identity its tunnel and loaded models are keyed by.

Wires the tunnel registry in core/application/distributed.go and hands it to
the membership loop. Without that call the re-claim after a replica is reaped
had no production caller and could never run.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Opus 5 [claude-code]
2026-09-01 09:42:41 +00:00
Ettore Di Giacinto 62476e553e fix(cluster): keep a session close out of the per-node claim gate
The gate is justified by being held for one claim round trip, and Attach
held it across the close of the session it superseded. Closing a yamux
session closes the underlying conn and then waits for both its send and
recv loops to exit, and the send loop can be inside a write bounded only
by ConnectionWriteTimeout, so that is a wait on other goroutines. It must
not stand between a worker re-dialling this node and its claim.

The gate is now released after the store and before the close, which also
makes Attach match reclaimOne, where it has always been released
explicitly on every path. This is safe because a superseded session is no
longer reachable from the map by the time it is closed: the next re-dial
replaces an entry that already names the new session.

Pin the re-claim half of the gate too. A worker that re-dials between a
re-claim's commit and its record leaves the row carrying the re-dial's
epoch while the entry carries the re-claim's, so the attachment holding
the socket releases an epoch the row does not have and the row outlives
it, with nothing to sweep it while this replica is alive. Only Attach's
half of the serialisation was asserted; keying the two apart left every
spec green.

Also take the test hook's action under the lock that guards whether it
has fired. It was written from the spec's goroutine and read from
whichever goroutine issued the statement, which is a race in the harness
that pins the serialisation specs.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 09:06:42 +00:00
Ettore Di Giacinto 63ed55a929 fix(cluster): make a claim and its record indivisible per node
Two Attach calls for one node both claim, and PostgreSQL serialises the
two upserts, but nothing ordered the two map writes against the two
commits. The entry left installed could be the one whose claim lost the
row, and its Detach then released an epoch the row does not carry, so the
release matched nothing and the row survived the socket.

Nothing swept that row. This replica is alive and heartbeating, so
ReapStale leaves its rows alone, and no reconnect is coming for a worker
that has gone. Owner kept naming this replica as the live owner of a
tunnel it no longer held, and every dialer sent here was answered
ErrNotOwner, which is the relay into a replica that cannot serve the
request that this phase exists to prevent.

Claims for one node now pass through a gate, so claim and record are
indivisible. It is per node rather than one lock over the registry, the
way PeerPool locks per peer: the claim is a database round trip, and a
slow one for a single worker must not hold up Open for every other.
Detach is not gated, because it takes no context and must never park
behind an in-flight database call, and it changes no epoch.

Reclaim takes the same gate, which makes its claim the newest one for
that node, so it records the epoch on whatever attachment is installed
rather than only on the one it listed. Refusing to record onto an
attachment that replaced the listed one would leave that row with nothing
able to release it. The interleave the gate does not cover is Detach, and
a claim whose attachment detached while it was in flight is now released
again rather than left behind.

Also: restore Start's doc comment, which SetTunnels had swallowed; keep
reaping other replicas when this one fails to rebuild its own row, rather
than skipping the sweep along with the re-claim; scope the comment about
an unnoticed dead socket to the keepalive of the session whoever accepted
the tunnel built, since the worker session config does not exist yet; and
pin the sortedness of Held, the nil-session refusal, and both re-claim
interleaves with specs.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 08:32:51 +00:00
Ettore Di Giacinto f3ba1f692b feat(cluster): hold worker tunnels, and re-claim them after a sweep
Phase 1 left the connection fence with a table and no sockets behind it.
This adds the registry that holds them: Attach claims the node and then
stores the session, Open hands out a stream over the tunnel this replica
holds, Detach releases the claim it was handed, and Held names what this
process is carrying.

The claim is written before the session is stored. A claimant that
installs itself and only then finds it cannot claim has, for that window,
published a tunnel no row records, so Held names it while a peer asking
Owner is told the worker is connected nowhere.

ErrNotOwner is produced at one place, the map miss. It is a routing fact:
some other replica may hold that worker perfectly well. A broken socket
under a held entry is returned as itself, because answering "not held
here" would send a dialer looking elsewhere for a worker this replica is
holding.

Epochs are compared for equality and never ordered. Claim guarantees an
epoch is unique and never reissued; it does not guarantee the later claim
draws the larger number, because the sequence value on the insert path is
drawn before the row lock.

The membership loop now re-claims on re-register, which closes the hole
phase 1 named in ReapStale. A replica that stalls long enough is swept by
a peer, losing its instance row and, in the same transaction, every
connection it owned; Register rebuilds the instance row and nothing else,
so without this it serves workers that every other replica reports as
connected nowhere. Re-claiming draws a fresh epoch, so an attachment
carries two: the token Attach handed back, which is what Detach matches
and which never moves, and the epoch of the row currently held, which is
what Release is given. Collapsing them would leave the re-claimed row
outliving the socket with no caller able to remove it.

A tunnel whose session is already closed is skipped rather than claimed
back, because claiming is an upsert and would take the row from whoever
holds the worker now.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 07:39:49 +00:00
Ettore Di Giacinto e4777915f9 fix(cluster): make the ownership comments say only what holds
Review round 1 on the joined Owner read. The behaviour was accepted; three
comments claimed more than the code delivered, one spec pinned less than its doc
promised, and one pre-existing spec ranked epochs.

instanceIsLive said every reader of instance liveness uses it, which was false:
ReapStale spelled the complement by hand. The complement is now written as
NOT (instanceIsLive), so "stale" is exactly "not live", including how each side
treats a NULL last_seen, and the sentence is true. Inverting that predicate
fails 3 reaper specs, so the routing is held.

The Select("node_connections.*") in Owner was justified by a SELECT * hazard
that cannot occur: with a join present and nothing selected, gorm expands the
model's own columns table-qualified (callbacks.BuildQuerySQL), and the suite is
green with the Select removed. It stays, because the projection should be a
property of this query, and the comment now says that instead.

Owner gained the dialect guard Claim has. now() and make_interval are
PostgreSQL, so on the SQLite single-binary path it failed with "no such
function: now", which reads as a missing migration; that regression already
shipped once in phase 1. The refusal is deliberately not ErrNoConnection: a
deployment with no cluster has no answer about ownership, and reporting absence
would let a caller conclude the worker is not connected. A spec in the
non-PostgreSQL block holds all three properties.

The new specs aged rows by ten minutes, which any window between zero and ten
minutes satisfies, so nothing tied Owner's window to the one the sweeper uses.
They now age to just past InstanceLiveness, and a sibling ages to half of it and
must still resolve. Widening the window tenfold fails 2 specs, narrowing it
tenfold fails 1; before this both were silent.

The concurrent-claim spec asserted the stored epoch was the highest handed out,
and justified it with claims drawing their epoch after the row lock, which
contradicts Claim's own doc: the insert path draws nextval while the tuple is
built. It now asserts the stored epoch is one of the epochs handed out, and
ranks nothing.

OwnerRow's doc justified the function with a sweeper that does not call it.
ReapStale deletes orphans with a set difference; the callers are this package's
specs and one e2e assertion. It says that, and states plainly that a caller
needing to know who owns a node in order to dial it wants Owner.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 07:09:18 +00:00
localai-org-maint-botandEttore Di Giacinto b4e16b963f fix(ci): stop the e2e teardown from failing a green suite (#11816)
* fix(ci): remove the e2e container before removing its image

`docker stop` returns as soon as the container exits, but the daemon
reaps a `--rm` container asynchronously after that. The `docker rmi
localai-tests` that follows teardown-e2e then loses the race against the
reaper and fails with "conflict: ... is using its referenced image", so
make exits 1 and the job goes red after every spec has passed.

This is why the E2E Backend Tests job fails at random across pull
requests. Runs 33435319093, 33435332991, 33412165884 and 33444669207 all
report "SUCCESS! -- 235 Passed | 0 Failed" and then die in teardown.

`docker rm -f` is synchronous, so the image reference is gone before
teardown-e2e returns. It also covers the case where no container is
running, which `docker stop` could not because it rejects an empty
argument list.

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

* fix(ci): open a tmate session only when a PR asks for one

The tmate step runs on every failure and then holds the runner until
GitHub cancels the job at the 6 hour limit. A one second cleanup race in
the e2e teardown therefore costs a whole ubuntu-latest slot. The recent
run list is full of 6h, 7h and 12h cancelled runs for that reason.

The step now needs the `ci-debug` label on the pull request, so a
session opens when somebody wants to debug and never otherwise. The
30 minute step timeout caps the cost when the label is left behind.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 08:55:35 +02:00
Ettore Di Giacinto 5e2938ebf0 feat(cluster): resolve tunnel ownership against a live owner
OwnerRow is a bare row read of node_connections. A connection row outlives the
replica that wrote it: a replica that dies stops heartbeating, but its rows
survive until a peer's sweep removes them, which is up to InstanceLiveness plus
one InstanceHeartbeat later. For that whole window the table names a process
that is gone. The next component phase 2 builds is the relaying dialer, and a
dialer reading OwnerRow would relay into a corpse for roughly 35 seconds after
every replica death, then report the worker as unreachable when it is in fact
absent, which is the distinction the phase 1 end-to-end specs pinned.

Owner is the resolving read: one statement joining instances, returning
ErrNoConnection when the row is missing OR its owner is not live. Both cases are
one answer on purpose, since both mean no replica here holds this tunnel; they
differ only in which sweep has run. It is one statement, not a row read followed
by an instance lookup, because between two statements the owner can die and the
caller would act on an owner the second read would have rejected.

OwnerRow stays, unjoined, for readers that need the row itself, and a spec holds
the two apart: with an aged-out owner, OwnerRow still names it and Owner
refuses, so neither can quietly become the other.

The liveness predicate is now one string, instanceIsLive, shared by Live and by
Owner's join. Two spellings of one fact drift, and this drift would show as a
relay to a replica one query calls dead and another calls alive. It is
table-qualified so it is unambiguous inside the join, and the cutoff stays on
the database clock, so replica clock skew cannot widen or narrow the window.

Both mutations were run. Dropping the liveness predicate from the join fails 3
specs, the aged-owner one among them. Replacing the database clock with a
Go-side time.Now() fails 1: the aged-owner specs still pass, because the two
clocks agree on one host, and only the recorded-SQL spec sees the literal
timestamp. That is why that spec exists.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 06:46:02 +00:00
localai-org-maint-botandmudler 834473a46d chore: ⬆️ Update 0xShug0/audio.cpp to bf3315fe4aaa16dc1125f580c29aff90a8900b36 (#11794)
⬆️ 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-09-01 08:34:36 +02:00
Ettore Di Giacinto 0d13056d53 fix(cluster): share one lock order, and correct the phase 1 comments
ReapStale deleted from instances then node_connections while Deregister took
them the other way round, both inside one transaction and both running
concurrently by design: a replica shuts down while a peer sweeps it. Opposite
orders let each hold the row the other waits for. PostgreSQL breaks the cycle by
aborting one side, so the cost today is a warning rather than lost data, but the
inversion costs nothing to remove.

Deregister now deletes the instance row first. That is the order ReapStale is
forced into anyway, since its connection delete asks which instance rows
survived, so the sweeper is the fixed side. Both functions say the order is
deliberate and shared, and name the other. A spec records the statements each
path issues and asserts they delete from the same two tables in the same order;
racing two transactions until they really deadlock would be flaky and could pass
for the wrong reason.

The rest is comment and spec accuracy, deferred from the phase 1 task reviews:

- co-location does not imply loopback. Compose's usual host=postgres resolves to
  a bridge address and discovery works there; it is a DSN that NAMES localhost
  that yields a loopback source address. Corrected in the DiscoverAdvertisedAddr
  doc and in the spec comment that repeated it.
- unroutableReason labelled every scoped address "link-local", including the
  class the check exists for, and formatted the IP with %s, which drops the
  %iface, so the reported address was not the one being rejected. Split into two
  cases, both rendered with their zone. CheckAdvertisedAddr passed zone "" and
  net.ParseIP rejects fe80::1%eth0, so a scoped literal looked like a name and
  collected no warning at all; the zone is now split off before parsing.
- Splice's "Both callers satisfy it" claimed callers that still do not exist.
  It now names the two stream types the wake-on-Close property was verified
  against and says a phase 2 caller over anything else has to check it.
- restored, short, why a socket-level ECONNRESET stays reported while a yamux
  reset does not: the yamux endings are the teardown Splice's own Close
  provokes, and whether an aborted request is routine is the relay's policy.
- the real-yamux spec's far.Read had no deadline, so a stall parked the suite
  rather than failing it.
- gorilla's SetWriteDeadline is conn.go:796, not 787.
- ClusterPathPrefix is no longer derived from: the peer route spells its path
  out, because core/services/cluster must not import core/http/auth. The comment
  now points at the spec that holds them together instead of claiming a
  derivation the move removed.
- the epoch spec asserted e2 > e1, an ordering Claim's doc tells callers not to
  rely on. It asserts uniqueness, which is what the fence guarantees, and is
  named for that. A sibling spec still described the epoch as incrementing in
  SQL when it is drawn from a sequence.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 03:26:36 +00:00
Ettore Di Giacinto e26d556594 fix(cluster): hold the guarantees phase 1's comments were claiming
Review found the recurring class: assertions that a wrong implementation
also satisfies.

The "refuse promptly, never park the peer" guarantee was stated in three
places and tested in none. Removing the Close from the no-relay branch left
the whole cluster suite green, because the specs asserted only that some
error arrived and yamux reports a read deadline as ErrTimeout: a parked
stream satisfied that as well as a refused one. Both specs now require an
ENDING, EOF or a reset, inside a deadline short enough that parking is
unmistakable, and both go red when the Close is removed.

Deregistration existed only in a comment. Membership.Stop ended the loop and
left the row behind, so every clean rolling restart had peers dialling a
corpse for the full liveness window; the shutdown comment described the
opposite. Registry.Deregister deletes the row and the connections that
replica owned, in one transaction, for the reason the sweeper does both, and
an e2e spec pins departure inside a budget shorter than the liveness window
so it cannot pass on the sweeper doing the work. Before: the spec times out
with both replicas still live. After: 3.6s.

The configured advertised address bypassed every check discovery makes, so
the one value most likely to be copied between hosts, 127.0.0.1, was taken
verbatim and would make every peer dial itself. Both paths now share one
rejection rule: unparseable is refused, "this host" is warned about once and
honoured, because a single-host deployment uses it correctly.

Two comments claimed more than the code does. The sweeper said a stalled
replica recovers via re-register; only its instance row does, while the
connections another replica reaped stay gone and the sockets stay held here
- phase 2 must re-claim, on re-register, every connection a replica still
holds locally. And Owner became OwnerRow, documenting that the owner it
names may be dead for up to InstanceLiveness plus a heartbeat and that any
caller acting on it must join instances itself, so the deferred constraint
lives at the call site rather than in a report; the plain name is left free
for the joining version.

Minors: warn once when the peer link mounts with no registration token, so
an operator sees the cause rather than 401s; Stop no longer blocks forever
when Start was never called; corrected the NewRegistry migration doc and an
e2e comment that described a 6s window as "throughout".

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 03:08:33 +00:00
Ettore Di Giacinto aca383d263 feat(cluster): give phase 1 a call site, and prove it against real replicas
Tasks 1 to 5 built an instances table, a splice, both halves of a peer link
and an epoch fence, and nothing in the tree called any of it: no replica
registered, no route was mounted, no sweeper ran. Proving phase 1 end to
end therefore had to start by wiring it.

A frontend in distributed mode now publishes the address its peers dial,
heartbeats it, and sweeps replicas that stopped answering along with the
connection rows they owned, in one pass so the two can never disagree about
who is alive. It serves the peer link and owns the sessions peers dial in,
refusing streams on them until phase 2 installs a relay: a session nobody
accepts on does not fail a peer's Open, it hangs it.

The address is the one peers use, not the one the process binds, and it is
derived from the route to PostgreSQL. That derivation only holds while the
database is remote, so LOCALAI_DISTRIBUTED_ADVERTISE_ADDR sets it
explicitly and a replica that can determine neither warns and keeps
serving rather than failing to start.

Three e2e scenarios run against real local-ai processes, real PostgreSQL
and real dials: replicas publish addresses that can actually be connected
to; a sibling opens a stream over the peer link and is refused without the
cluster token; and a killed replica is reported unreachable, never absent,
loses the claim it held, and takes no worker with it. Each was verified by
mutation: eight injected defects, each failing the scenario that claims to
catch it.

Also moves RegisterClusterRoutes to core/http/routes beside every other
registrar, folds AutoMigrate and the epoch sequence into one
cluster.Migrate, and turns the peer route's auth-coverage spec into a real
assertion: it drives the request through the actual auth middleware
instead of comparing two string constants, which the old spec would have
passed even with the exemption deleted.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 02:28:42 +00:00
Ettore Di Giacinto 667ce1d9a9 fix(cluster): keep the connection schema migratable on SQLite
The NodeConnection model carried `default:now()`, which is PostgreSQL syntax
reaching the DDL, so AutoMigrate failed on the single-binary SQLite path and
took every SQLite caller of nodes.NewNodeRegistry down with it. Stamp the
database clock as an expression inside Claim instead, the way Register
already does, and leave the column plain.

CREATE SEQUENCE is Postgres-only for the same reason, so it is skipped on
another dialect, and Claim refuses that dialect outright: a fence that cannot
draw a token must say so rather than fail later as a missing function.

Also correct a claim the previous commit made in both the doc comment and its
message. An epoch is unique and never reissued, but it is not ordered: the
insert path draws its sequence value before taking the row lock, so a claim
that inserts after a release can be handed a lower number than one already
issued. Uniqueness is what Release needs, since it matches by equality;
callers must never compare epochs for order.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 01:28:27 +00:00
Ettore Di Giacinto 7f1599e83d fix(cluster): draw connection epochs from a sequence so none is ever reused
Release deletes the row, so a per-row `epoch + 1` restarted the numbering at 1
for the next claim. A replica could then be handed an epoch it already held:
claim w1 at epoch 1, lose the link silently, watch another replica claim and
release, reclaim and be handed 1 again, and its delayed cleanup for the first
dead link would match the live claim and delete it. The fence has to be
unique per node over time, not per row lifetime.

Every claim now draws nextval from a dedicated sequence on both the insert and
the conflict paths, so an epoch is never issued twice. The draw still happens
after the row lock on the conflict path, so the winning claim still holds the
highest epoch handed out.

Also drop last_seen. Nothing maintained it and it was always equal to
connected_at, but an indexed column named that way invites a second liveness
clock; whether the owner is alive is Instance.LastSeen, and whether a claim is
current is the epoch.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 01:09:49 +00:00
Ettore Di Giacinto d9288513e6 feat(cluster): fence worker-connection ownership with a monotonic epoch
A worker whose link is silently broken reconnects to another replica while
the old owner's socket has not yet noticed. Without a fence both believe
they own it. Claim is a single atomic upsert returning the new epoch, and a
release must match both owner and epoch so a stale owner cannot delete a
live claim.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 00:50:11 +00:00
Ettore Di Giacinto d73730b545 refactor(cluster): make the cluster service a leaf and blame the caller's deadline
The peer link's WebSocket adapter and route constant lived in
core/http/endpoints/cluster, so the dialler in core/services/cluster had to
import an HTTP endpoints package to reach them. That pulled echo, core/http/auth
and core/config into a package whose doc says it is deliberately free of such
dependencies, and it made core/services/nodes reach an endpoints package
transitively. It also has no way forward: the worker-connect handler needs the
tunnel registry and the node token store, both of which are cycles from there.

Move WebsocketConn and PeerPath into core/services/cluster and let the endpoints
package import it, which is the direction the rest of core/http flows. The route
and the auth exemption still cannot drift apart, now asserted where both are
visible rather than by a const reference across the boundary, and the assertion
is stronger than the one it replaces: it pins the route under the prefix instead
of pinning the prefix's spelling.

Also guard the fresh-dial path with ctx.Err(), mirroring the cached path. A
caller with a 300ms deadline dialling a live, listening peer was told the peer
was unreachable, which would be enough for one impatient client to get a healthy
replica routed around once the relay consults these errors.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 00:29:23 +00:00
Ettore Di Giacinto e7aac9b52b feat(cluster): dial and pool yamux links to peer replicas
Distinguishes a peer missing from the registry from a peer that will not
answer: the second must never be readable as node absence, or a network
hiccup between replicas evicts healthy workers.

The distinction is a property of the error type rather than of the call
sites. The unreachable error formats its cause into its message and keeps
it out of its unwrap chain, so an ErrInstanceNotFound picked up on the
dial path cannot reach a caller's absence check.

One yamux session is cached per peer and re-dialled when OpenStream on it
fails, which is how both a dead transport and a graceful remote go-away
arrive. A reset of one stream never reaches the pool, so an abandoned
request cannot cost every other worker its link.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 00:09:15 +00:00
Ettore Di Giacinto 78958beef1 fix(cluster): own the peer auth prefix in auth, and pin what the specs claimed
The prefix constant moves to core/http/auth beside the check that uses it, and
the endpoints package derives its route from there. Seven sibling endpoint
packages already import auth, so the previous direction would have deadlocked
the build as soon as this one registered in RouteFeatureRegistry, and it was
dragging echo, gorilla/websocket and yamux into unrelated service packages.

Four properties were argued in comments and held by nothing. Flipping the
empty-token check to fail open, making SetWriteDeadline a no-op, returning a
zero-length read for a zero-length message, and dropping the recover around the
callback all left the suite green. Each now fails a spec that asserts the
behaviour rather than the setter's return value.

SetWriteDeadline takes the write mutex because gorilla keeps that deadline in a
plain struct field applied at the next flush; SetReadDeadline must not take the
read mutex, since it goes straight to the net.Conn and would otherwise block
behind the read it exists to unblock.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 23:44:11 +00:00
Ettore Di Giacinto 5847f6ee80 fix(cluster): stop reading a bare EOF as a clean ending
A dead yamux session does not always arrive wrapped. Session.close publishes
shutdownErr and closes shutdownCh before it force-closes the streams, so a
Write or Close landing in that window gets the raw cause back instead
(session.go:507-510, 528-533), and for a peer that vanished the raw cause is a
bare io.EOF. The generic io.EOF clause then reported the dead session as a
clean completion.

Remove the clause. A clean read-side EOF never reached it anyway: io.Copy
consumes that and reports nil, and neither *yamux.Stream nor *net.TCPConn
takes a WriteTo/ReadFrom path that would hand one back. Every existing spec
still passes, the io.EOF entry in the normal-termination table included, which
is what showed the branch was dead for legitimate endings and live only for
the bug.

Add a spec driving a real yamux session end to end. Every mux shape until now
was a synthesized error, which is exactly why a race inside the real library
stayed invisible.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 23:19:25 +00:00
Ettore Di Giacinto e957ff1ca2 feat(cluster): accept authenticated peer links on /api/cluster/peer
Upgrades to a WebSocket, wraps it as a yamux server session and hands it to
the caller. Rejects before upgrading so an unauthenticated dial sees a 401
rather than a WebSocket error, which is what the route-coverage test asserts.

The adapter keeps the reader of a partially consumed message across Read
calls. yamux reads through a 4 KiB bufio.Reader, so a small-payload test
cannot see a dropped message tail; the framing specs drive the adapter
directly with buffers smaller than the message.

An empty configured token authorizes nobody here, unlike the worker file
transfer server's check: this route is registered in every deployment, so
failing open would publish an unauthenticated mux.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 23:14:37 +00:00
Ettore Di Giacinto 8ebc24194c fix(cluster): report a dead yamux session instead of swallowing it
Matching yamux errors with errors.Is was too broad. Session.close hands every
live stream ErrStreamReset wrapped around whatever killed the connection, so a
keepalive timeout, a broken TCP connection or a peer that simply vanished all
matched, and a relayed request that died reported a clean ending. Nothing
upstream would have retried or logged it.

Match the plain sentinels by identity, since only identity separates a stream
that was reset from the wrapped form that means the session died. Treat a
StreamError as a per-stream reset, and a GoAwayError as normal only when it
carries the no-error code, read off ErrRemoteGoAway because the constant is
unexported. ErrSessionShutdown needs no entry of its own; it is a GoAwayError
with that code.

Order matters as much as the matching: session death wraps its cause, which is
routinely io.EOF or a closed socket, so the mux checks run before the generic
endings. Reversing them alone puts a vanished peer back to nil.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 22:49:57 +00:00
Ettore Di Giacinto 77e2041249 fix(cluster): treat a yamux teardown in Splice as a normal ending
go-yamux/v5 matches none of its errors against net.ErrClosed, so the
classifier reported an ordinary teardown as a failure: when the session has
gone away, the FIN that Splice's own Close writes returns ErrSessionShutdown,
and a stream torn down under a live copy surfaces as ErrStreamClosed or a
reset. Splice owns that Close, so it owns the errors it produces; the
sentinels are named here rather than injected by the caller, which would make
a forgotten classifier reintroduce the same bug silently.

Cover the error half of the contract, which no in-memory pipe could reach: a
scripted stream now feeds Splice a genuine transport failure and each
closed-stream ending in turn. Replacing the tail of Splice with "return nil"
passed every previous spec.

Also assert that Splice does not return until the second direction has
finished, rename a spec that promised a leak check it never made, and correct
two comments that claimed more than the code did.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 22:33:54 +00:00
Ettore Di Giacinto 6ce3edaa43 feat(cluster): add the bidirectional splice used by the relay and tunnel
Returns on the first direction to finish and closes both sides so the other
unblocks; a sequential copy deadlocks on any protocol where the far side
speaks first. EOF and use-of-closed are normal termination, not errors.

The fourth spec covers a peer that stops reading mid-body, the case where a
copy is parked in Write rather than in Read. The other three tear down an
idle splice and pass even against a Splice that closes only one side.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 22:19:05 +00:00
Ettore Di Giacinto 6ef642f60d fix(cluster): stamp liveness on the database clock and refuse undialable addresses
DiscoverAdvertisedAddr promised to return an error rather than a fallback
no peer can dial, but only rejected an unspecified address. With PostgreSQL
on the same host or pod as a replica, which is compose, single-node and any
sidecar layout, the route to it is loopback, so every replica advertised
127.0.0.1 and a peer dialling that reached itself. Loopback, link-local and
zoned source addresses are now rejected with an error naming the remedy, and
a port outside 1-65535 is rejected before it becomes an undialable address.

Liveness was also measured on each replica's own clock: Register and
Heartbeat stamped last_seen from the Go process, and Live compared those
rows against the reading replica's time.Now(). Skew therefore shrank or
stretched the window by writerBehind+readerAhead, evicting healthy peers or
keeping dead ones. Both sides now use the database clock, which is the one
clock every replica demonstrably shares.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 22:08:42 +00:00
localai-org-maint-botandmudler 2dcd853a2b chore: ⬆️ Update mudler/vllm.cpp to 6a544bdb89eb5a3512ac922241439e45f24d74d4 (#11797)
⬆️ 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-09-01 00:06:49 +02:00
Claudio Maradonna 2ad4238416 fix(ds4): separate prefilled reasoning from content (#11802)
DS4 appends the opening thinking marker to tokenizer-templated prompts, so generated text begins directly with reasoning bytes. Starting DsmlParser in TEXT therefore puts the reasoning and closing marker in visible content.

Start the parser in THINK for structured chat requests with thinking enabled in both Predict and PredictStream. Keep the default TEXT state for raw prompts and reasoning-off requests, and add incremental regression coverage.

Assisted-by: Codex:gpt-5

Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>
2026-09-01 00:06:23 +02:00
localai-org-maint-botandmudler 475dc254be chore: ⬆️ Update ikawrakow/ik_llama.cpp to 3c58ae373a0081c884099f435fb16ca720852bf7 (#11809)
⬆️ 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-09-01 00:06:05 +02:00
Ettore Di GiacintoandClaude Opus 5 5555a3c569 fix(nodes): skip checksum sidecars when staging option dirs
stageDirectory and countStageableFiles already skip them, but
stageOptionDir did not - and it is the path sherpa-onnx voices take for
espeak-ng-data. The receiver writes "<file>.sha256" for every file it
accepts, so staging the sidecars made it write sidecars for those in
turn, one level deeper on every load.

Observed on a live node: "<file>.sha256" repeated eleven times, 5077
junk files out of 7832 in the models dir, and still growing. Staging
never finished, so vits-piper-it_IT-paola-sherpa stayed permanently
"staging on node" and every realtime warmup needing that voice failed
with the session then going silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142UfUh8HWxdim5JZqf8Tr6
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 00:03:04 +02:00
Ettore Di Giacinto a3054442e2 feat(cluster): record frontend replicas in a shared instances table
Replicas need to find each other to relay worker traffic, and nothing in
the tree recorded a replica's address. The advertised address is discovered
by opening a UDP socket toward PostgreSQL and reading back the local
address, which yields the interface every replica demonstrably shares
without asking an operator to configure one.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 21:56:27 +00:00
Ettore Di Giacinto b13ebeaa1b docs(e2e): correct three claims in the distributed e2e comments
The closure note in cluster/failure.go quoted a Gomega error that Gomega
does not emit. Describe the argument-count failure and the
Eventually().WithArguments() hint instead, so nobody greps for a string
that never appears.

The advisory-lock note in cluster_failover_test.go called the wedge
window unbounded. A SIGKILLed local child closes its socket at once, the
Postgres backend reads EOF and is reaped in milliseconds, so the
mechanism bounds the window tightly. Say bounded, and keep the low
probability but real framing, which was right.

The workflow comment attributed HealthCheckInterval to
core/services/nodes/health.go. It is declared in
core/config/distributed_config.go:64; health.go only carries the ticker
on the unexported checkInterval. Point a debugger at the right file.

Comments only, no behaviour change.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 20:54:25 +00:00
Ettore Di Giacinto c5796d407f test(distributed): correct the claims the e2e comments make
Review of the whole branch found five comments that would send a reader to
the wrong place, plus three smaller inaccuracies. Nothing here changes
behaviour.

The KNOWN RACE note on both backend-log WebSocket handlers said the fix
needs an atomic snapshot-plus-subscribe "under the store lock". It does
not: BackendLogStore.mu guards only the buffers map, and AppendLine
enqueues and fans out under the per-buffer buf.mu. Whoever took the store
lock would ship and the race would survive, so both notes now name buf.mu
and say what s.mu does and does not exclude.

Two comments in the cluster harness quoted Eventually(c.FrontendAlive)
.Should(BeFalse()). FrontendAlive takes an index, so Gomega rejects that
with "requested 1 arguments but received 0". Both now quote the closure
form the specs actually use, and say why the closure is needed.

proveHealthCheckingIsAlive claimed to prove the health monitor ran for the
whole preceding window. It proves the monitor was alive at the end of it,
and inferring backwards needs any wedge to be sticky. In the
peer-replica-death spec that inverts: health checks are single-flighted by
a session-scoped pg_try_advisory_lock, the spec SIGKILLs the replica that
may hold it, and until Postgres reaps the session the survivor acquires
nothing and checks nothing silently. Consistently(healthy) can then pass
because nothing was checking, with the positive control still succeeding
once the lock frees. The doc now states what is proven, names that gap,
and says the assertion is a floor rather than a proof.

The Makefile still called DISTRIBUTED_TEST_FLAKES a retry count, which is
what seeded that error into the two docs just corrected against it, and
the workflow called the 15s window a reconcile tick when the mechanism is
HealthCheckInterval in the node health monitor.

Also: the cluster suite measured 509.1s / 509.8s / 512.3s, so about
8m30s and not the 8m39s/8m40s three files claimed; the dead-worker spec
title implied two independent detectors when both probes read one
advisory-lock-serialised verdict out of the same row; and the
sanitizeDBName length assertion used <= 50, which an empty string also
satisfies, where the invariant for an over-long input is exactly 50.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 20:47:33 +00:00
Ettore Di Giacinto 2c314d66d6 docs: correct the flake-attempts semantics and fill the review gaps
--flake-attempts is total attempts, not retries: ginkgo v2.29.0 sets
maxAttempts = FlakeAttempts and loops attempt < maxAttempts, and the flag's
usage string reads "0 - failed tests are not retried". At 1 there is no retry
at all, so "retries a failing spec once" was false in CONTRIBUTING.md and
implied in .agents/building-and-testing.md. Both now say each spec runs once,
and cite the source so the next reader need not re-derive it.

Also restores the React-UI stub rationale, which is load-bearing because a spec
asserting on a UI asset passes locally against a real dist/ and is served the
stub in CI; explains why 213 and ~240 differ; records that the workflow also
triggers on master pushes, where paths-ignore does not apply; and completes the
LOCALAI_E2E_REQUIRE_BINARIES value table, including that any unparseable value
reads as ON.

In .agents/ci-caching.md the stale "13 of those 20" figure now carries its
qualifier inline rather than in the following sentence.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 20:21:36 +00:00
Ettore Di Giacinto b0e01e9814 docs: document the distributed and cluster e2e suites
Two Make targets, a flake-budget variable and two environment variables
landed with no way to discover them. CONTRIBUTING.md now tells a contributor
how to run both suites, what each costs and which variables steer the cluster
one.

.agents/building-and-testing.md records the decisions that are easy to undo by
accident: suite-scoped containers, the shared NATS bus and what that means for
a new spec, BeforeSuite over SynchronizedBeforeSuite, the label split,
--fail-on-empty, the binary gate, the flake budget of 1, the coverage
exclusion, and why the cluster suite's long waits must not be shortened.

.agents/ci-caching.md lists tests-e2e-distributed.yml in its paths-ignore
inventory; the workflow already pointed readers there, so the cross-reference
was dangling.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 20:11:49 +00:00
Ettore Di Giacinto 73e7ef85b6 ci(distributed): fail the cluster job when it selects no specs
Ginkgo exits 0 when a label filter matches nothing, so a refactor that
renamed or dropped Label("Cluster") would have left the job reporting
"Test Suite Passed" having started no cluster. LOCALAI_E2E_REQUIRE_BINARIES
does not cover that case: it only fires inside a spec that is already
running. Add --fail-on-empty to both distributed targets.

Drop -r from test-e2e-cluster while here. All six Cluster specs live in the
top-level package, and the cluster subpackage contributes nothing under this
filter by design, so recursing only widened the blast radius. test-e2e-
distributed keeps -r: it must reach the eight argument-validation specs in
that subpackage.

Raise the cluster job to 45 minutes, matching its sibling. The 20 minute
Ginkgo timeout bounds the suite alone; the job timeout must also cover setup,
which is the larger and more variable half here: cold-cache module download,
protoc and protogen-go, a full build of ./cmd/local-ai and a separate test
compile, realistically 8-12 minutes on a 4-vCPU runner. At 25 minutes the
runner would have hard-killed the job before Ginkgo could report which spec
hung, which is the red-with-no-evidence outcome that gets suites disabled.

Also move upload-artifact to @v7 with the rest of the repo, and note on the
react-ui stub step that it must go if a spec ever asserts on a UI asset,
since a developer box has a real dist/ and would not catch that locally.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 20:00:14 +00:00
Ettore Di Giacinto 6620aa0937 ci(distributed): run the process-level cluster suite
Add test-e2e-cluster and a second CI job that runs it. The cluster specs
spawn local-ai as real child processes and kill them, so they need a built
binary; keeping them in their own job means the fast in-process suite is not
held behind that build.

The binary is built with a stubbed core/http/react-ui/dist. A single
index.html satisfies the go:embed in core/http/app.go, and this suite drives
the HTTP API only, so the job skips a Node and Vite install entirely.

The job runs serial and pins --flake-attempts 1. Each Ginkgo process would
otherwise get its own PostgreSQL and NATS container while every spec spawns
two or three children, and a retry would hide exactly the nondeterminism the
suite exists to catch. Measured at 8m39s over three runs, hence a 25 minute
job timeout and a 20 minute Ginkgo timeout.

LOCALAI_E2E_LOG_DIR points inside the workspace so the per-process logs
upload as an artifact on failure; they are the only way to read a cluster
failure. LOCALAI_E2E_REQUIRE_BINARIES is set explicitly even though CI
already implies it, because a skipped cluster spec is indistinguishable from
a passing one and this job's whole value is that it cannot go green without
starting a cluster.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 19:49:19 +00:00
Ettore Di Giacinto 58232a3f04 test(distributed): prove health checking was alive during the failover windows
The two specs that assert a healthy worker stays healthy were pure negatives:
they say nothing happened. A cluster whose health checking had wedged, by
leaking the advisory lock the monitor takes at health.go:110, would freeze the
roster and satisfy both while observing a corpse. Kill the worker once the
window closes and require the roster to settle it to offline, so the preceding
Consistently is a statement about behaviour rather than about a stopped clock.
Applied to the cold-restart spec as well as the peer-death one: a restart is
exactly the event that could leave a replacement unable to check anything.

Document the hazard that can make an offline assertion hang. The staleness
branch skips a node already marked unhealthy (health.go:153-155), a skip meant
for nodes an operator took down, which also swallows the flap: an unhealthy mark
landing after the heartbeat goes stale means MarkOffline is never called and the
node stays unhealthy forever. Name the file and line at the assertion, and have
the failure message say so when the roster shows a node stuck there, so a
timeout sends the reader to LocalAI rather than to the harness.

Stop calling the two-replica registration spec a race. Start spawns workers
sequentially and the registrations land about a second apart; it is a
shared-roster identity test, and saying otherwise invites someone to trust it
for something it does not check.

WorkerRegistrar now bound-checks its index like every other index-taking method
here. It answered 0 for an out-of-range worker, and 0 is a real frontend index,
so the failure mode was a spec killing the wrong replica.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 19:40:01 +00:00
Ettore Di Giacinto 875ff339ba test(distributed): cover replica death, rolling restart and racing replicas
Four scenarios with no prior equivalent: killing a replica must not disturb a
worker that never depended on it, a cold-restarted replica must rehydrate the
roster from shared state and keep accepting the worker's heartbeats, a dead
worker must settle to offline on every replica, and two replicas registering a
worker each must converge on one roster.

The timings are measured, not assumed. Node liveness is heartbeat freshness, so
the only eviction path is StaleNodeThreshold (60s) plus one HealthCheckInterval
tick (15s), and neither is reachable from the CLI. A worker whose registrar was
killed was observed going offline at 74.2s. Every window here is sized to
outlast that, because an assertion that expires before the system could have
reacted proves nothing.

Two assertions are deliberately unlike the obvious form. Statuses are compared
for equality against a probe that returns a sentinel on error, rather than
asserting a name is absent from the healthy list: the list probe returns nil on
any error, and "does not contain" is satisfied by nil, so a 401 at the second
replica would have passed while observing nothing. And a killed worker is
required to settle to exactly offline, because it first flaps to unhealthy at
~8s and back to healthy at ~14s, which any not-healthy matcher would accept.

SpreadWorkerRegistrations is new, off by default, and exists so the racing
spec is a race: the harness otherwise points every worker at frontend 0, which
would have left that scenario asserting on two sequential writes through one
process. The default is unchanged because the baseline specs depend on it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 16:28:10 +00:00
localai-org-maint-botandlocalai-org-maint-bot 357eabf382 feat(gallery): add Ornith 1.5 9B OBLITERATED (#11803)
Add Q4 and Q8 GGUF builds with their shared vision projector. The
model is a recent refusal-removed Ornith derivative for alignment and
red-team research.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-31 18:06:07 +02:00
ginavalent 04bca750d0 refactor(worker): iterate node label pairs with SplitSeq (#11806)
Signed-off-by: ginavalent <ginavalent@outlook.com>
2026-08-31 18:05:44 +02:00
Ettore Di Giacinto 737eb6c34c test(distributed): require the cluster binaries by default under CI
The previous round made a missing binary fail instead of skip, but only when a
workflow remembered to set LOCALAI_E2E_REQUIRE_BINARIES. That leaves the silent
pass one forgotten line away: the Cluster label partition is two specs, Ginkgo
exits 0 on skips, and a job that skips both reports "0 Passed | 2 Skipped" and
goes green having never started a cluster.

So the polarity is inverted. Binaries are required whenever CI is set, which
GitHub Actions always does, and the flag now exists to force the requirement
OFF rather than to be remembered ON. A local developer sees no change, since CI
is unset in an ordinary shell and a missing binary still skips with a message
naming the path and how to build it. off, no, n and disabled are honoured as
off; ParseBool rejects them, and reading a word that unambiguous as its
opposite would be a worse trap than the one this removes.

Also correct a claim the previous commit message got wrong. Comparing the
worker's registration id across the two replicas does not pin the topology:
NodeRegistry.Register looks a node up by name and preserves the existing id,
and both replicas read one Postgres, so registering the worker with every
frontend would yield identical ids too. The assertion is still worth keeping
for what it does catch, a replica answering from its own registry or database
instead of the shared one, and the comment now says that and nothing more.

The topology fact moves to where someone would break it: a note on
LOCALAI_REGISTER_TO recording that workers register with frontend 0 only, that
the cross-replica specs depend on it, and that nothing in those specs can
detect a change to it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 15:06:54 +00:00
Ettore Di Giacinto 2e4c731ea1 test(distributed): fail rather than skip the cluster specs in CI
The Cluster label partition is these two specs and nothing else, so a missing
binary skipped the entire job. Ginkgo exits 0 on skips, so a build step that
broke or moved its output would have left the job reporting "0 Passed |
2 Skipped" and going green without ever starting a cluster: the silent pass
this suite exists to make impossible. Skipping stays the local default, which
is the right courtesy for someone who has not run `make build`, but
LOCALAI_E2E_REQUIRE_BINARIES turns it into a failure that names the missing
path and the target that builds it. A value that is set but unparseable counts
as on, since reading it as off would restore the very skip it disables.

Failures also name themselves now. The roster poll kept returning a bare nil on
error, so a 401 at the second replica, a decode failure and "the worker never
registered" all presented identically as an empty list. It now retains the last
error and the last roster and reports whichever happened, through a lazily
evaluated Gomega description that costs nothing until something fails.

Finally, the two-frontend spec no longer depends on the harness to mean what it
says. It asserts an unauthenticated GET /api/nodes at frontend 1 is refused,
which observes the admin gate instead of assuming it, and it compares the
worker's registration id across the two replicas rather than its name. A future
harness that registered every worker with every frontend would have kept a
name-only assertion green while it quietly stopped proving anything about
shared state.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 14:54:23 +00:00
Ettore Di Giacinto 1f241fb310 test(distributed): prove the cluster harness with a two-replica baseline
Tasks 4 to 6 built a harness that runs local-ai as real child processes, but
none of it had ever started a process: every spec so far returned inside
argument validation. These two specs are the first to run it against a real
binary, a real Postgres and a real NATS.

Two frontends against one database both see a worker that registered through
only one of them. Every failover spec assumes this, so it is asserted first.

One admin session is minted at frontend 0 and reused for both replicas rather
than registering per frontend. The auth routes share a five-per-minute-per-IP
limiter and all e2e traffic is 127.0.0.1, so a session per frontend would
exhaust the budget as soon as a spec needs a third one. Reuse is sound because
sessions live in the shared Postgres and the harness pins one HMAC secret
across replicas; frontend 1 answering /api/nodes with 200 on a cookie minted at
frontend 0 is what proves it.

The binaries are resolved before SetupInfra so a missing build skips without
first provisioning a database the skip would then have to tear down.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 14:38:12 +00:00
Ettore Di Giacinto fc0fce8b7d test(distributed): correct the failure-primitive comments and guard the wipe
Review round 1. Comments only, plus one guard.

The note on Process.alive claimed the exited check closed the zombie window.
It does not. The reaper closes exited only after Cmd.Wait returns, and Wait
marks the os.Process done before returning, so exited being closed implies
signal 0 already errors and the branch cannot fire earlier than the one it
precedes. The window between the child exiting and waitid collecting it stays
open in both versions, and the only real mitigation is for callers to poll
with Eventually rather than sample once. Keep the check as hygiene, say what
it actually does, and say it again on the exited field, so nobody reads the
old claim and drops the Eventually.

Record what the cold wipe destroys. The harness sets no LOCALAI_STORAGE_URL,
so the object store is a directory under DataPath, and quantization and
fine-tune outputs live there too. Postgres keeps the job row; the artifact it
points at does not survive the restart. A spec that asserts otherwise will
fail for a storage reason wearing a failover costume.

Tell callers to let a graceful stop finish before restarting: RestartFrontend
terminates with SIGKILL, so pairing it straight after StopFrontendGracefully
cuts the drain short and silently converts the rolling-update case into the
crash case.

Refuse to wipe when the cluster has no work dir. frontendDataDir is relative
when baseDir is empty, so a Cluster built by some future test helper without
one would have RemoveAll walking frontend-N/data inside the source tree. The
guard sits before terminate, so a refusal leaves the cluster as it was.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 14:22:35 +00:00
Ettore Di Giacinto ce3f360219 test(distributed): add kill and restart primitives to the cluster harness
The point of running LocalAI as real child processes is to be able to take
one away. Add KillFrontend (SIGKILL, the lost replica), StopFrontendGracefully
(SIGTERM, the rolling update), KillWorker, RestartFrontend and FrontendAlive.

RestartFrontend pins the dead replica's original port. Workers read
LOCALAI_REGISTER_TO once at boot and never re-resolve it, so a replica that
returns on a fresh port is unreachable by exactly the workers that registered
with it and the failover under test never happens.

It also wipes the replica's data directory, so the process comes back with
empty local state and has to rehydrate node, session and job state from the
shared Postgres and NATS. Reusing the directory would model a pod with a
persistent volume and hide the class of bug these tests exist to find. That
is only safe because the harness pins LOCALAI_AUTH_HMAC_SECRET; otherwise the
wipe would take {DataPath}/.hmac_secret with it and every session minted
before the restart would 401 afterwards.

FrontendAlive consults the reaper's exited channel before signal 0: a child
that has died but has not yet been waited on is a zombie, and signal 0 to a
zombie succeeds, which would report a dead replica as alive.

The new specs cover argument validation only. Killing, stopping and
restarting a live process needs a built binary plus Postgres and NATS, so
those paths stay unexecuted until the failover suites land.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 14:12:35 +00:00
Ettore Di Giacinto 23a2bd5f1b test(distributed): give each frontend its own data dir and one pinned secret
Session rows are keyed by an HMAC of the token under a secret generated
per instance into {DataPath}/.hmac_secret. The replicas shared that
secret only because they shared a working directory, and that directory
was the source tree. Give each frontend LOCALAI_DATA_PATH under its own
baseDir and pin LOCALAI_AUTH_HMAC_SECRET, so a session minted at one
replica resolves at every other one by construction.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 14:03:58 +00:00
Ettore Di Giacinto 53cd640a89 test(distributed): add admin session helper to the cluster harness
The register handler answers 201 both for "user created, here is your
session" and for "this email already exists", so the status code cannot
tell a fresh registration from a repeat one. Key on the session cookie
instead and fall through to login when it is absent.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 11:25:47 +00:00
Ettore Di Giacinto a7847b8a37 test(distributed): make the cluster harness survive a restart
Restarting a frontend replica must not move it: workers read
LOCALAI_REGISTER_TO once at boot and never re-resolve it, so a replica that
returns on a fresh port is unreachable by the workers that registered with it.
startFrontend now takes the port, with <= 0 meaning "allocate".

Process logs are opened for append rather than truncated, so a restarted
process cannot erase the log of the instance that died, which is the log a
failover post-mortem needs. The post-SIGKILL wait is bounded, so one stuck
child no longer becomes a suite-wide timeout that names nothing. Stop is
nil-safe because Start returns a nil cluster after stopping itself.

Start's doc comment no longer claims to wait for worker registration; that
needs an authenticated admin session, so it now says callers must poll
/api/nodes themselves.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 11:18:49 +00:00
localai-org-maint-botandmudler 69a5b54c0a chore: ⬆️ Update CrispStrobe/CrispASR to 18b3e3f8456748a6380dc4c13817df244b695d39 (#11799)
⬆️ 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-08-31 13:11:15 +02:00
localai-org-maint-botandmudler bdf600ea2a chore: ⬆️ Update ggml-org/whisper.cpp to eacbd8234c6654cdbf2c377f72b2106875479bdc (#11796)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-31 13:11:05 +02:00
Ettore Di Giacinto c0af66a7eb test(distributed): add a process-level cluster harness
Runs local-ai as real child processes, one per frontend replica and one per
worker, against containerised infrastructure. The in-process suites cannot
express frontend-replica failure: there is no process to kill and no real HTTP
boundary between a worker and the frontend it registered with.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 11:10:04 +00:00
Ettore Di Giacinto 2d37ee10e6 ci(distributed): widen the trigger and drop the mid-suite image pull
The path allowlist covered 13 of the 99 packages the suite reaches. Commit
1dc3aeef8 touched core/config, core/services/modeladmin and core/backend and
matched no entry, so it would have merged without running the very specs that
cover it. Use the paths-ignore denylist tests-e2e.yml already uses.

Disable the testcontainers reaper: the runner is ephemeral, so the reaper buys
nothing and its unpinned image was pulled mid-suite, defeating the pre-pull.

Drop continue-on-error, which no other workflow uses and which reports a failed
run as green. The job is advisory by staying out of branch protection instead.
Pin Go to 1.26.0 to match go.mod, and add the tmate-on-failure step.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 11:02:13 +00:00
Ettore Di Giacinto 3257fc5cd8 ci(distributed): run the distributed e2e suite on PRs
The suite has never run in CI, so 239 specs across 32 files were verified only
by hand. Path-filtered to distributed code, advisory until it earns a track
record, and with flake retries at 1 rather than 5 so nondeterminism surfaces
instead of being retried away.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 10:49:54 +00:00
Ettore Di Giacinto 53639c4df3 test(distributed): scope the log-subscriber wait and mark the race it works around
Three corrections from review of the previous commit.

The lock-order comment on SubscriberCount claimed no path takes s.mu and a
buffer lock together. Subscribe does exactly that, holding s.mu.RLock across
replica registrations that take buf.mu. State the rule that is actually true —
s.mu precedes any buffer lock, so counting after releasing it preserves the
order — and say what follows from it: the total is a sample, not a snapshot.

waitForLogSubscriber read as general-purpose but unblocks on the first
registered subscription. Subscribe attaches the exact-key buffer and each
replica buffer one at a time, so for a replicated model the count goes positive
while later replicas are still unattached and the race survives. Rename it
waitForSingleLogSubscriber, document that it holds only where Subscribe
resolves to one buffer, and assert on exactly 1: misuse then fails loudly on
the count rather than going quietly back to being flaky. Taking the expected
count as a parameter was the alternative, but that makes callers predict a
store-internal number and an under-count fails the same silent way as the
original bug.

The snapshot-then-subscribe race had no artifact outside a report, and review
found a second site carrying it. Mark both handlers identically, including the
point that swapping the two calls duplicates rather than drops and so is not
the fix. The race itself is left alone; this branch stays test infrastructure.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 10:42:49 +00:00
Ettore Di Giacinto f0fa4a7b1f test(distributed): wait for the log subscriber instead of racing it
The WebSocket log handler writes its "initial" batch before it calls
Subscribe, so a line appended the instant that batch arrives lands in the
circular buffer with no subscriber to receive it. Three backend-logs specs
append exactly there and then wait out a 5s read deadline; once a gorilla
read hits its deadline the connection is unusable, so the spec cannot retry.
`--focus='Worker WebSocket log streaming' --repeat=25` failed on attempt 17
with nothing else running, which is far too often to wire into CI.

Add BackendLogStore.SubscriberCount, resolving a model ID by the same
exact-key and replica-prefix rules Subscribe uses, and have the specs poll it
until the handler has attached. Nothing in production calls it and no
assertion is weakened; the handler's own snapshot/subscribe window is left as
it is, being a production streaming question rather than a test one.

Verified with 60 repeats of the WebSocket specs and three consecutive
--randomize-all runs of the whole distributed suite, all at
--flake-attempts 1: 239 of 240 specs pass in about 80 seconds.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 10:31:51 +00:00
Ettore Di Giacinto 1974bc1ea0 test(distributed): stop leaking admin pools when database setup fails
A failed CREATE DATABASE panics out of the assertion before closeDB runs,
leaking a pgx pool per attempt. With --flake-attempts 5 that exhausts
postgres:16-alpine's 100 connection slots, at which point the cleanup path's
own Expect fails the spec and one hiccup cascades across the suite. Scope the
admin handle so the panic unwinds through defer closeDB, and let cleanup use a
fallible tryAdminDB that reports rather than asserts.

Register DeferCleanup immediately after CREATE so a later failure cannot leave
the database behind, and warn on TestInfra that the container handles are now
suite-wide.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 10:08:17 +00:00
Ettore Di Giacinto 115aeb47c0 test(distributed): share suite containers, isolate specs by database
Starting a Postgres and a NATS container per spec cost roughly 48 minutes of
startup across the 213 specs behind SetupInfra, which is why this suite was
never wired into CI. Containers move to BeforeSuite and isolation comes from
CREATE DATABASE, which the dbName argument already described.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-31 09:59:26 +00:00
lei_lei b8316f2a2a fix(gallery): use published F16 mmproj for qwythos-9b (#11792)
Install 404s because the gallery still points at mmproj-...-f16.gguf.
HF only ships ...-F16.gguf now, with a different sha256.

Signed-off-by: lei_lei <96427312+leilei3167@users.noreply.github.com>
2026-08-31 10:17:11 +02:00
localai-org-maint-botandmudler 1ab3db4bb7 chore: ⬆️ Update leejet/stable-diffusion.cpp to 6b3edaaf32cc19e5bb2d819c788bd557eddc8eba (#11793)
⬆️ 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-08-31 09:39:08 +02:00
Ettore Di Giacinto f829059e3d Rename branches from 'update/' to 'bump/'
Signed-off-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-08-31 09:38:28 +02:00
Ettore Di Giacinto 7aeb47cbf3 fix(launcher): auto-start the server so launching the app actually serves
Fixes #11673: on macOS the DMG launcher appeared to launch nothing. After
installing, the app sat in the menu bar with no window, nothing listening
on localhost:8080, and empty log files, because nothing ever started the
server unless the unrelated 'start on system boot' option was enabled.

- Start the LocalAI server automatically when the launcher opens and right
  after a fresh install. The new auto_start_server config key defaults to
  enabled and gets a settings checkbox; the legacy auto_start key was never
  honored nor exposed, so every existing launcher.json carries an
  unintentional false and is deliberately left behind.
- Fix the welcome window suppressing itself: its 'don't show this again'
  checkbox was initialized with the inverted value, and SetChecked fired
  the change callback which persisted ShowWelcome=false on the very first
  showing.
- Surface auto-start failures through the systray startup-error dialog,
  since there is no visible window during auto-start.
- Pass --app-version to fyne package so the app stops reporting itself as
  version 0.0.0 in the About box.
- Document the first-launch flow (menu bar app, auto-start, WebUI URL) in
  the macOS getting-started page.
- Repair two launcher specs that never ran in CI: a *bool matched against
  BeTrue and a /tmp assertion that trips on Linux where the test tempdir
  itself lives under /tmp.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-30 21:45:59 +00:00
localai-org-maint-botandlocalai-org-maint-bot dd4e75983d feat(gallery): add Qwen3.8 GSQ-RCO variants (#11787)
Add three llama.cpp-compatible mixed quantizations from ISTA DASLab. These builds give Qwen3.8-27B users an 8.4 to 10.1 GB weight tier with the shared vision projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-30 20:10:22 +02:00
localai-org-maint-botandlocalai-org-maint-bot 9c7c9974eb feat(gallery): add LFM2.5 8B DSpark variants (#11751)
Add Q4 and Q8 target builds with LiquidAI DSpark draft sidecars.
The variants expose mainline llama.cpp speculative decoding for the
existing LFM2.5 8B family.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-30 09:33:39 +02:00
localai-org-maint-botandlocalai-org-maint-bot 2a13b6e1f2 feat(gallery): add Qwen3.8 Cold Fusion (#11754)
Add Q4_K_M and Q8_0 MTP variants with the shared vision projector.
The publisher recommends these builds for faster Qwen3.8 generation.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-30 09:33:24 +02:00
localai-org-maint-botandlocalai-org-maint-bot 287ef12bf0 feat(gallery): add Granite 4.2 variants (#11779)
Add the 3B, 8B, and 30B safetensors checkpoints as one vLLM variant family so LocalAI can select the largest build that fits. Configure the parsers and sampling defaults recommended for Granite reasoning and tool calls.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-30 09:01:05 +02:00
mudler's LocalAI [bot]andmudler a7cc5873ef chore(model gallery): 🤖 add 1 new models via gallery agent (#11777)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-29 21:29:32 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 572a127682 feat(stablediffusion-ggml): build a ROCm variant (#11774)
The Makefile already had a hipblas branch, but no CI row built it and
the gallery's `amd:` mapping stayed commented out. On an AMD host the
capability lookup found no `amd` key and fell back to `default`, so
these users silently ran the CPU build.

Add the hipblas row to the backend matrix and the two gallery entries
it publishes, then point `amd:` at them.

Drop `-DGGML_HIPBLAS=ON` while here. `SD_HIPBLAS` sets `GGML_HIP`
itself, and `GGML_HIPBLAS` is the name ggml used before the rename, so
the flag only produced an unused-variable warning. Add gfx1151 to the
local target list to match the value the workflows pass in.


Assisted-by: Claude Code:claude-opus-5[1m]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-29 21:29:16 +02:00
localai-org-maint-botandlocalai-org-maint-bot 27bcf86a5a feat(gallery): add WeMM embedding variants (#11775)
Tencent released three WeMM sizes with direct Sentence Transformers support. Add each safetensor repository so users can select the quality and resource tradeoff.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-29 21:29:00 +02:00
localai-org-maint-botandlocalai-org-maint-bot 893a45141c fix(realtime): accept GA WebRTC signaling (#11778)
OpenAI GA clients send multipart or raw SDP requests. They expect a bare
SDP answer. LocalAI only accepted its legacy JSON envelope, so signaling
failed before media setup.

Keep the JSON contract for existing clients. Accept both GA request
shapes and choose the matching response format.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-29 21:28:37 +02:00
mudler's LocalAI [bot]andmudler 1db8db762d chore: ⬆️ Update mudler/vllm.cpp to 150b37852c123f7855fb219b37347572ca9427e7 (#11745)
⬆️ 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-08-29 21:20:09 +02:00
localai-org-maint-botandlocalai-org-maint-bot 16aa8ca004 feat(gallery): add Qwen3.8 Flash Next (#11763)
Add the Q4 and Q8 GGUF builds with the shared vision projector.\nThe variant pair lets LocalAI select the build that fits available memory.\n\nAssisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-29 10:34:53 +02:00
localai-org-maint-botandlocalai-org-maint-bot 62f1c0ca7f feat(gallery): add PhoneLLM variants (#11772)
Add complete vLLM and SGLang entries with their exact tool parsers. Preserve an explicit zero temperature in both backend adapters.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-29 10:03:54 +02:00
mudler's LocalAI [bot]andmudler 14c13ca1ef chore: ⬆️ Update 0xShug0/audio.cpp to 89a0e9803380880305e9e1b83c93614f9df2c893 (#11769)
⬆️ 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-08-29 09:43:27 +02:00
github-actions[bot]andmudler 176683dbe6 chore: bump inference defaults from unsloth (#11773)
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-29 09:17:12 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 80e3240f2d feat(distributed): key scheduling rules by a model alias (#11771)
Node placement and replica rules could only name a model, so an operator
who pinned "llama3" to the GPU tier had to rewrite the rule whenever a
different model took over that job. An alias already gives a stable name
for whichever model serves it, and a rule on that name makes it a
deployment slot: repoint the alias and the placement follows.

A rule keeps the name the operator chose. Reads resolve that name through
the config loader to the model the rule governs, so the reconciler counts,
schedules and trims replicas of the target, and the router finds an
alias-keyed rule from the target it is already routing. An alias that
resolves to nothing governs nothing loadable, so the reconciler skips it
and the write paths refuse it.

A replica is shared by every name that resolves to it, so only one rule
can decide where it runs. The REST and MCP write paths reject a rule whose
target another rule already governs. A pair that arrives some other way,
such as a seed file or an alias repointed onto a model that already has a
rule, resolves in favour of the rule named after the model itself and then
the oldest, and the rest are listed as shadowed.

The eviction guard is the exception: it matches rules to replicas in raw
SQL inside a locking transaction and cannot resolve an alias. It reads a
stored target that the reconciler refreshes each tick, and falls back to
the rule's own name when that target is empty.


Assisted-by: Claude:claude-opus-5 golangci-lint eslint

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-29 09:16:50 +02:00
mudler's LocalAI [bot]andmudler f9f4d2751f chore: ⬆️ Update ggml-org/llama.cpp to d7bd3bfcad3e29c7e49fd26f38c79ee3e9a3fd6b (#11770)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-29 09:16:37 +02:00
mudler's LocalAI [bot]andmudler a5a8338eeb chore: ⬆️ Update antirez/ds4 to 8db89fe083ae4d17c9a2428ccd29803d3ae8f577 (#11768)
⬆️ Update antirez/ds4

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-08-29 01:04:06 +02:00
mudler's LocalAI [bot]andmudler 8c2c3c5777 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 15dddc60b3fc937a9e2a210359ecce392ccdf446 (#11767)
⬆️ 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-08-29 01:03:53 +02:00
localai-org-maint-botandlocalai-org-maint-bot 9db6caf3fd feat(gallery): add Thomson 1.0 Small variants (#11766)
Add Q4_K_M and Q8_0 GGUF builds with the shared BF16 vision
projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-28 22:57:53 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 29899cd1e0 fix(ui): size model fit against the cluster and move node labels into the selector (#11765)
* fix(ui): move node labels into the scheduling selector field

The scheduling page kept a node-label browser open above the rules
whether or not anyone was writing one, while the field that actually
needs labels, the rule's node selector, was two bare text inputs with no
hint of what the cluster reports.

The browser is gone. The selector's key input now completes against the
label keys the cluster uses, and the value input offers only the values
that key takes. The roster already loads for the page, so the
suggestions cost no request, and a roster that fails to load costs the
admin the hints and nothing else.

Suggestions stay suggestions: a key no node reports yet still commits as
typed, which is how an admin writes a rule before labelling the nodes
for it.

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

* fix(distributed): size model fit against the cluster, not the frontend

The models page asked the frontend how much memory a model may occupy.
In distributed mode the frontend is usually a GPU-less pod while every
model runs on a worker, so a fleet of GPU nodes was told it could only
run the smallest CPU build. The variant picker's fits flag and its
auto-selection came from the same place, as did the hardware
recommendations.

The registry now reports the largest single healthy backend node. The
largest node, not the fleet total: a model loads into one node, so four
16GB workers are not a home for a 40GB model. An operator-set VRAM
budget caps a node's contribution, because the scheduler refuses a load
above that ceiling anyway, and a GPU node beats a CPU node holding more
system RAM.

GET /api/resources and GET /api/models carry this as an additional
cluster object. Their aggregate and ram fields keep reporting the
frontend's own hardware, which is what the resource monitor shows.
Variant selection judges backends against the union of the capabilities
present in the cluster, the way backend discovery already did.

Every path degrades to the local host: no cluster object in single-node
mode, and none when the registry cannot be read, so a hiccup narrows the
answer back to single-node behaviour rather than marking the whole
catalog too large.

The verdicts now name the node they belong to, since a model fits
somewhere or nowhere.

Assisted-by: Claude:claude-opus-5 golangci-lint eslint playwright
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-08-28 22:57:35 +02:00
0cdc31dcb3 chore: ⬆️ Update ggml-org/llama.cpp to e70802a01f03f0ed31a26338a5664796f3824371 (#11755)
* ⬆️ Update ggml-org/llama.cpp

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

* fix(llama-cpp): follow upstream MTMD APIs

The dependency update adds MTMD initialization options to prompt and
bitmap helpers. The gRPC adapter now passes the server options through
each affected path.

The update also replaces the per-layer MoE regex helper. Preparation
probes both APIs because older forks still reuse this adapter.

Assisted-by: Codex:gpt-5

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-28 14:06:35 +02:00
mudler's LocalAI [bot]andmudler 51f906f4e0 chore: ⬆️ Update 0xShug0/audio.cpp to 17751c0e8c48a3d56dcf05eeb60464409ecc69ce (#11759)
⬆️ 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-08-28 08:52:13 +02:00
localai-org-maint-botandlocalai-org-maint-bot d85577ff5c docs: add Apache APISIX reverse proxy example (#11294)
docs: add APISIX reverse proxy example

Document the route settings needed for forwarded headers, streaming responses, and long-running inference behind Apache APISIX.

Closes #11215

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-28 08:51:38 +02:00
mudler's LocalAI [bot]andmudler 81a54573ed chore: ⬆️ Update leejet/stable-diffusion.cpp to be0e34480dada95f8ce9a021bbb95c5de85d67c7 (#11760)
⬆️ 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-08-28 08:49:26 +02:00
mudler's LocalAI [bot]andmudler 83972b593f chore: ⬆️ Update ikawrakow/ik_llama.cpp to 7cff686d3732bfef5ce18bc4a6115fbceda29c14 (#11757)
⬆️ 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-08-28 08:48:31 +02:00
mudler's LocalAI [bot]andmudler f2814b9b83 chore: ⬆️ Update mudler/depth-anything.cpp to 739992d10bf9472c46dcd4622b14d2b20766c58d (#11758)
⬆️ Update mudler/depth-anything.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-28 08:48:15 +02:00
dependabot[bot] 92bcbaea87 chore(deps): bump vllm from 0.26.0 to 0.28.0 in /backend/python/vllm (#11752)
Bumps [vllm](https://github.com/vllm-project/vllm) from 0.26.0 to 0.28.0.
- [Release notes](https://github.com/vllm-project/vllm/releases)
- [Changelog](https://github.com/vllm-project/vllm/blob/main/RELEASE.md)
- [Commits](https://github.com/vllm-project/vllm/compare/v0.26.0...v0.28.0)

---
updated-dependencies:
- dependency-name: vllm
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-27 22:32:53 +02:00
Plamen K. Kosseff e58dabf75f feat(ui): add 'Focus mode' option in chat settings to persistently toggle the sidebar auto-collapse (#11750)
Assisted-by: Claude:claude-fable-5

Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
2026-08-27 22:32:32 +02:00
mudler's LocalAI [bot]andmudler 1070cb1245 chore: ⬆️ Update 0xShug0/audio.cpp to db21cbdd60f3d2ff62114bc863781ff8073ac39b (#11746)
⬆️ 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-08-27 09:54:02 +02:00
mudler's LocalAI [bot]andmudler 1b4c4853fb chore: ⬆️ Update ggml-org/llama.cpp to 925e1179947ea0c0ebfb0032df18af3a729822be (#11744)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-27 09:53:49 +02:00
mudler's LocalAI [bot]andmudler 460c22bff6 chore: ⬆️ Update ikawrakow/ik_llama.cpp to ef40550042973817ac391ca95a2ff041f512257b (#11743)
⬆️ 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-08-27 09:53:36 +02:00
mudler's LocalAI [bot]andmudler 8712d37e2e chore: ⬆️ Update vllm-project/vllm cu130 wheel to 0.28.0 (#11741)
⬆️ Update vllm-project/vllm cu130 wheel

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-27 09:53:25 +02:00
mudler's LocalAI [bot]andmudler 74b885c31a chore: ⬆️ Update PrismML-Eng/llama.cpp to 312bb2a93ea2bf798333fa859614fbf913ecb9e2 (#11740)
⬆️ Update PrismML-Eng/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-27 09:53:12 +02:00
mudler's LocalAI [bot]andmudler 0a89fdb1d0 chore(model-gallery): ⬆️ update checksum (#11742)
⬆️ 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-08-27 00:18:18 +02:00
Szymon Podeszwa 6f6ddba746 fix(deps): bump go-m1cpu to v0.2.2 to fix SIGSEGV on Apple M5 (#11736)
go-m1cpu v0.1.6 runs its cgo initialiser from a package init(), where
getFrequency() dereferences the CFTypeRef returned by
IORegistryEntryCreateCFProperty without a NULL check. On Apple M5 the
pmgr IORegistry node does not expose voltage-states5-sram /
voltage-states1-sram in the shape v0.1.6 expects, so the call returns
NULL and CFDataGetLength(NULL) faults before main() runs. Every command
dies, including local-ai --version.

The package is linked indirectly: cmd/local-ai reaches
gopsutil/v3/{process,disk}, which pull in gopsutil/v3/cpu on darwin,
which calls m1cpu.IsAppleSilicon() and m1cpu.PCoreHz().

v0.2.2 adds the missing NULL guard and moves the IORegistry probe out of
init() behind a lazy sync.Once. The exported Go API is unchanged and the
non-darwin stub is byte-identical, so gopsutil/v3 compiles against it
untouched and no other platform is affected.

Bumping gopsutil/v3 is not an alternative: v3.24.5 is the final v3
release, so the v3 line will never carry this fix.

Fixes #11735

Assisted-by: Claude:claude-opus-5

Signed-off-by: Szymon Podeszwa <2962046+sz-po@users.noreply.github.com>
2026-08-26 21:33:37 +02:00
fa19b08f35 chore: ⬆️ Update mudler/vllm.cpp to 6738e0b4639199f3ff0998815e4d32bfa7fe5be2 (#11647)
* ⬆️ Update mudler/vllm.cpp

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

* fix(vllm-cpp): mirror ABI v23

The new engine pin reports ABI v23 and appends mmproj_path to
vllm_model_params. LocalAI still declares v21, so the build-time ABI
guard rejects every backend build.

Grow the Go mirror by the appended pointer and update its offset checks.
ABI v23 adds a video function but does not change the mirrored text
structs.

Assisted-by: Codex:gpt-5.6 [systematic-debugging]

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-08-26 16:16:49 +02:00
localai-org-maint-botandlocalai-org-maint-bot bbd3ab5a14 feat(gallery): add Tiel-Coder 35B variants (#11723)
Add Q4_K_XL, MTP Q4_K_XL, and Q8_K_XL builds with their BF16 vision projectors.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-26 09:02:31 +02:00
localai-org-maint-botandlocalai-org-maint-bot 5dab4fcde9 feat(gallery): add Granite 4.2 variants (#11719)
Add the official IBM Q4_K_M and Q8_0 GGUF builds for the 3B, 8B, and 30B Granite 4.2 models.

Assisted-by: Codex:gpt-5.6-sol

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-26 09:01:55 +02:00
mudler's LocalAI [bot]andmudler 15f12074ca chore: ⬆️ Update leejet/stable-diffusion.cpp to 50d640568388f876b0d63ee6ddb6bc86d997ec64 (#11725)
⬆️ 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-08-26 08:59:12 +02:00
mudler's LocalAI [bot]andmudler 5c08ec0382 chore: ⬆️ Update ggml-org/llama.cpp to eab8ee41f889ef7823af517e8098fb8a9b3cf601 (#11724)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-26 08:58:37 +02:00
Copilotandmudler e7b83ef7c0 Fix flaky "tests-apple" CI job in modeladmin test suite (#11717)
* Initial plan

* tests: raise default Eventually timeout for modeladmin suite to fix flaky macOS CI

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-26 08:58:17 +02:00
mudler's LocalAI [bot]andmudler aea477932d chore(model-gallery): ⬆️ update checksum (#11730)
⬆️ 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-08-26 08:57:58 +02:00
lei_lei f28e8b24e6 fix(ollama): accept :latest tag on model lookup (#11732)
/api/tags appends :latest to untagged names, but chat and the other
model endpoints looked the tagged name up as-is and 404'd.

Signed-off-by: lei_lei <96427312+leilei3167@users.noreply.github.com>
2026-08-26 08:57:36 +02:00
mudler's LocalAI [bot]andmudler 5755898e57 chore: ⬆️ Update ggml-org/whisper.cpp to 978113305b2ead22249b881deafa131dc8884911 (#11711)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-26 01:01:32 +02:00
mudler's LocalAI [bot]andmudler f63f11eb86 chore: ⬆️ Update 0xShug0/audio.cpp to c79e58899bf13db4d78fd06372da23cc13f55b28 (#11722)
⬆️ 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-08-26 01:01:19 +02:00
mudler's LocalAI [bot]andmudler 33dafe37ab chore: ⬆️ Update ikawrakow/ik_llama.cpp to 08b500b958a3f1102e6500e5c425e65517d6fb7e (#11726)
⬆️ 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-08-26 01:01:06 +02:00
localai-org-maint-botandlocalai-org-maint-bot edabdf9501 feat(gallery): add Ornith 1.5 397B variants (#11716)
* feat(gallery): add Ornith 1.5 397B variants

Add the official Q4_K_M and Q8_0 GGUF builds with their shared BF16 vision projector.

Assisted-by: Codex:gpt-5

* feat(gallery): resolve Ornith variant ordering\n\nKeep the 35B entries from master next to the 397B variants.\n\nAssisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-25 17:36:02 +02:00
fa9ffc181c chore: ⬆️ Update ggml-org/llama.cpp to f280b26983ad0fdb705a0d9ebf0503e76f2899b0 (#11646)
* ⬆️ Update ggml-org/llama.cpp

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

* fix(llama-cpp): adapt to the common JSON API

The llama.cpp bump replaces its nlohmann JSON alias with common_json. Update the gRPC adapter for the new exception, iterator, conversion, and container APIs.

Assisted-by: Codex:gpt-5.6 [systematic-debugging]

* fix(turboquant): adapt the JSON exception type

The shared gRPC source now follows the upstream common_json API. The
TurboQuant fork still exposes nlohmann JSON and cannot compile the new
exception type.

Translate that exception in the fork-specific source patch so both
llama.cpp variants compile from the shared adapter.

Assisted-by: Codex:gpt-5.6 [systematic-debugging]

* fix(bonsai): adapt the JSON exception type

The shared gRPC source uses upstream's common_json wrapper. The Bonsai fork still exposes nlohmann JSON and cannot compile that exception type.\n\nTranslate the exception in the fork-specific preparation step and verify that repeated preparation stays idempotent.\n\nAssisted-by: Codex:gpt-5.6 [systematic-debugging]

* fix(llama-cpp): let prepare register gRPC

The score patch duplicated the gRPC CMake registration that prepare.sh already owns. Its stale context rejects the current upstream tools file on Darwin before compilation starts.

Assisted-by: Codex:gpt-5

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-25 12:57:12 +02:00
mudler's LocalAI [bot]andmudler ccb9a0a088 chore: ⬆️ Update 0xShug0/audio.cpp to d25ffac094a9d5a240940b4955ea79ad9b7b4c78 (#11710)
⬆️ 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-08-25 12:55:43 +02:00
localai-org-maint-botandlocalai-org-maint-bot f7c55788c7 feat(gallery): add Ornith 1.5 35B variants (#11714)
Add the official Q4_K_M and Q8_0 GGUF builds with their shared BF16 vision projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-25 12:54:00 +02:00
lei_lei a760a7ab4b fix(backends): honor enable_thinking=false in sglang and vllm (#11715)
Those backends only forwarded the flag when it was "true", so "false"
never reached apply_chat_template and Qwen3 kept thinking on.

Signed-off-by: lei_lei <96427312+leilei3167@users.noreply.github.com>
2026-08-25 12:52:57 +02:00
mudler's LocalAI [bot]andmudler 964be3bceb chore: ⬆️ Update ikawrakow/ik_llama.cpp to 0ed847d3140baead542abe3e5e6fe841013e7340 (#11708)
⬆️ 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-08-25 08:54:29 +02:00
mudler's LocalAI [bot]andmudler 496921f73a chore(model-gallery): ⬆️ update checksum (#11707)
⬆️ 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-08-24 23:41:38 +02:00
Ettore Di Giacinto f7ded96b1e fix(distributed): probe liveness on a subject every worker answers
The scheduler's liveness probe asks a worker a question over NATS and
reads "no responders" as proof the worker is gone. That is only sound
when every worker in the fleet subscribes to the subject asked.

It asked models.running, which arrived in 4.6. A 4.5 worker is alive and
serving, answers backend.list, and never subscribes to models.running,
so the probe condemned it on every scheduling attempt and marked it
unhealthy. A model pinned to such a node by its selector could then
never be placed at all: on this cluster an embedding model pinned to the
one Apple node was unschedulable for exactly this reason, while that
node's log showed it handling backend.list throughout.

Ask backend.list, which has been in the worker protocol far longer, and
treat a worker that answers anything as alive. Only a node that reports
no responders on every subject is absent, so adding a newer subject here
can never condemn an older worker.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-24 19:58:49 +00:00
Ettore Di Giacinto 1dc3aeef87 fix(distributed): resolve config revisions through one entry point
A model's revision is published by administration and checked against on
every inference request. Those were computed by separate code: the
request path resolves through the loader, while each publisher hashed
whatever ModelConfig it happened to hold. By then SetDefaults had folded
in the GGUF guess and app-level options, so the published value was one
no request would ever carry and the model became unroutable until the
row was deleted by hand.

Fixing the publishers one at a time did not hold. Three rounds each
found another: the startup resync, then a saved edit and a toggle, then
a rename and the peer-change path.

ModelConfigLoader.RevisionFor is now the only way to obtain a revision,
and the raw hash is unexported, so a caller outside this package cannot
hash a config it holds. A publisher and a request agree by construction
rather than by two implementations happening to match.

The request path no longer falls back to hashing its merged config
either: an unstamped config is routed without a revision rather than
with a wrong one.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-24 19:11:16 +00:00
Ettore Di Giacinto 2c68fa1eb6 fix(distributed): keep eviction inside the model's node selector
When no node the selector allows has a free slot, scheduling falls back
to evicting the least-recently-used idle model. That eviction searched
every healthy node, so it freed a slot on a node the selector forbids
and the model was then placed there: pinned to one class of hardware and
running on another.

An unrelated model pays for it. On this cluster an embedding model
pinned to Apple hardware could not reach its only matching node, so each
attempt evicted a large language model from an Nvidia node, failed to
start there anyway, and left the evicted model to reload. Repeated, that
reads as one replica bouncing between nodes.

Eviction is now restricted to the candidate set the selector produced.
With no selector the candidate set is nil and eviction stays global.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-24 13:18:35 +00:00
Ettore Di Giacinto 38ba3fec63 fix(distributed): stop reclaiming healthy reconciler-driven loads
The abandoned-load sweeper treated a replica row with no load job as
abandoned. Only the request path creates load jobs; the reconciler's own
scale-up loads a replica without one. So any scale-up that ran past the
five-minute grace period was deleted mid-transfer, which for a
multi-gigabyte checkpoint is every time. The replica never finished
anywhere, and the reconciler kept re-placing it, so it looked like one
replica hopping between nodes instead of a model reaching its replica
count.

A row with no job is now reclaimed only once its node stops being
healthy, which is the case the sweeper was written for: a worker that
dropped out mid-transfer. A job that failed or stopped heartbeating
still proves abandonment on its own. Every uncertain case leaves the
slot held.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-24 13:00:34 +00:00
Ettore Di Giacinto bebd812e7d fix(distributed): stop flapping agent nodes on backend listing
Only backend workers subscribe to backend.list. ListBackends asked every
node that was not pending, offline or draining, so an agent worker could
only answer "no responders", which the error handling reads as a node
that has gone away. Every poll of the backends view therefore marked
each agent node unhealthy, and its next heartbeat marked it healthy
again.

While unhealthy the node is not schedulable, so this also cost agent
capacity for as long as each flap lasted.

Skip non-backend workers, as the backend-op fan-out already does for the
same reason. A backend worker that does not answer is still marked
unhealthy: that one really is gone.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-24 12:48:23 +00:00
Ettore Di Giacinto df1a40f9c0 fix(distributed): hash the config as persisted, not as defaulted
The revision was computed after SetDefaults, which folds in things that
are not persisted configuration: the GGUF guess, the hardware defaults,
and app-level options such as threads.

The GGUF guess is the damaging one. It parses the model file to fill in
values like context size, and when that parse fails it falls back to a
different default. Whether a multi-gigabyte file on network storage
parses at a given moment is not a property of the configuration, so one
unchanged YAML produced two different revisions depending on when it was
read. The controller rejected every request carrying the other one, and
the model stayed unroutable until the stored value happened to match
again. This is why it never reproduced against a model directory with no
weights in it: the guess is skipped there and both values agree.

The app-level defaults are the same class of bug with a slower fuse:
changing threads in the settings UI changed every model's revision and
made every model unroutable.

The revision is now stamped when the file is parsed, before any defaults
are applied, so it is a function of the file alone.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-24 11:45:32 +00:00
Ettore Di Giacinto 505a6d040b fix(distributed): publish the revision a request actually carries
Two code paths computed a model's revision. Inference resolves the
config through the loader, which applies SetDefaults a second time.
Everything that publishes a revision hashed the stored config instead,
with SetDefaults applied once.

SetDefaults is not idempotent for every model: it re-runs the GGUF guess
and the hardware defaults, both of which read state the stored config
does not carry. Where the two disagree, a publisher wrote a revision no
request would ever carry, and the model became unroutable the moment it
was published. On this cluster the startup resync republished one such
value and every request for that model was then rejected against it.

The publishers now resolve the revision through the loader, exactly as a
request does, so there is one definition rather than two that agree only
when SetDefaults happens to be idempotent. This covers the startup
resync, a saved config edit, and enabling or disabling a model.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-24 09:05:52 +00:00
mudler's LocalAI [bot]andmudler 98649d775e chore(model gallery): 🤖 add 1 new models via gallery agent (#11692)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-24 09:45:32 +02:00
mudler's LocalAI [bot]andmudler dc303aa96c feat(swagger): update swagger (#11682)
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-08-24 09:44:51 +02:00
DanielSwift1992 336b97fcfe chore(deps): remove 16 dependabot entries for directories that no longer exist (#11686)
Remove 16 dependabot entries for directories that no longer exist

Signed-off-by: Daniil S <daniel.swift.1992@gmail.com>
2026-08-24 09:44:36 +02:00
mudler's LocalAI [bot]andmudler dc0961f962 chore: ⬆️ Update 0xShug0/audio.cpp to 288a2712316470847a730e55db9ac9e5062a2b03 (#11683)
⬆️ 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-08-24 09:34:09 +02:00
mudler's LocalAI [bot]andmudler 1bee6b14b7 chore: ⬆️ Update CrispStrobe/CrispASR to ae4474dd8306384a0e697183d863dfc52e69a2fb (#11684)
⬆️ 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-08-24 09:33:56 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto d7ff43781d fix(oci): resume interrupted layer downloads (#11688)
quay.io redirects blob downloads to pre-signed S3/Akamai URLs that
expire after about 10 minutes. On a slow connection a multi-GiB
backend layer cannot finish inside that window, so the connection
drops mid-stream on every attempt. The retry added for #10577
restarted each attempt from byte zero, which replayed the same
failure until the budget ran out and the install failed with
"unexpected EOF".

A retry now keeps the bytes already on disk and re-requests the
blob with "Range: bytes=N-". Each request goes back to the
registry, so it gets a fresh redirect URL and auth token. The
retry budget only counts attempts that made no forward progress,
so a slow link that keeps advancing keeps downloading. A resumed
file is spliced from separate responses and bypasses the digest
check in layer.Compressed(), so the assembled file is re-verified
against the layer digest before it is trusted; on a mismatch the
download starts over through the verified reader.

Fixes #10577


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-24 09:33:44 +02:00
localai-org-maint-botandlocalai-org-maint-bot e470d4b625 feat(gallery): add Qwen3.8 OBLITERATED variants (#11691)
* feat(gallery): add Qwen3.8 OBLITERATED variants

Add Q4_K_M and Q8_0 llama.cpp builds with the shared BF16 vision projector.

Assisted-by: Codex:gpt-5

* fix(tests): implement node liveness stub

NodeCommandSender now requires PingNode. The endpoint test stub must
implement it before the package can compile.

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

* fix(distributed): restore node liveness tests

The router now probes models.running before it schedules work. The E2E
workers only mocked backend.install, so every test node appeared offline.

The endpoint test double also missed the new PingNode method and stopped
the Linux, Apple, and lint jobs during compilation.

Mock the existing worker reply in both distributed fixtures and keep the
endpoint test double aligned with NodeCommandSender.

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

* fix(tests): check node liveness replies

The liveness test subscriptions ignored setup and reply errors.

Errcheck rejected each branch that carried them.

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

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-24 09:33:10 +02:00
localai-org-maint-botandlocalai-org-maint-bot 7ff9d9942b fix(distributed): restore node liveness tests (#11694)
* fix(distributed): restore node liveness tests

The router now probes models.running before it schedules work. The E2E
workers only mocked backend.install, so every test node appeared offline.

The endpoint test double also missed the new PingNode method and stopped
the Linux, Apple, and lint jobs during compilation.

Mock the existing worker reply in both distributed fixtures and keep the
endpoint test double aligned with NodeCommandSender.

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

* fix(tests): check node liveness replies

The liveness test subscriptions ignored setup and reply errors.

Errcheck rejected each branch that carried them.

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

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-24 09:32:48 +02:00
localai-org-maint-botandlocalai-org-maint-bot a8bc64cd09 fix(ci): bound Discord release summaries (#11695)
* fix(ci): bound Discord release summaries

The release model can return more than Discord's 2,000-character
message limit. Discord then rejects the entire release notification.

Ask the model for a smaller response and truncate extracted content to
1,800 characters before the notification step. The smaller bound leaves
room below Discord's hard limit when model output varies.

Assisted-by: Codex:gpt-5

* fix(tests): implement node liveness stub

NodeCommandSender now requires PingNode. The endpoint test stub must
implement it before the package can compile.

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

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-24 09:32:26 +02:00
mudler's LocalAI [bot]andmudler 2f625becf6 chore(website): refresh the counters (#11697)
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-24 09:31:36 +02:00
Ettore Di Giacinto 5c9d8190d9 fix(distributed): resync revisions after the configs are loaded
The resync added in 3953448f6 ran before LoadModelConfigsFromPath, so it
read an empty loader, reconciled nothing and reported success. The
symptom was a stored revision that stayed stale across restarts while
the log showed no complaint, which is exactly what the resync was meant
to prevent.

Move the call after the configs are loaded, and refuse to treat an empty
loader as a clean run: reconciling zero models is indistinguishable from
reconciling correctly, and that is what hid the mis-ordered call.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-24 06:40:30 +00:00
Ettore Di Giacinto 3953448f60 fix(distributed): resync stored config revisions at startup
The controller pins a model's replicas to a stored revision and rejects
any request carrying a different one. Nothing ever re-derived that value
from the configuration on disk: it moved only on an edit, a gallery
install, or a peer's change broadcast. An inference request may only
establish a revision, never replace one.

So any other way for the two to diverge left the model permanently
unroutable. A configuration edited while a frontend was down lands
there, and so does a change in what the revision is computed over: an
upgrade that alters the hashed form leaves every stored revision
describing a configuration that no longer exists. The only recovery was
deleting the row by hand, which is not something a cluster should need.

Each frontend now reconciles the stored revisions against the loaded
configurations at startup and republishes the ones that disagree. Only
those: republishing quarantines every replica loaded under the old
revision, so doing it for a model that did not drift would unload a
healthy replica for nothing. A model with no stored revision has never
been served and is left for its first request to establish.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-23 22:17:59 +00:00
mudler's LocalAI [bot]andmudler eadc005b86 chore: ⬆️ Update antirez/ds4 to c1d4597a80e300b803dc642519718f2c999589da (#11685)
⬆️ Update antirez/ds4

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-08-23 23:49:32 +02:00
mudler's LocalAI [bot]andmudler 7a78ec82eb chore(model-gallery): ⬆️ update checksum (#11690)
⬆️ 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-08-23 23:46:34 +02:00
Ettore Di Giacinto e6269e3cdd fix(distributed): reclaim replica slots held by abandoned loads
A replica row in staging or loading holds its slot, because slot
allocation counts every state except unloading. Nothing ever reclaimed
such a row: every reconciler pass and the router's eviction query filter
state = "loaded", and the per-model probe skips rows without an address,
which is exactly what a row that never finished loading has.

So a worker that dropped out mid-transfer left a row that pinned the
only replica slot for that model on that node. Scheduling then found no
free slot and eviction found nothing it was allowed to evict, and the
request failed with "no replica slot on <node> and eviction failed: all
models busy". The state persisted until an operator intervened.

The reconciler now reclaims a row stuck before serving when no load job
is driving it. Ownership is decided by the job's LastProgress heartbeat,
not by elapsed time: staging a large checkpoint legitimately runs for a
long while without touching the replica row, so a deadline would either
be a model-size cliff or reclaim a healthy transfer. That heartbeat is
the same signal job takeover already trusts. Any error reading the job
leaves the slot held, because holding one for another pass costs a
scheduling opportunity while a wrong reclaim restarts a multi-gigabyte
transfer.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-23 21:07:29 +00:00
Ettore Di Giacinto c541dbeef4 fix(distributed): check a node answers before scheduling onto it
A node's status comes from its HTTP heartbeat. Backend installs travel
over NATS. The two are independent, so a worker that dies stops
answering on the bus at once but stays healthy in the database until its
heartbeat ages out. Inside that window the scheduler picked a node it
could not reach, and the request failed with "no responders available"
rather than moving to a node that was up.

The scheduler now probes the node it selected and, when nothing answers,
marks it unhealthy and selects again. The demotion is what makes the
retry terminate: the next selection reads only healthy nodes. It also
tells the other frontends what this one learned, so the cluster does not
rediscover a dead worker one failed request at a time.

Only nats.ErrNoResponders counts as absent. A worker that answers slowly
stays eligible, because dropping it would cost capacity that is really
there. The probe reuses the models.running subject: a new subject would
go unanswered by workers that have not been upgraded, and every one of
them would then look dead.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-23 20:44:43 +00:00
Ettore Di Giacinto ac9969ef4d chore: bump go-processmanager to dfa0ed8
Picks up "surface option errors instead of discarding them". New records
the error from applying its options and Run returns it, so a process
whose state directory cannot be created reports the real cause instead
of failing later inside os.MkdirAll("") with an empty path.

LocalAI already resolves that directory itself, so this covers the other
process.New call sites rather than changing behaviour on the backend
start path.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5
2026-08-23 20:26:47 +00:00
Ettore Di Giacinto d40662cfd5 fix(model): report why a backend state directory fails
process.New applies its options and discards the error they return. When
WithTemporaryStateDir could not create a directory, StateDir stayed
empty and every later option went unapplied, so the failure surfaced
from Run as "mkdir : no such file or directory" naming no path.

That message cost a full day of diagnosis on a worker whose volume was
full: the real errno was ENOSPC and nothing reported it.

The loader now creates the directory itself and returns the underlying
error with the path attached.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-23 20:20:02 +00:00
Ettore Di Giacinto cee87d1608 fix(distributed): expire staged request files on the worker
A request that carries a file stages it to the worker, which writes it
under its staging directory. Nothing removed it afterwards. The frontend
expires ephemeral keys from object storage, but that sweep never covered
a worker's local disk, so every image, audio clip and video a worker
ever served stayed on it.

One worker had accumulated 175 request directories over three months.
The volume reached 100 percent, and from that point every backend start
failed because the process manager could not create a state directory.

The worker now sweeps its ephemeral staging directory on a timer and
once at startup, so files left by a crash are reclaimed too. Staged
model files live beside that directory and are not touched.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-23 20:20:02 +00:00
Ettore Di Giacinto 4bad644498 fix(distributed): name both revisions in the stale error
"stale model config revision" reported only that two hashes differed.
It named neither, so an operator could not tell an edited configuration
from a revision that is not reproducible for one unchanged file, and the
failing value appears in no table.

The error now carries the revision the request brought and the one the
controller holds. It still wraps ErrStaleModelConfigRevision, so callers
that classify the error keep working.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-23 19:53:42 +00:00
localai-org-maint-botandlocalai-org-maint-bot eafc7fda27 feat(gallery): add Homura 30B (#11680)
Add the publisher\047s Q4_K_M build for the recent Muse Glimmer agent fine-tune.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-23 21:38:10 +02:00
localai-org-maint-botandlocalai-org-maint-bot b9914b56fb feat(gallery): add UI-Mate 27B variants (#11672)
Add Q4_K_M and Q8_0 builds for Tencent's Qwen3.6-based computer-use model.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-23 21:37:58 +02:00
localai-org-maint-botandlocalai-org-maint-bot 031eb5fc7c feat(gallery): add Qwen3.8 small distills (#11675)
Add the 2B and 4B distilled models alongside the 9B model. Their compact GGUF builds make the Qwen3.8 reasoning distill practical on smaller hosts.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-23 21:37:45 +02:00
localai-org-maint-botandlocalai-org-maint-bot 1313a4a5a2 feat(gallery): add LFM2.5 DSpark variants (#11676)
LiquidAI now publishes official DSpark draft sidecars for its 2.6B target. Pair Q4 and Q8 targets with matching draft choices so LocalAI can use speculative decoding across different memory budgets.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-23 21:37:28 +02:00
Ettore Di Giacinto f3fabe8c5c fix(distributed): order derived usecases deterministically
syncKnownUsecasesFromString rebuilds KnownUsecaseStrings by ranging
GetAllModelConfigUsecases, which is a map. Go randomizes that order per
call, and the field is part of the serialized config, so one unchanged
YAML hashed to a different config revision on every load.

A model that derives a single usecase hid the problem. One that derives
several, such as a chat model with an mmproj, alternated between as many
revisions as there are orderings. The router treats a revision it did
not establish as a config change, so requests failed with "stale model
config revision" until the stored value happened to match again.

Sorting the list makes the revision a function of the file alone.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-23 17:20:20 +00:00
Ettore Di Giacinto 04735cd1f6 fix(distributed): stamp config revision at load time
The request middleware merges the caller's prediction parameters into
its copy of the model config. core/backend.ModelOptions then hashed
that copy, so the revision identified the request body rather than the
persisted configuration.

EstablishModelConfigRevision stores the first revision it sees and
requires an exact match afterwards. The first request after a restart
therefore pinned the model to its own temperature, top_p and stop
values, and every later request that sent different ones failed with
"stale model config revision". No config edit was involved.

The loader now stamps the revision when it materializes a config,
before any request override reaches it, and ModelOptions reads that
stamp. Model administration keeps hashing the same persisted config, so
both paths agree on one revision per configuration.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
2026-08-23 14:35:44 +00:00
localai-org-maint-botandlocalai-org-maint-bot 8f56e4e042 fix(vram): persist remote probe metadata (#11487)
* fix(vram): persist remote probe metadata

The startup warmer repeated remote size and GGUF metadata probes after every restart because both caches lived only in memory. Store successful HTTP probes for 24 hours so frequent restarts reuse the prior results.

Bound the cache, reject invalid records, and purge it when gallery data changes. Local model files continue to bypass persistence.

Assisted-by: Codex:gpt-5

* fix(vram): check temporary file cleanup

The lint gate rejects the unchecked cleanup call in the persistent cache writer.

Assisted-by: Codex:gpt-5.6 [golangci-lint]

* fix(vram): make persistent cache optional

Remote metadata probes can transfer enough data that operators need
control over disk reuse and startup warming. Gallery autoload now gates
both behaviors, and the runtime setting applies changes immediately.

Assisted-by: Codex:gpt-5

* fix(ui): expose gallery startup pre-warm

The existing gallery autoload setting also gates the startup metadata warmer. Name both effects in Settings so operators can find the requested boot control.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-23 08:55:13 +02:00
mudler's LocalAI [bot]andmudler ffef539866 chore: ⬆️ Update ggml-org/whisper.cpp to 233fe1fc9b48a09e361d3594520838ca266537fe (#11648)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-23 08:54:33 +02:00
mudler's LocalAI [bot]andmudler 1205f37457 chore: ⬆️ Update 0xShug0/audio.cpp to 4d383be1bff107e823ffc19120dcb6c78d493c0f (#11666)
⬆️ 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-08-23 08:54:13 +02:00
mudler's LocalAI [bot]andmudler 3f5467b4eb chore: ⬆️ Update CrispStrobe/CrispASR to 74bb374a8cc74284348d76a0a6e944180fbe6b07 (#11650)
⬆️ 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-08-23 08:53:57 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 82c191afad fix(distributed): keep model replicas config-consistent (#11664)
* docs: design configurable copy buffering

Document the context-aware copy buffer option and its validation plan.

Assisted-by: Codex:gpt-5

* docs: design durable distributed staging operations

Assisted-by: Codex:gpt-5

* docs: design distributed model config revisions

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command]

* feat(config): add stable model revisions

Hash typed model configuration and effective protobuf options deterministically for distributed revision comparisons.

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command]

* feat(worker): acknowledge exact model stops

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command]

* feat(nodes): track model config revisions

Assisted-by: Codex:GPT-5 [apply_patch]

* fix(distributed): retry quarantined model cleanup

Stop quarantined replicas by exact process identity, retain failed cleanup as durable capped retries, and compare-and-delete only the claimed registry row. Process one sufficiently leased row at a time so multiple frontends cannot duplicate slow cleanup work.

Assisted-by: Codex:gpt-5

* fix(distributed): bind loads to config revisions

Assisted-by: Codex: GPT-5 [OpenAI Codex]

* fix(modeladmin): apply config revisions consistently

Route model edits, patches, state changes, deletion, and peer refreshes through the same revision lifecycle. Quarantine stale replicas before exact cleanup and report durable pending cleanup without failing successful config writes.

Assisted-by: Codex: GPT-5 [OpenAI Codex]

* feat(distributed): expose model config revision state

Document replica revision observability and durable cleanup behavior. Keep pending cleanup explicit in model mutation responses and verify endpoint contracts expose revision state without serialized load options.

Assisted-by: Codex:GPT-5 [OpenAI Codex]

* test(distributed): cover model revision convergence

Exercise cross-frontend quarantine, stale replay rejection, exact cleanup retry, worker re-registration, and current-generation replica convergence against the distributed PostgreSQL harness.

Assisted-by: Codex:gpt-5

* fix(distributed): pass config revision CI checks

Keep configured gallery sources out of authoritative runtime snapshots only after validating their real schema, and harden rollback snapshots against symlink races and non-regular files.

Assisted-by: Codex: GPT-5 [OpenAI Codex]

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-22 22:44:03 +02:00
localai-org-maint-botandlocalai-org-maint-bot 6c7b45b32d fix(gallery): remove duplicate Qwopus entries (#11670)
The Qwopus variant pair was added twice, which makes the later pair unreachable and fails the gallery invariant on master.

Keep the first pair that LocalAI already resolves and remove the dead duplicate declarations.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-22 08:47:58 +02:00
mudler's LocalAI [bot]andmudler b013edab35 chore(model-gallery): ⬆️ update checksum (#11665)
⬆️ 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-08-22 00:51:59 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 9d92139de4 feat(ui): edit scheduling rules in place (#11667)
* docs(ui): design scheduling rule editing

Document the approved in-place rule editing flow and scalable node-label reference for the scheduling view.

Assisted-by: Codex:gpt-5

* feat(ui): improve scheduling rule management

Add scalable node-label discovery and editable scheduling rules with responsive, accessible controls.

Assisted-by: Codex:gpt-5

* chore(ui): ratchet inline style baseline

Record the static inline style removed by the scheduling view enhancement.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-22 00:51:48 +02:00
localai-org-maint-botandlocalai-org-maint-bot 9feaa817f2 feat(gallery): add OpenResearcher variants (#11658)
Add the Q4_K_M and Q8_0 GGUF builds for the agentic deep-research model.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-21 21:21:13 +02:00
localai-org-botandlocalai-org-bot 3684a534bb docs(website): simplify installation paths (#11631)
Keep the homepage focused on runtime capabilities and move engine details to their canonical directory. Make installation choices stable and explicit for users across supported hardware.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-bot <306113404+localai-org-bot@users.noreply.github.com>
2026-08-21 21:20:54 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto a0252ad6a1 fix(distributed): keep staging operations stable (#11663)
* docs: design configurable copy buffering

Document the context-aware copy buffer option and its validation plan.

Assisted-by: Codex:gpt-5

* docs: design durable distributed staging operations

Assisted-by: Codex:gpt-5

* fix(distributed): merge durable staging operations

Use active model load jobs as the durable operations baseline and overlay replica-local staging progress without duplication. Preserve tracker-only operations when the registry cannot be read.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-21 20:08:47 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 5429f569e0 fix(progress): stop status updates throttling downloads (#11661)
* feat(progress): aggregate and coalesce gallery downloads

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): show rolling transfer speed

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): preserve legacy import byte labels

Assisted-by: Codex:gpt-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-08-21 18:54:28 +02:00
localai-org-maint-botandlocalai-org-maint-bot 36ad21d1f5 feat(gallery): add Qwopus 3.6 variants (#11662)
Add the trending 27B Fusion merge in Q4_K_M and Q8_0 formats with its shared vision projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-21 18:53:07 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 5072219829 feat(xio): make copy buffer size configurable (#11660)
* docs: design configurable copy buffering

Document the context-aware copy buffer option and its validation plan.

Assisted-by: Codex:gpt-5

* feat(xio): configure context copy buffer size

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-21 16:14:26 +02:00
mudler's LocalAI [bot]andmudler 5797ccb442 chore(model-gallery): ⬆️ update checksum (#11645)
⬆️ 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-08-21 12:50:01 +02:00
Anupam Mediratta 387db04a6e fix: upgrade github.com/containerd/containerd to 1.7.33 (CVE-2026-53488) (#11655)
fix: CVE-2026-53488 security vulnerability

Automated dependency upgrade by OrbisAI Security

Signed-off-by: anupamme <mediratta@gmail.com>
2026-08-21 10:20:55 +02:00
mudler's LocalAI [bot]andmudler 22ae9f5ba7 chore: ⬆️ Update 0xShug0/audio.cpp to 43001a7e0f452d80f4588e613f13332940dd4d3a (#11649)
⬆️ 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-08-21 08:47:02 +02:00
localai-org-maint-botandlocalai-org-maint-bot 5a890bf661 fix(gallery): tag MTP-enabled models (#11653)
The gallery ranker reads serving features from tags instead of backend options. Mark two entries that enable draft MTP so ranking can identify them correctly.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-21 08:46:47 +02:00
github-actions[bot]andmudler 7f2c599f4a chore: bump inference defaults from unsloth (#11654)
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-21 08:46:35 +02:00
Anupam Mediratta ff6043b811 fix: upgrade react-router to 7.18.2, 8.3.0 (GHSA-qwww-vcr4-c8h2) (#11644)
* fix: GHSA-qwww-vcr4-c8h2 security vulnerability

Automated dependency upgrade by OrbisAI Security

Signed-off-by: anupamme <mediratta@gmail.com>

* fix: upgrade react-router-dom to 7.18.2 to fully remediate GHSA-qwww-vcr4-c8h2

The prior fix pinned react-router@7.18.2 directly but left react-router-dom
at ^7.18.1, which bun resolved to 7.18.1. That package bundles its own
react-router@7.18.1 sub-dep, leaving the vulnerable version in bun.lock
via the react-router-dom/react-router scoped resolution. Pinning
react-router-dom to 7.18.2 and regenerating the lockfile removes all
7.18.1 resolutions.

Assisted-by: Claude Code:claude-sonnet-4-6
Signed-off-by: Anupam Mediratta <mediratta@gmail.com>

---------

Signed-off-by: anupamme <mediratta@gmail.com>
Signed-off-by: Anupam Mediratta <mediratta@gmail.com>
2026-08-21 08:41:23 +02:00
mudler's LocalAI [bot]andmudler 6d8667f93a chore: ⬆️ Update ggml-org/llama.cpp to d59d455fd8ea09e5a2e87ce2a9d668267ffb5ccd (#11618)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-20 22:59:48 +02:00
dependabot[bot] 3c899961e9 chore(deps): update charset-normalizer requirement from >=3.4.9 to >=3.5.1 in /backend/python/vllm (#11637)
chore(deps): update charset-normalizer requirement

Updates the requirements on [charset-normalizer](https://github.com/jawah/charset_normalizer) to permit the latest version.
- [Release notes](https://github.com/jawah/charset_normalizer/releases)
- [Changelog](https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jawah/charset_normalizer/compare/3.4.9...3.5.1)

---
updated-dependencies:
- dependency-name: charset-normalizer
  dependency-version: 3.5.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 22:59:19 +02:00
Anupam Mediratta 9236be96bd fix: upgrade github.com/containerd/containerd to 1.7.32 (CVE-2026-46680) (#11634)
fix: CVE-2026-46680 security vulnerability

Automated dependency upgrade by OrbisAI Security

Signed-off-by: anupamme <mediratta@gmail.com>
2026-08-20 22:56:33 +02:00
mudler's LocalAI [bot]andmudler 80dd0fd076 docs: ⬆️ update docs version mudler/LocalAI (#11643)
⬆️ 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-08-20 22:55:36 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto d2588b9177 docs(blog): add the 4.9 release post and its demo clips (#11629)
* docs(blog): add the 4.9 release post and its demo clips

The 4.9 cycle changed how you authenticate, how chat handles a history
that no longer fits, and where models and backends live in the UI. The
release notes list every pull request; this post covers the three
changes that alter day-to-day use, and leads with the auth one because
it needs an action before upgrading.

Two clips are recorded from a real session against a local-ai built
from master with the live gallery loaded: model-lifecycle.mp4 walks
the unified models and backends pages, import-model.mp4 shows the
rebuilt import form. Both follow the clip conventions in
.agents/preparing-a-release.md: h264, no audio track, 1000x562, under
30 seconds, and named after the feature so they stay reusable.

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

* docs(blog): anti-slop pass over the 4.9 post

Ran the post through the humanizer and no-ai-slop rules, calibrated
against what-landed-in-localai-4-8.md. That post is the one #11324
left unchanged, so it sets the rate for this series.

The draft ran denser than it on two constructions: "rather than" at
5.1 per thousand words against 3.5, and "instead of" at 3.1 against
1.6. Both are now at or below the 4.8 rate, 2.7 and 1.5, by rewriting
seven of them as plain statements.

Also cut: "keeping both cost a mode switch", a ledger metaphor for
something that is not money, which is the tell #11324 removed eight
times from the APEX post. "A follow-up fixed the thing that made that
awkward", an unearned framing plus a colon reveal. "This release adds
a different one: compress them", a second colon reveal. And
"byte-structurally identical", a second exactness idiom in a post that
already uses "byte-identical" where the precision carries weight.

Five paragraphs opened with "Two things" or "Two details", so three of
them start differently now. The summary listed three items, which is
the rule of three; it lists four, like the 4.8 summary.

Every figure, PR number, link and media reference is unchanged, checked
by diffing them out of both revisions. Hugo builds clean and the
rendered HTML has no em dashes.

Assisted-by: Claude Code:claude-opus-5 [Bash]
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-08-20 22:55:13 +02:00
Anupam Mediratta 9ba4bbf9bb fix: upgrade ip-address to 10.3.1 (CVE-2026-69192) (#11632)
fix: CVE-2026-69192 security vulnerability

Automated dependency upgrade by OrbisAI Security

Signed-off-by: anupamme <mediratta@gmail.com>
2026-08-20 20:04:11 +02:00
localai-org-maint-botandlocalai-org-maint-bot f7ad3f70eb feat(gallery): add Gemma 4 agentic variants (#11622)
Expose the upstream Q8_0 build and the MTP drafter for the existing Gemma 4 12B agentic entry. The variants let LocalAI select higher fidelity or faster speculative decoding from the same model family.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-20 12:53:54 +02:00
0ef6a5d7f1 chore: ⬆️ Update NVIDIA/NeMo-Speech.cpp to 4f9676226f667d14608487df744f375db87127f8 (#11417)
* ⬆️ Update NVIDIA/NeMo-Speech.cpp

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

* fix(nemo-speech-cpp): build required sentencepiece

The updated runtime uses SentencePiece for RNNT context biasing even when Flashlight and text normalization are disabled. Build upstream's pinned static archive before configuring the engine so every backend variant has the newly required library and headers.

Assisted-by: Codex:gpt-5

* fix(nemo-speech-cpp): support BSD install

The pinned SentencePiece script uses GNU install -D for license files. macOS provides BSD install, which rejects that flag after the library compiles.

Create the license directory and use the portable install form on Darwin.

Assisted-by: Codex:gpt-5

* fix(nemo-speech-cpp): support CMake 4

Homebrew CMake 4 rejects the old policy floor in the pinned SentencePiece helper. Select the oldest supported policy floor for the Darwin dependency build.

Assisted-by: Codex:gpt-5.6 [Codex CLI]

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-20 08:16:55 +00:00
mudler's LocalAI [bot]andmudler 98fd29c1e3 chore: ⬆️ Update CrispStrobe/CrispASR to a153b09b37c90cd55cd9336fccbdf3ba7a289596 (#11611)
⬆️ 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-08-20 09:16:17 +02:00
mudler's LocalAI [bot]andmudler 5b87f4adf7 chore(model-gallery): ⬆️ update checksum (#11615)
⬆️ 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-08-20 08:17:43 +02:00
mudler's LocalAI [bot]andmudler ea1d19064d chore: ⬆️ Update leejet/stable-diffusion.cpp to 97d2990807fe6d558e395f8764198d7c7e7b411c (#11616)
⬆️ 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-08-20 08:17:28 +02:00
mudler's LocalAI [bot]andmudler ea1a876b46 chore: ⬆️ Update 0xShug0/audio.cpp to a61da671b6a81c79071500954eea3c91c1a383dd (#11613)
⬆️ 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-08-20 08:17:13 +02:00
mudler's LocalAI [bot]andmudler 63d6993e0a chore: ⬆️ Update mudler/vllm.cpp to 438305e1577768ec0f75729456a4c8b9f425e2ee (#11612)
⬆️ 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-08-20 08:17:00 +02:00
localai-org-maint-botandlocalai-org-maint-bot 4ee667343c feat(gallery): add Ornith 1.5 9B variants (#11603)
Add the official Q4_K_M and Q8_0 GGUF builds with the shared BF16 vision projector.

Assisted-by: Codex:gpt-5 [Hugging Face API]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-19 20:49:55 +02:00
mudler's LocalAI [bot]andmudler 6fae75cb21 chore(model gallery): 🤖 add 1 new models via gallery agent (#11605)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-19 20:49:37 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 0ab632b6bd fix(auth): protect HTTP routes by default (#11602)
* fix(auth): default to protected HTTP routes

Use a method-aware registry for the small anonymous bootstrap surface.
Unknown routes now require credentials instead of inheriting fail-open
path classification.

Keep node self-service routes behind their registration-token middleware.
Global auth no longer rejects valid worker credentials first.

Assisted-by: Codex:gpt-5

* docs(auth): document public HTTP surface

Assisted-by: Codex:gpt-5

* test(auth): align route coverage with default denial

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-19 20:49:19 +02:00
localai-org-maint-botandlocalai-org-maint-bot 34e986de0a feat(gallery): add Carbon genomics variants (#11594)
Add the Carbon 3B and 8B genomic foundation models with Q4_K_M and Q8_0 GGUF builds.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-19 16:40:28 +02:00
localai-org-maint-botandlocalai-org-maint-bot 0df8de35e2 feat(gallery): add Hy-MT2 1.8B variants (#11599)
Add Tencent’s compact translation model in Q4 and Q8 GGUF formats. The linked variants let LocalAI select the best build that fits each host.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-19 16:40:10 +02:00
Ettore Di Giacinto 2383726d6d Revert "chore(tests): Avoid network, sleep and more during tests" (#11601)
Revert "chore(tests): Avoid network, sleep and more during tests (#11050)"

This reverts commit cb3bf7af3f.
2026-08-19 16:39:39 +02:00
localai-org-maint-botandlocalai-org-maint-bot 6d27f3b210 feat(gallery): add Ling 3.0 Flash variants (#11598)
Add IQ1_M and IQ2_XS GGUF builds for the 124B hybrid reasoning model.

Assisted-by: Codex:GPT-5 [Codex CLI] [curl]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-19 12:43:15 +02:00
Richard Palethorpeandlocalai-org-maint-bot cb3bf7af3f chore(tests): Avoid network, sleep and more during tests (#11050)
* test: make coverage failures observable

Keep per-root logs, reject concurrent coverage runs, and avoid relying on /bin/sleep in the worker timeout test.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: parallelize coverage without remote fixtures

Assisted-by: Codex:gpt-5 [apply_patch] [exec_command]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: add offline resource infrastructure

Introduce versioned resource manifests, a checksum-verified CAS preparer, offline test wrappers, and a guarded network transport. Replace live Hugging Face, GitHub, and OCI cases with deterministic fixtures and inject fixture metadata into importer discovery.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: enforce offline resource replay

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: harden offline resource refresh

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: expose slow coverage waits

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: eliminate avoidable wall-clock waits

Inject a clock into Hugging Face retry handling, reuse a process-scoped PostgreSQL container with per-spec schemas in the nodes suite, and poll local import jobs promptly.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: remove repeated fixture startup waits

Share PostgreSQL fixtures across parallel endpoint and agent suite workers, and make the worker Free deadline injectable so the wedged-backend test does not spend five seconds on wall-clock time.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: fix offline resource CI portability

Normalize Docker archive metadata before content addressing, derive archive checksums during explicit refreshes, make network lint portable to macOS, and prepare distributed images before running their offline suite.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* ci: cache Go modules before offline tests

Warm the complete module graph before the Linux and macOS test jobs enter offline replay mode, so tool dependencies such as Ginkgo are not fetched through the guarded proxy.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: drop the static network lint in favour of real isolation

The offline test suite already prevents tests from reaching the network
twice over: run-test-linux-offline.sh puts the test process in a cgroup
and REJECTs egress outside the private ranges, and HardenedTransport
installs testnetwork.LocalGuard to refuse dials that resolve to a public
address. Both fail the test with a precise error at the moment of the
dial.

test-network-lint.sh added neither. Its diff stage defaulted to a HEAD
base, so on a clean checkout it compared the tree against itself and
inspected nothing; the branch's own commits were never examined. It only
produced output when an earlier job step dirtied the tree, and then it
matched a bare https?:// against whatever changed. make react-ui runs
npm install rather than npm ci, so CI rewrote
core/http/react-ui/package-lock.json and the lint reported an npm
registry URL as forbidden test network access:

  +      "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",

Its fingerprint stage was self-defeating in a quieter way: hashing the
whole tree's network-mechanism inventory meant every rebase onto a master
that touched any _test.go needed a manual baseline bump, so the check
mostly caught its own staleness.

Remove the script, its make target and the two prerequisite edges, along
with the test-network: fixture markers that existed only to suppress it.
The isolation itself is untouched.

Assisted-by: Claude:claude-opus-5 [go vet]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* ci: keep hidden files in the offline test bundle artifact

Cherry-picked from 15a37b0ac on the remote branch. The offline bundle lives
under .cache/, which actions/upload-artifact skips by default, so the Linux
job packed an artifact missing the very file the next step restores.

The other half of 15a37b0ac moved test-network-lint out of the `test` and
`test-coverage` prerequisite lists into a recipe line, so parallel make could
not fingerprint the tree while generated fixtures were still changing. That
is dropped: the preceding commit removes the lint entirely, and the race it
worked around is one more reason a whole-tree fingerprint was the wrong
mechanism.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* refactor: share bounded exponential backoff

Use overflow-safe saturating arithmetic for retry delays across model import polling, downloads, registration, node operations, and model loading. Keep model import status checks responsive initially while capping their interval at 500ms.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* ci: mirror Jetson Python wheels

Keep the CUDA aarch64 wheel subset in GHCR and serve it as a local PEP 503 index during L4T backend builds, preserving last-known-good packages through upstream outages.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* docs(agents): index the Jetson wheels mirror

Mention the GHCR-hosted L4T wheel mirror in the CI caching guide summary so maintainers can find its outage and cache documentation.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* ci: add defensive build network proxy

Record build destinations and byte counts, retry observable idempotent HTTP downloads, and isolate explorer database tests that race under coverage.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(kokoros): implement updated backend trait

Return unimplemented for image upscaling, matching the backend's other unsupported modalities after the protobuf API update.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(ci): clear recovered proxy errors

Do not mark a request failed when a later safe retry succeeds.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* ci: require HTTPS build interception

Inject a short-lived proxy CA into BuildKit and Dockerfile RUN steps, reject plain HTTP and opaque tunnels, and retain method/status/byte telemetry for verified HTTPS traffic.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(ci): preserve system trust in unproxied builds

Mount the generated interception CA at a dedicated secret path and add it to the trust bundle only in proxy-aware dependency stages. This prevents optional secret mounts from masking the system CA bundle in ordinary backend test builds.

Install the requested Go toolchain before starting the proxy and satisfy cleanup error checks found by CI lint.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(ci): persist build proxy trust

Install the generated proxy CA through the system-managed local certificate directory so ca-certificates upgrades retain it. Avoid turning canceled matrix jobs into proxy cleanup failures.

Assisted-by: Codex:gpt-5

Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(ci): trust proxy in nested build scripts

Install the build proxy CA before nested source fetches, route the DS4 package setup through the HTTPS mirror helper, and avoid repeated OCI setup in gallery behavior tests.

Assisted-by: Codex:gpt-5

Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(ci): use HTTPS apt sources for Bonsai

Rewrite ARM64 package sources before installing GCC and check gallery fixture cleanup errors so the optimized tests satisfy errcheck.

Assisted-by: Codex:gpt-5

Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(privacy-filter): trust build proxy CA

Install the mounted build proxy certificate before privacy-filter's make target fetches its HTTPS sources, for both source and prebuilt builder paths.\n\nAssisted-by: Codex:gpt-5

Signed-off-by: Richard Palethorpe <io@richiejp.com>

* test: fail on hidden offline egress

Count cgroup-scoped firewall rejects and fail the offline test harness with bounded aggregate diagnostics. Inject the gen-audio GGUF probe so fixture-backed importer tests do not attempt real network access.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(ci): preserve system CA trust

Build a combined runner certificate bundle instead of replacing public roots with the generated proxy CA. Centralize additive container installation in the shared proxy CA helper.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-19 10:59:31 +02:00
localai-org-maint-botandlocalai-org-maint-bot de3329e332 feat(gallery): add UI-Mate 9B variants (#11584)
Add Q4_K_M and Q8_0 GGUF builds for Tencent UI-Mate-9B. Include the f16 vision projector so llama.cpp can process desktop screenshots.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-19 08:48:35 +02:00
localai-org-maint-botandlocalai-org-maint-bot aa7d016a48 feat(gallery): add North Mini Code variants (#11559)
Add the supported Q4_K_M and Q8_0 GGUF builds for Cohere Labs' 30B-A3B coding model.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-19 08:48:24 +02:00
mudler's LocalAI [bot]andmudler 89d1cda0fc chore(model gallery): 🤖 add 1 new models via gallery agent (#11596)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-19 08:46:12 +02:00
localai-org-maint-botandlocalai-org-maint-bot 4cad809003 fix(ci): test stale chunks in split bundle (#11595)
The V8 coverage build inlines every dynamic import, so the stale chunk tests cannot intercept a page chunk. Run those tests against the normal code-split bundle and exclude them from the inlined coverage pass.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-19 08:45:59 +02:00
mudler's LocalAI [bot]andmudler ea60f62d66 chore: ⬆️ Update mudler/parakeet.cpp to e75de9b6b9b688fd293aa22f7e27aa724ea286f8 (#11592)
⬆️ Update mudler/parakeet.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-19 08:45:27 +02:00
mudler's LocalAI [bot]andmudler 25cfc7c3c9 chore: ⬆️ Update CrispStrobe/CrispASR to a117d433855ecc36b0cb20bd1abddd852902ed13 (#11591)
⬆️ 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-08-19 08:45:07 +02:00
mudler's LocalAI [bot]andmudler a57fce10c5 chore: ⬆️ Update ggml-org/llama.cpp to 60addddf3c567c43ec3caf70fc953fba3572d96f (#11590)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-19 08:44:55 +02:00
mudler's LocalAI [bot]andmudler 2ba3265c67 chore: ⬆️ Update mudler/vllm.cpp to 6e99e4540b8d0811542a9d7690dc9aa0bda5812f (#11589)
⬆️ 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-08-19 08:44:44 +02:00
mudler's LocalAI [bot]andmudler 61f02f7504 chore: ⬆️ Update ggml-org/whisper.cpp to 4834a2327d008ace3ec5a9ed00f51454bcabbc1c (#11588)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-19 08:44:32 +02:00
mudler's LocalAI [bot]andmudler 59001291e1 chore: ⬆️ Update 0xShug0/audio.cpp to 92816fc5c48d33a58171b49678ccf884d70142fb (#11587)
⬆️ 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-08-19 08:44:13 +02:00
mudler's LocalAI [bot]andmudler 77ff6889ec chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260818075955 (#11586)
⬆️ 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-08-19 08:39:14 +02:00
mudler's LocalAI [bot]andmudler 08786395be feat(swagger): update swagger (#11585)
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-08-19 08:37:21 +02:00
Ettore Di Giacinto b806b1fec3 fix(ui): reload once when a page chunk 404s
A deploy replaces the whole content-hashed asset set at once. A tab
holding an older index.html, or one whose request lands on a replica
that the rollout has not swapped yet, asks for a page chunk the server
no longer has. The dynamic import rejects and React Router's default
error boundary replaces the app with "Unexpected Application Error!"
until someone reloads by hand.

The router now reloads the page itself when a chunk fails to load.
index.html is served no-cache, so the reload lands on a self-consistent
asset set. A timestamp in sessionStorage bounds this to one reload per
10 seconds, so a chunk that is genuinely gone reaches the error boundary
instead of looping forever.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m]
2026-08-18 19:51:15 +00:00
Stefan Walcz 178ac66d77 docs(ecosystem): add Walcz to contributors (#11582)
Nine commits in this repository, all merged: native Prometheus metrics for agent
chat runs (#10689), PII/audit events as a Prometheus counter (#10641), optional
Anthropic prompt-cache breakpoints (#11158), plus fixes to cloud-proxy, the agent
UI and the Python backend.

The author's public GitHub profile names the employer, which is the evidence this
file asks for. Counts verified with the GitHub commit search API on 2026-08-18.

Placed by commit count, between Spectro Cloud and VictoriaMetrics.

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-08-18 17:55:58 +02:00
Stefan Walcz 906868992e docs(adopters): add walcz.de (#11581)
We run LocalAI as the inference layer of a self-hosted appliance for a German
B2B consultancy — local-only, on AMD Strix Halo (gfx1151) with ROCm.

In daily production use for agents with MCP tools, RAG over an internal
knowledge base, and a document/bookkeeping pipeline.

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-08-18 17:55:35 +02:00
localai-org-maint-botandlocalai-org-maint-bot 0761bd02c7 feat(chat): add end-to-end context compression (#11556)
* feat(config): add context compression policy

Define the opt-in model configuration contract before the chat middleware consumes it. Document each policy field so later request handling does not invent a second schema.\n\nRefs #9534\n\nAssisted-by: Codex:gpt-5

* fix(config): register compression fields

The model editor metadata gate rejects new config fields without descriptions and suitable controls. Register the compression policy so operators can edit its six fields safely.

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

* feat(chat): compress long contexts

Long conversations currently fail once they reach the model context window. The opt-in policy now summarizes complete older turns before primary inference and preserves the newest tool chains.

Both OpenAI and MCP chat routes share the same transformation. Usage metadata and metrics expose each compression event.

Refs #9534

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 11:31:03 +00:00
Nicholas Ciechanowski cf93c04878 fix: tts text wrap (#11576)
Signed-off-by: Nicholas Ciechanowski <nicholas@ciech.anow.ski>
2026-08-18 12:57:55 +02:00
4058194b18 chore: ⬆️ Update 0xShug0/audio.cpp to 3407559d7c83b543bd60ca8fa2b630374e71083f (#11474)
* ⬆️ Update 0xShug0/audio.cpp

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

* fix(audio-cpp): map the MIDI task

audio.cpp now appends MIDI to its task enum. Keep the LocalAI mirror and conversion switches aligned so the backend builds against the new pin.

Assisted-by: Codex:gpt-5

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 09:46:56 +02:00
6d4c64c6c3 chore: ⬆️ Update mudler/vllm.cpp to 76f2a6d84e41cc9a593146a9a958f63645542731 (#11555)
* ⬆️ Update mudler/vllm.cpp

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

* fix(vllm-cpp): track ABI v21

The updated engine exposes ABI v21 after extending its speech API. The Go
backend does not bind that API, so its existing mirrors remain valid.

The qwen3.5 warning fix is now present upstream, so the old patch no
longer applies.

Assisted-by: Codex:gpt-5

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 09:46:15 +02:00
mudler's LocalAI [bot]andlocalai-org-maint-bot 4c4911fe2c chore: ⬆️ Update ggml-org/llama.cpp to 0021a77de0a8966059dc94548fb3b96654e0bb12 (#11508)
* chore(llama-cpp): update upstream revision

Assisted-by: Codex:gpt-5.6

* fix(llama-cpp): refresh server patch contexts

The new llama.cpp pin changed the slot reset and prompt batch code. GNU patch accepted stale hunks with fuzz, which left the L4T build with invalid source.

Refresh both server patches against the pinned source so each hunk applies at its intended location.

Assisted-by: Codex:gpt-5

* fix(llama-cpp): adapt metrics result fields

The updated llama.cpp groups cumulative counters under server_metrics. Probe the result layout so the shared adapter also compiles against older forks.

Assisted-by: Codex:gpt-5

* fix(llama-cpp): refresh TTS patch offsets

GNU patch rejects the stale pre-decode hunk after the score patch changes the same file. Anchor the TTS hunks to the pinned llama.cpp source so the full series applies without fuzz.

Assisted-by: Codex:gpt-5.4

* fix(llama-cpp): normalize batch threads

The updated llama.cpp creates its batch threadpool during model initialization, before the context-level fallback can replace the -1 sentinel. Resolve that sentinel from the inference thread count so model loading does not overflow the threadpool allocation.\n\nAssisted-by: Codex:gpt-5.4

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 09:45:56 +02:00
Richard Palethorpe d10374f849 feat(router): make KNN a first-class classifier with a persisted, curated corpus (#10652)
* feat(router): make KNN a first-class classifier with a persisted, curated corpus

Add `classifier: knn` — similarity-weighted voting over labelled
example prompts. Unlike score/colbert it needs no classifier model:
label knowledge lives in a corpus seeded and curated through the
admin API, so routing decisions are deterministic, auditable, and
grounded in graded experience rather than a model's opinion.

Epistemic gate: corpus entries below knn.similarity_threshold cannot
vote; when none clears it the classifier activates no labels and the
router uses the fallback — a prompt unlike all labelled experience is
treated as undecidable, not guessed. Decisions record
nearest_similarity (also on fallback rows) so admins can see how far
the nearest labelled experience was; the Routing tab explains
out-of-corpus fallbacks and shows per-label corpus counts.

Persistence: one JSONL file per router under
<data path>/router-corpus (text, labels, vector, embedder
fingerprint). The file is the source of truth; the local-store index
is rebuilt from it at classifier build time and stays a pure
in-memory index. Entries recorded under a different embedding model
re-embed on load. Also corrects the docs' false claim that
local-store collections persist — the embedding cache never survived
restarts (and still doesn't); the corpus does.

Corpus input is API-only by design (entries may contain example user
content): POST /api/router/{name}/corpus seeds (labels validated
against declared policies, embedded server-side, indexed
immediately), GET .../corpus/stats inspects — label counts only,
entry texts are never returned by any surface — DELETE .../corpus
wipes. Admin-gated like the sibling router endpoints, and exposed as
MCP tools (seed_router_corpus / get_router_corpus_stats /
clear_router_corpus) in both the httpapi and inproc clients with
coverage-test route mappings.

Plumbing: VectorStore gains SearchK (top-K was hardcoded to 1);
local-store gets InsertBatch/Delete as optional fast paths;
RouterConfig gains a knn block (embedding_model, k,
similarity_threshold, vote_threshold, store_name) with meta-registry
fields; the classifier dropdown now offers knn and the
previously-missing colbert; embedding_cache is ignored (with a
warning) for knn — it IS an embedding-KNN lookup; the stale
/api/instructions intelligent-routing entry is rewritten (it
described a classifier that no longer exists); swagger regenerated.

Tests: KNN vote/gate specs with hand-computed vote shares, corpus
manager suite (restart reload without re-embedding, fingerprint
re-embed, dedupe, hostile store names), middleware specs (corpus
routing, gate fallback, config validation, cache-wrap refusal),
corpus endpoint specs pinning the texts-never-returned contract, MCP
catalog + route-mapping gates, and a Playwright spec for corpus
stats and the out-of-corpus decision detail.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(router): name consulted corpus neighbours in knn decisions

Every knn decision (decision log rows and the /api/router/decide
response) now carries neighbors: the K retrieved corpus entries by
descending similarity - including ones below the epistemic gate, which
is what makes fallback decisions diagnosable - each as {id, similarity,
labels}. The id is the entry's content hash (first 8 bytes of the
SHA-256 of its text, hex): stable across reseeds and re-embeds, and
text-free, so an external platform that seeded the corpus can recompute
text->id on its own copy and bucket decisions by corpus region (per-
region reliability accounting) without corpus text ever leaving the
server. A corrupt index payload surfaces as an id-less neighbour at a
real similarity instead of disappearing.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* refactor(router): deduplicate knn plumbing and cut corpus hot-path waste

Post-review cleanup of the knn-first-class-router branch; no behaviour
changes on the API surface.

Reuse/altitude:
- RouterKNNConfig.ResolvedStoreName is now the single source of the
  router-corpus-<name> default (was hand-derived in four files).
- corpus.ResolveKNNRouter + corpus.Seed carry the shared model
  resolution and seed validation; the REST endpoints and the assistant
  MCP client are thin transport adapters over them, with sentinel
  errors mapped to HTTP statuses at the echo boundary.
- middleware.NewClassifierDeps assembles the classifier dependency set
  once for all five entry points (OpenAI, Anthropic, realtime, decide,
  corpus) instead of five hand-copied literals.
- router.AllClassifiers feeds both the status endpoint and the
  unknown-classifier error, ending the classifier-list drift.
- Per-classifier requirements moved out of validateRouterPolicies into
  their buildClassifier arms; the knn arm owns its embedding_cache
  opt-out instead of a name-check in the shared wrap tail.
- adminOnly replaces four inline copies of the admin gate in the
  middleware routes.
- localVectorStore.Search delegates to SearchK (identical traces).

Efficiency:
- Manager.Add embeds outside the manager mutex and appends to the
  JSONL file (O(new) instead of O(corpus) rewrite); a torn tail from a
  crash mid-append is tolerated on read and repaired on next write.
- Stats memoises per store keyed on the file's stat fingerprint and no
  longer takes the manager mutex, so the 5s status poll stops parsing
  vector-laden JSONL and stops blocking behind seeds.
- KNN Classify decodes each neighbour payload once (was twice) and
  builds refs and votes in a single pass with one fallback return.
- Corpus file writes fsync before rename/close.
- The corpus manager is built eagerly in newApplication (sync.Once
  dropped); test helper dead branch removed.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(router): bind knn corpus vectors to an embedder fingerprint and fail closed on mismatch

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* chore(mcp): align corpus tool prompts and the mutating-tool safety list

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(proto,backend): report embedding shape from the llama-cpp backend

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(embeddings): Go-side pooling — mean/last/decayed_mean with half-life

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(embeddings): accept chat messages[] and per-request pooling on /v1/embeddings

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* chore(middleware): name the failing fields when post-merge validation 400s

An intermittent post-merge validation failure surfaced as an opaque 400
during integration (pooling scheme mismatch that no client had sent).
Log the model, the request's pooling override, and the merged config's
pooling fields at the failure point so the next occurrence identifies
whether the request or the stored config carried the bad value.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(embeddings): scheme override must not inherit the config's half-life

A model config defaulting to decayed_mean pooling carries
pooling_half_life_tokens; a request overriding the scheme to mean/last
without its own half-life inherited that value, and post-merge
validation rejected the pair the server itself had assembled. Zero the
inherited half-life when the overridden scheme is not decayed_mean; a
request that explicitly pairs a half-life with a non-decayed scheme
still 400s.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix embedding pooling validation and router bounds

Declare backend embedding layouts and reject incompatible pooling modes. Reset local-store dimensions after a full clear, validate KNN thresholds, and add real backend and store integration coverage.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* ci: run local-store integration tests

Build and install the local-store backend in the Linux test job, then run the existing store integration suite so new specs are discovered automatically.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-08-18 09:37:43 +02:00
localai-org-maint-botandlocalai-org-maint-bot 10b4a8a5ae feat(gallery): add MiniCPM5 1B Q8 variant (#11561)
The official GGUF repository publishes both Q4_K_M and Q8_0 builds, but the gallery only exposed Q4_K_M. Link the higher-quality Q8_0 build so capable hosts can select it automatically.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 09:04:03 +02:00
localai-org-maint-botandlocalai-org-maint-bot a7bce6a128 fix(audio): reject incompatible transform streams (#11565)
The transform WebSocket accepted any model and opened its frame-based RPC. Any-to-any models use a different stream contract, so liquid-audio failed with an unimplemented RPC after the handshake.

Reject incompatible model use cases before loading the backend. Direct realtime-audio callers to the OpenAI Realtime API.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 09:03:16 +02:00
localai-org-maint-botandlocalai-org-maint-bot 411fdf10c3 fix(fish-speech): preserve ROCm PyTorch (#11568)
The upstream editable install pins generic PyTorch packages. It
replaces the HIP wheels with CUDA wheels in ROCm images.

Remove those pins only for hipBLAS builds before the editable install.
Keep the existing CPU and CUDA dependency behavior unchanged.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 08:58:38 +02:00
Richard Palethorpe 799cc9f211 feat: bound global admission and expose running backend traces (#11560)
feat: bound backend admission and expose running traces

Add process-wide backend execution admission without blocking UI or administrative HTTP work. Represent backend operations while they are in flight, surface running traces with immediate log links, and tie streaming admission leases to the gRPC receive lifecycle.

Assisted-by: OpenAI Codex: GPT-5

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-08-18 08:56:59 +02:00
mudler's LocalAI [bot]andmudler 2ae7b45940 chore: ⬆️ Update CrispStrobe/CrispASR to 007fda47128aaba7e2ce2279b0947a1c13800d68 (#11569)
⬆️ 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-08-18 08:55:42 +02:00
mudler's LocalAI [bot]andmudler a755608cff chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260817081527 (#11570)
⬆️ 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-08-18 08:54:51 +02:00
localai-org-maint-botandlocalai-org-maint-bot b858f334fa feat(gallery): add Qwen3.8 small variants (#11571)
Add the 2B and 4B reasoning distillations in Q4_K_M and Q8_0 formats. These sizes extend the existing Qwen3.8 family to compact and edge hosts.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 08:54:41 +02:00
localai-org-maint-botandlocalai-org-maint-bot b5258f5d46 fix(ci): refresh site counters through a PR (#11572)
Branch protection rejects the weekly workflow's direct push to master. Reuse the repository's create-pull-request automation so counter updates go through the protected-branch review and CI path.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 08:54:30 +02:00
localai-org-maint-botandlocalai-org-maint-bot 4c740a0c65 feat(gallery): add Qwen3.8 Ridge variant (#11573)
Add the smaller 3.69-bit mixed quantization to the existing Qwen3.8 27B variant group. Enable its embedded MTP head so compatible hosts can prefer speculative decoding.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-18 08:54:12 +02:00
localai-org-maint-botandlocalai-org-maint-bot c29c99e120 feat(gallery): add Ornith 1.0 9B variants (#11552)
Add Q4_K_M and Q8_0 llama.cpp builds with the shared vision projector. The MIT-licensed agentic coding model is absent from the current gallery.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-17 08:57:29 +02:00
localai-org-maint-botandlocalai-org-maint-bot 2fa431bf6f feat(gallery): add Qwen3.8 9B variants (#11557)
Add Q4_K_M and Q8_0 llama.cpp builds for the text-only Qwen3.8 9B distillation.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-17 08:57:13 +02:00
mudler's LocalAI [bot]andmudler 8d8ea91fbf chore: ⬆️ Update CrispStrobe/CrispASR to 3721d402f7bcc911dd4143a58e3da1cc67f09cc2 (#11554)
⬆️ 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-08-17 08:57:01 +02:00
mudler's LocalAI [bot]andmudler b6d7046a51 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260816085229 (#11553)
⬆️ 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-08-17 08:51:04 +02:00
869b30d18f chore: ⬆️ Update mudler/vllm.cpp to 4880c5715f36445a30bd39d3349a06dc96085a11 (#11515)
* ⬆️ Update mudler/vllm.cpp

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

* fix(vllm-cpp): mirror ABI v20 layouts

The dependency bump advances the engine ABI from v17 to v20. The old
binding stops every backend build at the ABI guard and undersizes
structures used at runtime.

Mirror the appended model and video fields so every platform uses the
pinned engine layout.

Assisted-by: Codex:gpt-5

* fix(vllm-cpp): satisfy Apple Clang

The new engine pin captures a namespace-scope help string in a lambda. Apple Clang rejects the redundant capture because upstream enables -Werror.

Carry the one-line source patch until the engine pin includes the fix.

Assisted-by: Codex:gpt-5

* fix(vllm-cpp): align the carry patch

The Apple Clang patch used context from another source revision.
Source preparation rejected it before every backend build.

Align the patch with the pinned engine revision.

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

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-16 21:45:49 +02:00
localai-org-maint-botandlocalai-org-maint-bot dcb8b278a5 feat(gallery): add OvisOCR2 variants (#11549)
Add Q4_K_M and Q8_0 llama.cpp builds with the required F16 vision projector.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-16 21:45:05 +02:00
localai-org-maint-botandlocalai-org-maint-bot 069204e0e7 feat(gallery): add AREX Turbo variants (#11551)
Add Q4_K_M and Q8_0 llama.cpp builds for BAAI AREX-Turbo. The compact research agent is absent from the current gallery.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-16 21:44:53 +02:00
localai-org-maint-botandlocalai-org-maint-bot 804dc10968 feat(gallery): add Tess 4 27B variants (#11547)
Add Q4_K_M and Q8_0 multimodal builds. Include an MTP-enabled Q4_K_M build for speculative decoding.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-16 21:44:22 +02:00
localai-org-maint-botandlocalai-org-maint-bot d666f1a0f0 feat(gallery): add HunyuanOCR variants (#11540)
Add the official Q8 and BF16 llama.cpp builds for the archived HunyuanOCR 1.0 checkpoint.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-16 21:44:09 +02:00
localai-org-maint-botandlocalai-org-maint-bot 1e3e72ecb8 chore(deps): bump golang.org/x/net to v0.55.0 (#11544)
Assisted-by: Codex:gpt-5.6

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-16 11:57:55 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 0aaff91ebd feat(ui): unify model and backend lifecycle (#11548)
* feat(ui): add installed model lifecycle

Models now owns catalog exploration and installed runtime controls under one canonical route. URL-owned state keeps lifecycle context recoverable through links and browser history.

Assisted-by: Codex:gpt-5 Playwright

* feat(ui): add installed backend lifecycle

Backends split discovery from backend-binary management. The canonical
page now keeps both lifecycle views under one URL-backed shell while it
preserves target-node placement.

Assisted-by: Codex:gpt-5 Playwright

* fix(ui): repair lifecycle state updates

Installed models lost distributed refreshes and kept a deleted selection. Backend searches also stopped tracking URL changes, while batch upgrades stopped after their first error.

Preserve background refreshes and finish each requested batch action. Drive catalog results from URL-backed state without losing full metadata.

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

* feat(ui): make resource pages canonical

Replace Host navigation with canonical Models and Backends lifecycle routes, preserve legacy management URLs, and surface shared host capacity on the Operate overview.

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

* feat(ui): complete canonical resource lifecycle

Finish the responsive list-to-detail behavior, remove the retired Host implementation, and keep Explore focused on discovery while Installed owns destructive actions. Update regression coverage, localization, documentation, and development binding for the canonical resource pages.

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

* docs(ui): record the UI design context

Record the approved users, brand character, and design principles so
future interface work uses the same product direction. Index the context
from the repository's agent instructions.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-16 11:57:31 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 6fb9ab38aa feat(gallery): add vllm.cpp text-generation models (#11511)
Adds eight curated vllm-cpp entries to the model gallery. Until now the
backend had gallery coverage only for MiniMax-H3 video, so serving text on
it meant hand-writing engine_args.

The flagship tier is what vllm.cpp gates its correctness and speed claims
on: Qwen3.6-27B and Qwen3.6-35B-A3B in NVFP4, each with a speculative
sibling (MTP on both, DFlash on the 27B). Qwen3-Coder-30B-A3B covers
agentic tool use, and Qwen3-4B / Qwen3-0.6B in bf16 are the entries that
run where NVFP4 cannot, CPU included.

Three details are load-bearing rather than incidental:

- The 27B entries pin revision 890bdef7. That repository was later
  re-quantized in place from NVFP4 to FP8 W8A8 under the same name, so an
  unpinned entry resolves to different weights and reports nothing.
- Qwen3-Coder names tool_parser: qwen3_coder explicitly. Its dialect is
  byte-identical on the wire to step3p5's, so chat-template sniffing
  cannot separate them and auto-detection picks wrong.
- enable_prefix_caching is deliberately left unset everywhere. It defaults
  on for dense models and off for the GDN hybrids, and that per-model
  default is the right answer.

num_blocks is sized per model from its real KV footprint rather than
copied between entries, which ranges from 20 KiB/token on the 35B to
144 KiB/token on the 4B.

Docs: adds features/vllm-cpp.md covering installation, the model table,
the pinning rationale and how to choose between the speculative variants,
and cross-links it from the existing engine_args reference. It also
records that the CUDA images are built for Blackwell only, which is
narrower than vllm.cpp's own ten-architecture release and makes an
otherwise cryptic "no kernel image is available" failure legible.

Verified: gallery suite green; all eight decode and validate as a
ModelConfig. qwen3-0.6b-vllm-cpp confirmed end to end on a real cluster,
chat plus engine-parsed tool_calls. The NVFP4 entries are not yet
runtime-verified: no available node has kernels for them.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-16 00:32:37 +02:00
mudler's LocalAI [bot]andmudler 7a2a624424 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260815085651 (#11541)
⬆️ 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-08-16 00:32:04 +02:00
mudler's LocalAI [bot]andmudler a556b1a10d chore: ⬆️ Update ikawrakow/ik_llama.cpp to 8337e4cd3861406fc04e0854b1409cd1b027fbc9 (#11542)
⬆️ 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-08-16 00:31:54 +02:00
localai-org-maint-botandlocalai-org-maint-bot db21c47a76 fix(downloader): retry checksum mismatches (#11536)
A remote can serve stale or corrupted bytes for one request. Mark the
integrity failure as transient so the bounded download planner retries it.

Assisted-by: Codex:gpt-5.6

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-15 18:07:17 +02:00
dependabot[bot]andlocalai-org-maint-bot 4f807eaa06 chore(deps): bump vllm from 0.25.1 to 0.26.0 in /backend/python/vllm (#11402)
* chore(deps): bump vllm from 0.25.1 to 0.26.0 in /backend/python/vllm

Bumps [vllm](https://github.com/vllm-project/vllm) from 0.25.1 to 0.26.0.
- [Release notes](https://github.com/vllm-project/vllm/releases)
- [Changelog](https://github.com/vllm-project/vllm/blob/main/RELEASE.md)
- [Commits](https://github.com/vllm-project/vllm/compare/v0.25.1...v0.26.0)

---
updated-dependencies:
- dependency-name: vllm
  dependency-version: 0.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* fix(vllm): pin Intel source build to release

Build the Intel XPU backend from vLLM 0.26.0 instead of the moving main branch, and use the Triton XPU version required by that release's torch 2.12 dependency.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-15 14:38:08 +02:00
88edd7fc7f fix(distributed): run cold model loads as durable jobs instead of holding the advisory lock (#11514)
* fix(advisorylock): set statement_timeout alongside lock_timeout

WithLockCtx already overrides a deployment-wide lock_timeout on its
dedicated connection so a blocking pg_advisory_lock() waits its turn
instead of failing with 55P03. statement_timeout aborts that exact same
statement independently, with SQLSTATE 57014, and was not overridden.

Production roles commonly carry statement_timeout=60s. Any guarded
section longer than that (a cold model load stages for tens of minutes)
therefore killed every concurrent waiter:

  advisorylock: acquiring lock 9003261067483446873: ERROR: canceling
  statement due to statement timeout (SQLSTATE 57014)

Derive it from the same context budget as lock_timeout, with a matching
RESET so the pooled connection is returned clean.

Assisted-by: Claude Opus 5 [claude-code]

* feat(distributed): add ModelLoadJob, the durable cold-load record

A cold load in distributed mode is a long-running background job, but it
was modelled as a synchronous side effect of an inference request: the
whole of it (backend install, multi-GB staging, checkpoint load) ran
inside the per-model advisory lock. Loading a 35.7 GB GGUF held that lock
for ~20 minutes, so every concurrent request for the same model blocked
on pg_advisory_lock and died at the role's 60s statement_timeout.

Introduce the row that lets the lock shrink to a decision. Exactly one
ModelLoadJob may be active per tracking key; that uniqueness — not the
lifetime of a lock — is what de-duplicates concurrent loaders across
replicas. ClaimLoadJob does its read-then-write under the advisory lock
and nothing else: no network, file or gRPC I/O inside the guarded
section, so a claim costs milliseconds no matter how long the resulting
load takes.

LastProgress is a heartbeat rather than a byte counter. A checkpoint load
legitimately moves zero bytes for many minutes, so a reaper keyed on byte
movement would reclaim a healthy job mid-load; byte progress stays the
concern of load_deadline.go. A job whose heartbeat stops for longer than
the orphan window is reclaimable, so a replica killed mid-load cannot
wedge a model permanently.

Failed jobs keep their row for a short grace so an immediately-following
request reports the real cause instead of silently starting a fresh load
of a model that just failed.

No caller yet — the router moves onto this in the next commit.

Assisted-by: Claude Opus 5 [claude-code]

* refactor(distributed): run cold loads as jobs, outside the advisory lock

Route wrapped the entire cold load — node selection, backend install,
multi-GB staging and the remote LoadModel — in the per-model advisory
lock. The lock's job is to de-duplicate concurrent loaders, a decision
that takes milliseconds; holding it for the tens of minutes the resulting
work takes is what turned a dedup mechanism into a cluster-wide outage
for that model.

Split it into a claim and a run. The claim is the only thing left inside
the lock. The run is a background job owned by the claiming replica and
bounded by the same progress-extended deadline as before; every other
request for that model — local or on another replica — attaches as a
waiter and is served the moment the model is ready, with no duplicate
load and no lock contention.

Waiters share one broadcast rather than an ordered queue: they all want
the identical outcome, so ordering them would add fairness machinery that
changes no result. The local channel wakes same-replica waiters instantly
and a 2s DB poll is the authority, because a waiter on another replica
has no channel to close. On wake a waiter re-runs the warm path rather
than trusting the signal — the model may have been evicted in between.

A waiter whose client disconnects returns immediately and the job keeps
running; it belongs to the job record, not to the request. A failure is
recorded on the row so every waiter reports the real cause, and the row
survives briefly so the next request does not read "no job" as "not
loading" and start a duplicate load of a model that just failed.

The runner heartbeats the row on a fixed interval whether or not bytes
are moving, which is what keeps a legitimately silent checkpoint load
from being reclaimed as an orphan. Phase (installing/staging/loading) and
placement ride to the heartbeat on the context, the same seam
load_deadline.go already uses, so single-host paths are untouched.

Non-distributed mode (no DB) keeps the inline load exactly as it was.

Assisted-by: Claude Opus 5 [claude-code]

* feat(distributed): bound the wait for a loading model and answer with progress

A request whose model is cold-loading now attaches to the running job and
is served the moment the model is ready. That wait has to be bounded: a
held HTTP request cannot survive real infrastructure, and an ingress or LB
idle timeout kills a twenty-minute request regardless of what LocalAI
does.

New LOCALAI_MODEL_LOAD_WAIT (default 60s) bounds the CALLER, never the
load — the job keeps running either way. On expiry the request gets 503
with Retry-After and a structured body naming the model, the node, the
phase, byte progress and an ETA. The `error` envelope keeps OpenAI
clients working; `loading` is additive so they ignore it.

The ETA comes from the job's own observed rate and is omitted rather than
guessed until enough bytes have moved for that rate to mean anything: a
confidently wrong ETA on a twenty-minute wait is worse than none.
Retry-After is that ETA when known, clamped to [5s, 300s], and the wait
budget otherwise.

LOCALAI_MODEL_LOAD_WAIT=0 waits unbounded, for deployments with no proxy
in front. Zero in the config struct still means "unset, use the default",
so the CLI records the operator's zero as ModelLoadWaitUnbounded rather
than losing the distinction.

The distributed branch of ModelLoader.loadModel wrapped the router's
error with %s, which flattened it to a string. Use %w: the typed error is
what the HTTP layer keys the 503 off.

Assisted-by: Claude Opus 5 [claude-code]

* feat(api): add GET /api/models/{id}/load-status

A client that receives 503 while a model stages onto a worker needs
somewhere to poll. This returns the same `loading` object the 503 carries
— phase, node, byte progress and ETA — or 404 when no load is running.

Read-only and observability-shaped, so it is deliberately neither
admin-gated nor feature-gated: it explains a 503 the caller just
received, and hiding that behind a per-modality feature would make the
explanation for a failed image request depend on chat permissions. It
also gets no MCP tool, since there is nothing here an admin would manage
conversationally.

Registered on the surfaces from .agents/api-endpoints-and-auth.md: the
swagger block (existing `models` tag, so /api/instructions needs no new
area), the endpoint discovery maps in RegisterLocalAIRoutes, regenerated
swagger, and the distributed-mode docs page. No FLAG_* usecase is
involved, so capabilities.js is unchanged.

Assisted-by: Claude Opus 5 [claude-code]

* feat(ui): show cold-load progress in Chat and retry when the model is ready

A chat request for a model that is still staging onto a worker now gets a
503 carrying live progress instead of an error. Render it: the composer
shows the phase (installing / staging / loading), the node, the percent
and the ETA, then polls load-status and re-sends the request the moment
the model is ready.

Reuses the staging progress idiom the page already had rather than
inventing a second one — the two sources are folded into one
loadProgress, with the load job winning because it is authoritative
across frontend replicas and knows the phase, where the staging operation
only knows about a byte transfer this replica happens to be performing.

Waiting is bounded (three send attempts, ~30 min of polling each), so a
load that never finishes still surfaces as an error rather than as a
spinner nobody questions. An aborted generation stops the polling too.

Assisted-by: Claude Opus 5 [claude-code]

* fix(distributed): check warm-path cleanup errors

The router moved legacy cleanup calls onto newly linted lines. Report
cleanup failures while preserving the fallback to a cold load.

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

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-15 13:20:11 +02:00
localai-org-maint-botandlocalai-org-maint-bot 08563942b5 feat(gallery): add LFM2.5 230M variants (#11526)
Add LiquidAI’s compact edge model in Q4_K_M and Q8_0 builds. The
variant pair lets LocalAI choose the highest-quality build that fits.

Assisted-by: Codex:gpt-5.4

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-15 13:17:52 +02:00
localai-org-maint-botandlocalai-org-maint-bot 342c3d0e17 fix(model): report backend crash diagnostics (#11532)
Unexpected runtime exits only reported an exit code, which hid the backend diagnostic. Include the final non-empty stderr line when one exists.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-15 13:17:24 +02:00
localai-org-maint-botandlocalai-org-maint-bot ce8a04fe46 fix(stablediffusion): embed Metal library (#11531)
The Darwin workflow passes BUILD_TYPE=metal but does not define OS=Darwin. The backend therefore omitted its Metal CMake flags and shipped the runtime source path instead of the embedded library.

Map the requested build type directly to the Metal flags and guard the build contract with a dry-run regression test.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-15 13:15:55 +02:00
dependabot[bot] 9f17f5e76c chore(deps): bump sentence-transformers from 5.6.1 to 5.7.0 in /backend/python/transformers (#11499)
chore(deps): bump sentence-transformers in /backend/python/transformers

Bumps [sentence-transformers](https://github.com/huggingface/sentence-transformers) from 5.6.1 to 5.7.0.
- [Release notes](https://github.com/huggingface/sentence-transformers/releases)
- [Commits](https://github.com/huggingface/sentence-transformers/compare/v5.6.1...v5.7.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 13:14:13 +02:00
localai-org-maint-botandlocalai-org-maint-bot c82eec3bbd feat(gallery): add DeepSeek V4 Pro 0813 (#11533)
Add the UD-Q4_K_XL GGUF build as a 20-shard llama.cpp entry for the latest DeepSeek V4 Pro release.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-15 13:12:18 +02:00
mudler's LocalAI [bot]andmudler 342f9bc9d2 chore: ⬆️ Update ggml-org/whisper.cpp to 1fe009caeda75f69bc864d6370b10674e45a92bd (#11524)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-15 13:11:59 +02:00
mudler's LocalAI [bot]andmudler ac468f743c chore: ⬆️ Update CrispStrobe/CrispASR to cc498701f1a68d88dd489803ebad10053a924322 (#11523)
⬆️ 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-08-15 12:41:56 +02:00
mudler's LocalAI [bot]andmudler ee3604b294 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260814013332 (#11522)
⬆️ 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-08-15 12:41:10 +02:00
mudler's LocalAI [bot]andmudler f4a8dd73fd chore(model-gallery): ⬆️ update checksum (#11525)
⬆️ 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-08-15 12:40:37 +02:00
mudler's LocalAI [bot]andmudler 81d8507a26 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 43afea46c25a12aae6db1e3105643267164898b4 (#11527)
⬆️ 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-08-15 12:40:10 +02:00
localai-org-maint-botandlocalai-org-maint-bot 44413a9d06 feat(gallery): add Qwen3.8 27B variants (#11519)
Add the official Q4_K_M and Q8_0 GGUF files with the shared vision projector. Include an MTP variant for speculative decoding.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-14 19:02:32 +02:00
Ettore Di Giacinto 3a3c31114b Remove Star history from README
Removed the Star history section from the README.

Signed-off-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-08-14 15:07:40 +02:00
localai-org-maint-botandlocalai-org-maint-bot 95653f221e fix(ui): keep agent import action visible (#11488)
* fix(ui): keep agent import action visible

The header hid its full import label after the agent list became non-empty. Hide only the nested file input so users can import more agents.

Assisted-by: Codex:gpt-5

* test(ui): match the agent import label

The Agents page renders the action as Import.

The test searched for Import Agent, so it failed before checking visibility.

Mock the observables request to remove backend timing from the fixture.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-14 15:06:51 +02:00
mudler's LocalAI [bot]andmudler 58a37fa2c9 chore: ⬆️ Update CrispStrobe/CrispASR to cb082743c456ac77aec0947de36e6420a933da04 (#11510)
⬆️ 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-08-14 15:05:08 +02:00
mudler's LocalAI [bot]andmudler 1dd6af0977 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 981e5ea0d7579b4803c86afbb09a7cd7d7bf3bb8 (#11509)
⬆️ 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-08-14 15:04:53 +02:00
tom-mi 9e8a4b5f34 fix (gallery): Parse harmony output of gpt-oss-* models correctly (#8037) (#11518)
fix: Parse harmony output of gpt-oss-* models correctly (#8037)

* Delegate templating to llama.cpp's jinja runtime

Assisted-by: opencode:GLM-5.2

Signed-off-by: Thomas Reifenberger <tom-mi@users.noreply.github.com>
2026-08-14 15:04:12 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 0c9d4bf9cc fix(vllm-cpp): build every CUDA architecture the platform can host (#11512)
The vllm-cpp CUDA images were built for Blackwell only: 120a;121a on
amd64 and 121a alone on arm64. vllm.cpp's own release archive builds ten
architectures, so LocalAI shipped one or two of them.

The failure mode is the problem. An unlisted card is not slower, it dies
at the first request with "no kernel image is available for execution on
the device", long after `backends install` reported success. That covers
A100, A10/3090, L4/4090/RTX 6000 Ada, H100/H200, B200, B300, Jetson Orin
and Jetson Thor, and it is how a Jetson Thor node was found serving
nothing at all.

amd64 now builds 80;86;89;90a;100a;103a;120a;121a and arm64 builds
87;90a;100a;110;121a, split by where the silicon exists: Jetson is
arm64-only, desktop 120a is amd64-only, and 90a/100a are on both because
of GH200/GB200.

Triton-AOT stays ON for both, which the old comment said was impossible.
It is not, at the version we pin: only maintainer REGEN needs a single
arch, while the BUILDER path embeds every vendored cubin tree and selects
by exact SM, so 87/103a/110/120a take the portable CUDA kernels and can
never load a neighbouring cubin. Upstream ships its ten-SM archive that
way.

The CUDA 13 guard now covers both branches rather than amd64 alone. arm64
needs compute_121a just as much, and CI already builds it with 13.

Cost is smaller than the arch count suggests, because gencode is
per-source: fp4-mma still resolves to 120a;121a, and the CUTLASS
scaled-mm kernels to one arch each, so the added architectures do not
multiply the expensive translation units.

Verified: flag generation checked for both branches, CUDA 12 still
refused, CPU build untouched; both arch lists expanded through vllm.cpp's
own vt_cuda_gencode_options and per-feature arch gating, and all six
vendored Triton trees confirmed intact, at the exact pinned commit. A
real compile is CI-only: there is no CUDA toolchain on the dev box.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-14 08:53:27 +02:00
mudler's LocalAI [bot]andmudler f86df43415 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260813121949 (#11507)
⬆️ 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-08-14 08:42:23 +02:00
dependabot[bot] 90b3585080 chore(deps): update transformers requirement from >=5.14.1 to >=5.15.0 in /backend/python/transformers (#11500)
chore(deps): update transformers requirement

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 22:28:05 +02:00
071952a964 chore: ⬆️ Update ggml-org/llama.cpp to 84e908c625fb60992b4cdef8180fb12fa9b4c4bf (#11473)
* ⬆️ Update ggml-org/llama.cpp

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

* fix(llama-cpp): refresh the TTS patch

The llama.cpp update moved and changed the generated-audio pipeline. Refresh the carried patch so backend builds can apply it to the new revision.

Assisted-by: Codex:gpt-5.4

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-13 22:27:45 +02:00
Richard Palethorpe 5c63969760 fix: Show MCP connection errors in the UI (#11495)
* fix(mcp): surface configured server failures

Keep model-configured MCP servers visible when discovery or connection setup fails, propagate status through distributed discovery, and let the Chat UI show actionable errors while retrying unavailable servers.

Add model-editor metadata for remote and stdio configuration and document the expected format, deployment networking boundary, and alternate MCP scopes.

Assisted-by: Codex:gpt-5 Ordino golangci-lint
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* build(compose): match CUDA development image

Configure the API image with the cublas, CUDA 13, auth-tagged build settings used by the local development Makefile invocation, including the 24-way Docker build.

Assisted-by: Codex:gpt-5 Ordino
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* revert: keep host build settings out of compose

The CUDA development deployment is managed from ~/docker/localai, not the repository example Compose file. Restore the generic example and keep machine-specific build settings in the host deployment.

Assisted-by: Codex:gpt-5 Ordino
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(docker): exclude local agent artifacts

Keep Claude worktrees and locally installed verification tools out of the Docker build context. These host-only directories added roughly 1.9 GB to every root image build.

Assisted-by: Codex:gpt-5 Ordino
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-08-13 22:25:58 +02:00
dependabot[bot] b2ff2b5477 chore(deps): bump packaging from 26.2 to 26.3 in /backend/python/coqui (#11497)
Bumps [packaging](https://github.com/pypa/packaging) from 26.2 to 26.3.
- [Release notes](https://github.com/pypa/packaging/releases)
- [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pypa/packaging/compare/26.2...26.3)

---
updated-dependencies:
- dependency-name: packaging
  dependency-version: '26.3'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 22:25:41 +02:00
dependabot[bot] 8c69a5f77a chore(deps): bump backend/rust/kokoros/sources/Kokoros from 7089168 to 29e99ad (#11496)
chore(deps): bump backend/rust/kokoros/sources/Kokoros

Bumps [backend/rust/kokoros/sources/Kokoros](https://github.com/lucasjinreal/Kokoros) from `7089168` to `29e99ad`.
- [Commits](https://github.com/lucasjinreal/Kokoros/compare/7089168f0ca2d8e1fcd8e523c9d75d915c6afdff...29e99ad5a5aa64b97e1e8e963e6d73b0267d796a)

---
updated-dependencies:
- dependency-name: backend/rust/kokoros/sources/Kokoros
  dependency-version: 29e99ad5a5aa64b97e1e8e963e6d73b0267d796a
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 22:25:22 +02:00
Dedy F. Setyawan 9fd7ea7e93 i18n(id): translate admin, media, and nav UI strings to Indonesian (#11493)
Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
2026-08-13 16:27:45 +02:00
localai-org-maint-botandlocalai-org-maint-bot 8b01ac2d4e feat(gallery): add LFM2.5 VL 1.6B variants (#11490)
Add the official Q4_K_M and Q8_0 GGUF builds with the F16 vision projector.

Assisted-by: Codex:gpt-5.4 [web]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-13 10:47:56 +02:00
fieryWaters 1ef10721c8 fix(parakeet-cpp): enable Metal in macOS builds (#11492)
Forward BUILD_TYPE=metal to PARAKEET_GGML_METAL so macOS backend artifacts include Metal support.

Assisted-by: Codex:gpt-5.6-sol

Signed-off-by: fierywaters <fierywaters13@gmail.com>
2026-08-13 10:47:27 +02:00
mudler's LocalAI [bot]andmudler 9ab62cbb38 chore: ⬆️ Update leejet/stable-diffusion.cpp to de298c225bed97c3f9026b73cd7b71e7879bd41b (#11469)
⬆️ 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-08-13 08:25:21 +02:00
mudler's LocalAI [bot]andmudler e9b94e54a3 chore: ⬆️ Update CrispStrobe/CrispASR to ce521ee178867ceaa5fdc11803616578c8936c19 (#11470)
⬆️ 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-08-13 08:25:05 +02:00
mudler's LocalAI [bot]andmudler 6fc46e83e1 chore: ⬆️ Update vllm-project/vllm cu130 wheel to 0.27.1 (#11468)
⬆️ Update vllm-project/vllm cu130 wheel

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-13 08:24:45 +02:00
mudler's LocalAI [bot]andmudler 1fa9f2969d chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260812005333 (#11467)
⬆️ 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-08-13 08:24:32 +02:00
mudler's LocalAI [bot]andmudler 2b1f6d27e9 chore: ⬆️ Update ikawrakow/ik_llama.cpp to c46ffaa5665cfb2d6cf372c9a054dbab896e14fe (#11482)
⬆️ 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-08-13 00:47:11 +02:00
mudler's LocalAI [bot]andmudler 3aa097af44 chore: ⬆️ Update mudler/vllm.cpp to 9fd9e8f34408d5dd21d7f9385e96fc755708950b (#11472)
⬆️ 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-08-13 00:46:58 +02:00
localai-org-maint-botandlocalai-org-maint-bot c52808c38b feat(gallery): add Fara1.5 4B variants (#11479)
Add the smaller Fara1.5 computer-use model alongside the existing 9B and 27B gallery entries. Offer Q4_K_M and Q8_0 builds so LocalAI can select for memory or quality.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-12 22:43:26 +02:00
localai-org-maint-botandlocalai-org-maint-bot 0647939953 fix(gallery): repair DeepSeek V4 fallback (#11480)
The DeepSeek V4 Flash base entry points at a Hugging Face repository page instead of a GGUF object. When variant probing cannot rank a concrete build, the base fallback downloads no usable model weights.

Use the validated IQ2XXS object and checksum already shipped by the q2 variant. Pin that payload in the gallery resolution test.

Assisted-by: Codex:gpt-5.6

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-12 22:42:55 +02:00
mudler's LocalAI [bot]andmudler b295c936fb chore: ⬆️ Update mudler/depth-anything.cpp to 54abd5c0abfd1f394e01cb3c38f2e3af4daedf85 (#11481)
⬆️ Update mudler/depth-anything.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-12 22:42:26 +02:00
localai-org-maint-botandlocalai-org-maint-bot d54067d4c6 feat(gallery): add TwIL-LM3 variants (#11478)
Add the publisher-recommended Q4_K_M build and a near-lossless Q8_0 variant for the 3B formal-logic reasoning model.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-12 18:54:15 +02:00
localai-org-maint-botandlocalai-org-maint-bot d50a1b9c98 feat(gallery): add Nemotron 3.5 Lightning 30B (#11477)
Add the official llama.cpp GGUF builds for NVIDIA Nemotron 3.5 Lightning 30B-A3B, linking Q4_K_M, NVFP4, and Q8_0 as installable variants.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-12 15:53:12 +02:00
localai-org-maint-botandlocalai-org-maint-bot ecdb8e768a fix(gallery): align Higgs Audio checksum test (#11476)
The validated gallery checksum changed in #11459, but its dedicated regression assertion kept the previous value and now fails the master test suite on Linux and macOS.

Assisted-by: Codex:gpt-5.4

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-12 15:52:59 +02:00
mudler's LocalAI [bot]andmudler 4916e44c07 chore(model-gallery): ⬆️ update checksum (#11471)
⬆️ 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-08-12 09:12:26 +02:00
github-actions[bot]andmudler b7a0646587 chore: bump inference defaults from unsloth (#11270)
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-12 09:12:12 +02:00
3ec466b61b chore: ⬆️ Update ikawrakow/ik_llama.cpp to 26ceed9d4091a1696cf50e2ed87e5767d5811d81 (#11475)
* ⬆️ Update ikawrakow/ik_llama.cpp

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

* fix(gallery): align Higgs Audio checksum test

The validated gallery checksum changed in #11459, but its dedicated regression assertion kept the previous value and now fails the master test suite on Linux and macOS.

Assisted-by: Codex:gpt-5.6

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-12 08:46:16 +02:00
mudler's LocalAI [bot]andmudler f92e4e1303 chore: ⬆️ Update mudler/vllm.cpp to 24306364ab8beaed9197604a042a56aaccfde493 (#11385)
⬆️ 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-08-11 23:05:57 +02:00
Dimitris Karakasilis 3636fcbd38 fix(realtime): keep the ICE interface allow-list working with a fixed UDP port (#11466)
LOCALAI_WEBRTC_ICE_INTERFACES was silently ignored whenever
LOCALAI_WEBRTC_UDP_PORT was set. Every interface was gathered regardless
of the allow-list, so a browser was handed the docker0/veth addresses the
setting exists to suppress, and the connection established on a good pair
and then dropped when consent checks failed on the unreachable ones.

Two things combine to cause it. A mux built over a wildcard socket makes
pion derive its host candidates by enumerating interfaces itself, with a
nil filter and loopback included. Independently, the muxed gathering path
in pion/ice never consults SetInterfaceFilter, so setting it has no effect
there either.

Bind one socket per admitted interface address via NewMultiUDPMuxFromPort,
which takes the filter, instead of one wildcard socket. All the sockets
share the same port, so the firewall requirement is still a single rule.
Networks are pinned to UDP4 to match the socket family this replaces.

An allow-list that matches no address on the host now reports the
misconfiguration rather than binding nothing and leaving signaling to
succeed while no candidate is ever advertised.

Two tests: one asserts an unmatched allow-list is an error, and one gathers
against a real peer connection and asserts no address outside the allowed
interface appears (skipped on single-interface hosts).

Assisted-by: Claude:claude-opus-5 go vet gofmt

Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>
2026-08-11 18:37:49 +02:00
localai-org-maint-botandlocalai-org-maint-bot 22076774f0 fix(gallery): correct Higgs Audio v3 checksum (#11459)
The gallery checksum did not match the Q8_0 object currently published by audio-cpp. Use the SHA256 reported by both Hugging Face LFS metadata and the live x-linked-etag so model installation can verify the download.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 12:07:55 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 45cb3983ee fix(ui): unmerge the class strings that left buttons in browser chrome (#11462)
Eight header controls across seven pages had two or three elements' classes
collapsed into one string. The wrapper or the icon ended up wearing the
button classes, and the buttons themselves were left with no class at all,
so they rendered in the browser's own chrome. Reported on Agent Jobs; the
grep found the rest.

`fas` does not draw anything by itself: it sets
`font-family: "Font Awesome 6 Free"` and weight 900 on whatever carries it,
and the `fa-*` class supplies the glyph via ::before. So

    <button className="btn btn-primary fas fa-plus">

renders its own label "New Task" in the icon font, and

    <div className="hstack btn btn-primary btn-sm fas fa-edit btn-secondary fa-arrow-left">
      <button>Edit</button>
      <button>Back</button>
    </div>

styles the flex wrapper as a button that is both primary and secondary,
points two glyphs at one ::before, and leaves both real buttons bare.

Fixed, all of them keeping the correct `<i>` child they already had:

  - AgentJobs, AgentTaskDetails (x2), AgentCreate - icon classes off the
    button.
  - AgentTaskDetails, AgentJobDetails - wrapper back to plain `hstack`, and
    the two buttons inside each get the variants the wrapper had been
    holding. Back is secondary and leads, Edit/Cancel is the emphatic one
    and trails, matching every other detail header.
  - VoiceLibrary, VoiceProfileCreate - the title `<i>` had swallowed the
    action link's classes, so "Create voice" and "Back to library" were
    unstyled anchors. Back was also drawing a "+" because it had inherited
    fa-plus while its own fa-arrow-left sat up in the title.
  - P2P - a stray fa-circle-info on the title icon.

The ninth instance was ImportModel, where this class of bug was first
found. #11461 rewrote that file and landed first, so nothing is left to fix
there.

Guarded by e2e/class-hygiene.spec.js, which reads the source rather than
walking routes: several of these pages need agent or voice data before they
render a header, so a route walk would skip exactly the pages that had the
bug. It fails on an icon-font class outside an `<i>`/`<span>`, on two glyphs
or two button variants on one element, and on a layout wrapper that is also
a button. Font Awesome modifiers (fa-spin, fa-fw, sizes) are excluded, so
the `fa-spinner fa-spin` idiom stays legal.

e2e: 428 passed.


Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] [Playwright]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-11 12:07:42 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 7a7fb00730 feat(ui): rebuild the import form on the restyled design language (#11461)
The import page took the new palette in #11305 but kept its old layout, so
it stayed a 760px column with the primary action detached from the form it
submits. Two of the problems were outright bugs.

The Import button carried no className at all, so the page's single most
important control fell through to the user-agent button: system chrome,
wrong radius, no design-system focus ring. The YAML button carried
`fas fa-save fa-upload`, which sets Font Awesome as the button's own font
family (its label text inherits it) and points two glyph classes at one
::before.

On the layout: `page--narrow` is documented for "forms / single-record edit
views", and in Advanced mode this page held a URI field, a six-section
format guide, ten modality chips, nine preference fields, a key-value
repeater and a YAML editor at `calc(100vh - 400px)`. The width was the
symptom; one column was the disease.

  - `page--medium` with a work column and a format reference beside it.
    The reference answers the only question a first-time admin has and used
    to sit behind a chevron, closed by default. Below 1024px it becomes a
    disclosure rather than disappearing.
  - The source field is the hero: monospace, because it holds something you
    paste, and it carries its own Import button. That removes the hidden
    aria-hidden submit button that existed only because the real action sat
    outside the form.
  - Simple and Advanced are gone. They were ~80% the same surface, and the
    overlap cost a mode switch, a localStorage key and a three-button
    Keep/Discard/Cancel dialog whose only job was protecting state that
    switching modes would hide. One form with a collapsible options panel
    hides nothing, so none of it is needed. What genuinely differs is the
    kind of input, which is now the two tabs: a source, or YAML.
  - The size/VRAM estimate reports under the field that produced it instead
    of as a banner above the page header, and an import in flight gets the
    progress, phase and byte counts the poller already returned and the old
    status card threw away.
  - ModalityChips resolves its labels through the same `modality.*` keys as
    the dropdown it filters. It hardcoded English shorthand, so one modality
    carried two names on one screen ("Speech" on the chip, "Speech
    recognition" on the group it scrolled to) and seven locales had neither.
    Its inline styles and its pill radius move onto the design system.
  - Three inline styles go, including both conditional-padding hacks; the
    only one left is the progress bar's runtime width. Baseline 538 -> 535.

Docs updated in the same change: the WebUI section described a Simple and an
Advanced mode and told the reader to "Toggle to Advanced Mode".

e2e: 426 passed. The mode-switch suite is replaced by one covering the tabs
and the disclosure, and a new layout suite pins the width, the styled
primary action, the absence of an icon-font button, the reference column at
both widths, and the estimate's position.


Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] [Playwright]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-11 12:00:59 +02:00
localai-org-maint-botandlocalai-org-maint-bot f2cdc06781 fix(model): surface backend startup exits (#11447)
* fix(model): surface backend startup exits

Preserve the local backend process exit code and bounded stderr diagnostic when the process dies before its gRPC service becomes ready.

Fixes #9050

Assisted-by: Codex:gpt-5

* fix(model): satisfy startup diagnostic checks

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

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 09:54:29 +02:00
localai-org-maint-botandlocalai-org-maint-bot 16dd81ecd2 fix(ci): parse current vllm-metal version pins (#11457)
* fix(ci): parse current vllm-metal version pins

vllm-metal renamed its installer pin from vllm_v to VLLM_VERSION, breaking both the nightly bumper and the Darwin backend installer after a bump. Share a strict parser that accepts both formats and cover the transition with shell regressions.

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

* fix(ci): parse scoped vllm-metal pins

The pinned vllm-metal installer declares its version as a local shell variable. Accept that optional declaration while retaining strict validation of the assignment and semantic version.

Assisted-by: Codex:gpt-5.4

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 09:53:50 +02:00
localai-org-maint-botandlocalai-org-maint-bot f3ea275c05 feat(gallery): add XYZ Aquila mini variants (#11455)
Add Q4_K_M and Q6_K llama.cpp builds with the matching vision projector and verified Hugging Face artifact hashes.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 09:53:24 +02:00
localai-org-maint-botandlocalai-org-maint-bot b0d731288f feat(gallery): add BTL-4 Compact (#11449)
Add the text-only IQ2_XXS build with the model card's required Jinja, reasoning parser, KV cache, and sampling defaults.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 09:53:13 +02:00
localai-org-maint-botandlocalai-org-maint-bot 16193e1982 feat(gallery): add Higgs Audio v3 TTS (#11456)
Expose the existing audio.cpp Higgs support as an installable Q8 gallery model and document voice cloning and licensing constraints.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 09:52:38 +02:00
7cfccdc2bf chore: ⬆️ Update ggml-org/llama.cpp to 030ebb558a5820b444a8f836ed5cdd46c9b4bd7a (#11454)
* ⬆️ Update ggml-org/llama.cpp

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

* fix(llama-cpp): rebase server patches

Adapt score output limits and TTS backend sampling to the updated llama.cpp server APIs.

Assisted-by: Codex:gpt-5.4

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 09:52:24 +02:00
localai-org-maint-botandlocalai-org-maint-bot 129f834039 feat(gallery): add Muse Glimmer 30B variants (#11446)
Add the official dynamic and 17 GB K-quant GGUF builds with the shared multimodal projector and optional DFlash acceleration.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 09:51:58 +02:00
mudler's LocalAI [bot]andmudler 0df63de1ed chore(model-gallery): ⬆️ update checksum (#11453)
⬆️ 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-08-11 09:51:36 +02:00
mudler's LocalAI [bot]andmudler b08eaeeec0 chore: ⬆️ Update 0xShug0/audio.cpp to 9d6e7b39236e0151ad28a70fab0d538b84ce8718 (#11451)
⬆️ 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-08-11 09:51:22 +02:00
mudler's LocalAI [bot]andmudler c045a57e96 chore: ⬆️ Update CrispStrobe/CrispASR to 8b91f4c5490b88ae6193673c35644d91d8695821 (#11450)
⬆️ 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-08-11 09:48:07 +02:00
mudler's LocalAI [bot]andmudler 48118678ef chore: ⬆️ Update ikawrakow/ik_llama.cpp to 5763a901de3f525b65ed5574fd57623905685755 (#11452)
⬆️ 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-08-11 09:47:56 +02:00
localai-org-maint-botandlocalai-org-maint-bot fbe1d5036d feat(gallery): add Gemma 4 Scotoma 2 variants (#11458)
Add Q4_K_M and Q8_0 multimodal builds with the matching vision projector.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-11 09:47:40 +02:00
Richard Palethorpe 8ad22eeaef docs: remove broken stars counter (#11448)
Assisted-by: Codex:gpt-5

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-08-10 21:58:55 +02:00
localai-org-maint-botandlocalai-org-maint-bot f7db51bdf5 feat(realtime): add shared WebRTC UDP port (#11436)
* feat(realtime): add shared WebRTC UDP port

Allow realtime WebRTC peer connections to reuse one configurable UDP mux, and surface listener bind failures through signaling.

Assisted-by: Codex:gpt-5

* test(realtime): keep UDP mux alive during bind check

The returned SettingEngine owns the UDP listener. Retain it through the duplicate-bind assertion so macOS cannot finalize the listener early and make the exclusivity check spuriously pass.

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

* test(realtime): use IPv4 for UDP mux checks

Match the socket family used by the WebRTC UDP mux so macOS does not allocate an IPv6 probe that can coexist with the IPv4 listener.\n\nAssisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-10 17:57:58 +02:00
localai-org-maint-botandlocalai-org-maint-bot e1b1a2564a feat(gallery): add BigBang v1 variants (#11443)
Add official multimodal GGUF builds for the new BigBang-v1 scientific and agentic reasoning model, including Q4_K_M and Q6_K variants with MTP enabled.\n\nAssisted-by: Codex:gpt-5 [Hugging Face API]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-10 15:12:09 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto da6fc8f14a feat(gallery): add minimax-h3-ref2va-q4 - the Ref2VA partition, verified coherent (#11439)
MiniMax-H3 ships two independently-served DiT partitions and each refuses the
other's tasks. The gallery had only the FL2VA one (t2va + fl2va); this adds the
Ref2VA checkpoint, which is what serves REFERENCE conditioning - a reference
image, a reference clip, or reference audio prepended as their own blocks.

It is a genuinely separate 19.9 GB download, not a mode flag, which is why it
needs its own entry. Everything except the DiT is shared with the FL2VA entry.

Q4_K_M specifically, and the description says why: the NVFP4 Ref2VA weights
render a multicolour patch grid. Three upstream investigations attributed that to
the ref2va reference-row path before the checkpoint was varied; on Q4_K_M the
identical code renders coherently (period-16 seam 1.13 against 2.28 broken,
VAE-input latent adjacent-cell cosine 0.8526 against 0.06 white). Pointing users
at NVFP4 here would hand them that dead end.

known_input_modalities carries image, video AND audio, unlike the FL2VA entry:
the ABI's ref2va slots are ref_image (PPM), ref_video (a directory of
frame_%06d.ppm) and ref_audio (16-bit PCM WAV), and the backend's
checkPartitionConditioning already routes all three.

Checksum verified two ways: sha256 computed over the downloaded 19,864,208,064
byte file, and HuggingFace's x-linked-etag for the same URI - both
17925612821ea3037ffaf5f7f9789f5460e87025385bd45e9ec6c7d536684d56.

Not verified here: an end-to-end render through LocalAI itself. The render was
proven with vllm.cpp's own driver on this exact checkpoint; the backend path is
the same library seam but has not been exercised with these weights.

Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-10 10:33:58 +02:00
dependabot[bot] 6cf2e97868 chore(deps): bump dompurify from 3.4.12 to 3.4.13 in /core/http/react-ui in the npm_and_yarn group across 1 directory (#11425)
chore(deps): bump dompurify

Bumps the npm_and_yarn group with 1 update in the /core/http/react-ui directory: [dompurify](https://github.com/cure53/DOMPurify).


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

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 10:21:41 +02:00
localai-org-maint-botandlocalai-org-maint-bot 32bf33f7d7 feat(gallery): add Genesis Hermes V7 variants (#11435)
Add the recommended APEX, compact, MTP-enabled, and Q8_K_P builds of Qwen3.6 35B A3B Genesis Hermes V7 with its shared multimodal projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-10 10:21:25 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 7b9167eaad feat(llama-cpp): serve Qwen3-TTS through the llama.cpp backend (#11392)
* fix(config): do not read a TTS speaker-encoder mmproj as vision support

Qwen3-TTS on llama-cpp ships an mmproj holding the speaker encoder and
code predictor. VisionSupported() treated any non-empty MMProj as proof
of image input, so every such model would be advertised as vision-capable.

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

* feat(llama-cpp): add TTS request option parsing helper

Validates text and speaker reference presence and strictly parses the
top_k / top_p per-request params, in a header with no llama.cpp or gRPC
dependencies so the standalone C++ unit test gate picks it up.

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

* fix(llama-cpp): range-check the TTS top_k and top_p request params

Format validation alone let NaN, infinity and out-of-range values through.
The consumer copies both values into the audio generation input
unconditionally and only guards its separate sampler assignment with
"> 0", a test NaN also fails, so a NaN reached llama.cpp with the guard
never firing. top_k must now be >= 0 and top_p must fall within 0.0 to 1.0
inclusive, with the bound written as a negated in-range test so NaN is
rejected rather than silently accepted.

Also cover the two checks the suite could not previously kill: the
whole-string check in the float parser and the int32 range check.

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

* chore(llama-cpp): bump pin to f9e832c10 and carry the TTS server task

Picks up ggml-org/llama.cpp#26254 (Qwen3-TTS via mtmd) and #26536 (the
short-input audio chunk fix). Adds 0002-add-server-task-type-tts.patch,
the server-side half of the still-draft #26603, so TTS runs through the
slot scheduler instead of racing it. Remove that patch when #26603 merges.

The patch is rebased on top of the score patch: its tokenize-switch hunk
collided with the SERVER_TASK_TYPE_SCORE case, and its lone SRV_WRN call
passes no variadic argument, which the macro cannot expand. The score
patch itself needed no refresh.

Also fixes fallout from the bump in grpc-server.cpp: upstream dropped the
per-slot n_ctx argument from server_schema::eval_llama_cmpl_schema. Only
the schema branch loses it, since forks predating the server-schema split
still expect the old argument list.

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

* feat(llama-cpp): implement the TTS and TTSStream RPCs

Both were declared in backend.proto but unimplemented. They now submit a
SERVER_TASK_TYPE_TTS task and drain the response reader, the same shape
PredictStream uses.

The streaming path emits a leading sample_rate message and then raw PCM,
because ModelTTSStream builds the WAV header itself; the non-streaming
path emits a complete WAV to the requested dst.

The streamed samples are converted from the pipeline's float32 to signed
16-bit first. MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM hands back floats, while
the header ModelTTSStream writes announces 16-bit samples, so shipping
the floats verbatim would decode as noise.

prepare.sh and CMakeLists.txt now stage tts_request_options.h alongside
the other grpc-server helpers, and register its standalone test with
ctest the way passthrough_options_test is registered.

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

* fix(llama-cpp): mask non-codec tokens for Qwen3-TTS generation

The Qwen3-TTS gen-audio pipeline maps a sampled backbone token to a
codebook row with an unchecked subtraction, in mtmd-helper-gen.cpp:

    inp.code0 = sampled - codec_0;

For ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF the vocab is 155008 tokens,
<|codec_0|> is 151936 and the codec codes end at 153983. The model's own
tokenizer.ggml.suppress_tokens holds 1023 ids covering 153984..155007,
every special above the codec range except <|codec_eos_token|> (154086)
which stays reachable as the stop token. Nothing masks the text range
0..151935, so the backbone can sample a text token at any step, the
subtraction goes negative, and ggml_compute_forward_get_rows aborts the
whole backend process on GGML_ASSERT(i01 >= 0 && i01 < ne01).

Complete the mask upstream started: bias every token below <|codec_0|>
to -INFINITY for TTS tasks so only codec codes and the codec EOS remain
reachable. The biases are appended to task.params.sampling.logit_bias,
which common_sampler_init already merges with the model's suppress
tokens into one llama_sampler_init_logit_bias, so no sampler is added to
the chain. Measured cost is 0.082 ms per sampled token and 1.16 MB, set
against a forward pass in the multi-millisecond range.

It lands in launch_slot_with_task rather than in a route handler so that
llama.cpp's own POST /tts and LocalAI's TTS/TTSStream RPCs are both
covered, and <|codec_0|> is resolved from the vocab rather than
hardcoded so a model without it is left alone.

This is reproducible with upstream's own llama-tts and no LocalAI code
loaded, aborting at frame 55 on Q4_K_M and frame 71 on Q8_0, so it is
neither a quantization artifact nor an artifact of the gRPC adapter.
Two further defects in the same draft pipeline still prevent end-to-end
audio; they are independent of this one and are recorded in the task
report for an upstream bug report.

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

* chore(llama-cpp): bump pin to 9de0fcf2b and drop the TTS codec mask

Upstream fixed the Qwen3-TTS abort in ggml-org/llama.cpp c8e03ce81
("mtmd/ggml: add ggml_build_forward_order", #26649), landed one hour
after the previous pin. ggml_build_forward_expand marks a tensor and all
its ancestors for compute, so using it as a pure ordering hint defeated
ggml_build_forward_select and made GEN_WAV calls execute the GEN_CODE
branch against a stale inp_code0, hitting the get_rows bound assert in
ggml_compute_forward_get_rows.

That single defect accounts for every abort seen on this model, so
0003-mask-non-codec-tokens-for-tts.patch is removed rather than rebased.
The mask changed the observed behavior, but it was perturbing a graph
ordering bug rather than fixing a sampling one: at the new pin the whole
path works without it. Keeping it would have meant carrying a 152k-entry
logit bias, and rebasing it on every pin bump, for no benefit.

Verified at 9de0fcf2b with only 0001 and 0002 applied, which both apply
clean with no fuzz and needed no rebase:

  non-streaming  HTTP 200, 410924 bytes, 8.56 s
                 RIFF (little-endian) data, WAVE audio, Microsoft PCM,
                 16 bit, mono 24000 Hz
  streaming      HTTP 200, 560684 bytes, 11.68 s, exactly one RIFF at
                 byte 0, same format, which also exercises the
                 float32-to-s16 conversion at runtime for the first time

Pristine unpatched llama-tts at the same pin now also completes, 130
frames to a valid WAV, where it aborted at frame 55 before.

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

* fix(llama-cpp): clear the TTS slot sequence between requests

Only the first TTS request in a backend process succeeded. Every later
one failed instantly, in about 0.13 s, with "TTS prompt processing
failed" from step_prompt, regardless of streaming or non-streaming and
regardless of the text. With LOCALAI_SINGLE_ACTIVE_BACKEND=true the
process is kept alive between requests, so a deployment would have
served exactly one utterance per backend start.

The cause is missing KV hygiene, not anything in the gRPC adapter. TTS
slots never enter the shared batch: pre_decode() returns early for them
and process_tts_slots() drives them instead, so they skip the
prompt-cache bookkeeping that clears a slot's sequence between requests.
Nothing in the gen-audio path makes up for it: mtmd_helper_gen_audio_reset
only clears host-side buffers, and the pipeline always decodes from
position 0 into the sequence identified by slot.id. So the second task
on a slot writes positions 0..N over the first task's tokens and
llama_decode fails.

Fix is one call to slot.prompt_clear(), the same helper the normal path
uses, in the SERVER_TASK_TYPE_TTS branch of launch_slot_with_task before
set_input. It goes into 0002 rather than a new patch file because it is
a defect in the code that patch introduces, and the header now records
it as ours so we know whether it still needs carrying if #26603 merges
without it.

Verified in one backend process, different text on every request:
three consecutive non-streaming requests, three consecutive streaming
requests, and an interleaved non-streaming, streaming, non-streaming,
streaming run. All ten returned HTTP 200 with
RIFF ... WAVE audio, Microsoft PCM, 16 bit, mono 24000 Hz, the streamed
ones carrying exactly one RIFF header at byte 0, and every output
measured as real speech rather than silence or a truncated fragment.

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

* feat(llama-cpp): expose max_frames for TTS requests

The Qwen3-TTS backbone does not always emit <|codec_eos_token|>, and
when it does not, generation runs to upstream's 512-frame n_predict
default. At the model's 12.5 Hz frame rate that is 40.96 s of audio,
which a short input can trigger: one request in this session produced
40.96 s for a ten-word sentence. prepareTTSTask hardcoded n_predict to
-1, so callers had no way to bound it.

Add a max_frames key alongside top_k and top_p, parsed with the same
strict whole-string parsing so a typo is an error rather than a silently
truncated value, and rejected with a field-naming message when negative.
0 keeps the existing sentinel convention and means unset, so a request
that omits it behaves exactly as before.

Named max_frames rather than n_predict because frames are what the
parameter means at a TTS endpoint: one frame is 0.08 s of audio.

The 512-frame default is deliberately unchanged. Lowering it would
truncate legitimately long inputs, which is a worse failure than an
occasionally overlong one.

Verified end to end on one text of thirty words:

  max_frames=25    HTTP 200,  96044 bytes,  2.00 s, exactly 25 frames
  max_frames=50    HTTP 200, 192044 bytes,  4.00 s, exactly 50 frames
  no max_frames    HTTP 200, 572204 bytes, 11.92 s, stopped at its own
                   codec EOS after 149 frames, unchanged behavior

  max_frames=-1    InvalidArgument "max_frames must be >= 0, got \"-1\""
  max_frames=many  InvalidArgument "max_frames must be an integer, got \"many\""

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

* fix(llama-cpp): send the TTS sample rate up front, and tidy three review items

Four items from the Task 4 review.

Streaming first-byte latency. TTSStream sent the sample-rate reply only
once the first audio result arrived, and a chunk needs a whole 72-frame
window, roughly 5.8 s of audio and far longer in wall time on CPU. The
Go side blocks on that reply before it can emit the WAV header, so a
streaming client sat at zero bytes for the whole stretch. The rate is a
property of the loaded model and is available synchronously from
mtmd_gen_audio_get_info, so it now goes out immediately after post_task
and the rate_sent bookkeeping is gone. Measured on a warm model, first
byte drops from 30.48 s to 0.014 s, and the output is still a valid WAV
with exactly one RIFF header at byte 0.

Unchecked close. The non-streaming path ignored ofstream::close(), so a
failure that only surfaces on flush was reported as success while
leaving a truncated file at dst. It now returns INTERNAL like the other
write failures.

Wrong comment on set_lang. gen_audio::inp::get() already maps a stored
blank to nullptr, so our guard is behavior-preserving, not
behavior-fixing. The comment claimed otherwise; the code was right.

Repetition penalty. penalty_last_n = -1 is inert at this pin, because
llama_sampler_init_penalties clamps it with std::max(penalty_last_n, 0)
and then builds a disabled sampler, so the 1.05 penalty never applies.
Upstream's README attributes looping to a missing repeat_penalty, so it
was worth testing as a root-cause fix for the model running to the frame
cap. Dropping the line lets the sampling default of 64 apply, which was
confirmed in the sampler chain trace as penalty_last_n = 64 with
repeat_penalty = 1.050. Over 15 uncapped short requests each way it did
not help: 0 of 15 ran to the cap with the penalty inert, 1 of 15 with it
active. Both lines are therefore kept for parity with upstream's draft,
and a comment now records that the pair is inert and why, so the next
reader does not believe a penalty is applied. max_frames remains the way
to bound output.

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

* build(llama-cpp): let unpatched forks opt out of the TTS task

turboquant and bonsai copy grpc-server.cpp into llama.cpp forks that do
not carry our patches. disable-tts-task.sh injects the same kind of
preprocessor switch disable-score-task.sh already uses, so those builds
answer UNIMPLEMENTED rather than failing to compile.

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

* fix(config): keep a TTS speaker-encoder projector out of vision detection

Task 1 exempted a declared-TTS model's mmproj from VisionSupported, but the
first real gallery entry with an mmproj still came back vision-capable through
two paths the earlier fix did not close.

GuessUsecases has no FLAG_VISION branch, so it falls through to true for any
chat-ish model. That is not just a wrong answer at the call site:
syncKnownUsecasesFromString rewrites KnownUsecaseStrings from HasUsecases, and
the loader calls it more than once per config file, so the guessed FLAG_VISION
is written out and parsed back into KnownUsecases as if the operator had
declared it. Give GuessUsecases a FLAG_VISION branch that defers to the same
explicit signals VisionSupported uses.

Second, llama.cpp builds an mtmd context for the speaker-encoder projector and
reports its media marker on the first chat probe, which resurrected vision
after the model had been used once. Apply the same declared-TTS exemption to
MediaMarker that the mmproj check already had.

Verified against the qwen3-tts-llamacpp-q4 gallery entry: no vision capability
and no image input modality, before load, after a TTS request, and after a chat
probe.

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

* feat(gallery): add Qwen3-TTS entries for the llama-cpp backend

Two entries over upstream's own GGUF conversion, Q8_0 and Q4_K_M, each
pairing a backbone with the Q8_0 projector. Named to sit alongside the
existing qwen3-tts-cpp entries rather than replace them.

Also tags the llama-cpp backend text-to-speech / TTS so the backend browser
surfaces the capability.

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

* docs: cover Qwen3-TTS on the llama-cpp backend

Adds the gallery variants, the two-file mmproj configuration, the
required voice reference, and the language and sampling knobs. Also
corrects the streaming-support list, which named only voxcpm.

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

* fix(config): register llama-cpp as a TTS and voice-cloning backend

The branch taught the llama-cpp backend to serve Qwen3-TTS and shipped two
gallery entries for it, but never told the capability table. llama-cpp still
declared only the text RPCs and usecases, so:

- VoiceCloningForModel returned nil at the capability check, before it ever
  reached the model's own tts.voice_cloning override, and /tts answered 400
  "selected model does not support reference-audio voice cloning" for any
  localai://voice-profiles/... voice. No model YAML could opt back in.
- GET /api/backends/usecases did not list tts for llama-cpp, so the gallery
  greyed out the TTS filter for the entries this branch adds.
- The React TTS page saw voice_cloning: null and kept both models out of the
  Voice Library.

Add the TTS RPCs and usecase, and the reference-audio contract.

The contract needs narrowing, because the per-backend switch in
VoiceCloningForModel ends in a permissive default: an unnarrowed entry would
have advertised reference-audio cloning on every GGUF chat model in the
gallery. Narrow on the declared TTS usecase rather than the model name. The
TTS checkpoints are the only llama-cpp models carrying known_usecases: [tts];
name matching would have to guess at third-party repacks, and "base", the
substring the neighbouring Qwen and vLLM cases key on, is a routine word in
text-model names. The check reads the declared bit directly instead of going
through HasUsecases, which falls through to GuessUsecases and would hand the
decision to a heuristic that never had a llama.cpp TTS model in mind.

DefaultUsecases stays [chat]: a bare GGUF served by llama.cpp is a chat model,
and both the gallery filter and the importer read that field.

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

* fix(gallery): declare what nemotron-3-nano-omni actually accepts

The entry is backend: vllm-omni with known_usecases: [chat, completion], no
mmproj and no media marker, so it used to report vision only through the
blanket GuessUsecases fallthrough that the vision branch in this branch
removed. Nemotron 3 Nano Omni is a multimodal understanding model: image,
video and audio in, text out. Declaring that is what the sibling
vllm-omni-qwen3-omni-30b already does.

known_usecases gains vision only. FLAG_VIDEO is video GENERATION, an output
modality, and this model generates none; video and audio input belong in
known_input_modalities, which is where AudioInputSupported and
VideoInputSupported read them from.

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

* fix(importers): import a Qwen3-TTS GGUF repo as TTS, not chat

The llama-cpp importer hardcodes known_usecases: [chat] and assigns any
mmproj-matching file as a vision projector, so ggml-org/Qwen3-TTS-12Hz-1.7B-
Base-GGUF imported as a chat model with vision. Both fields were wrong, and
the model was unreachable from /tts and from the Voice Library.

Filenames cannot fix this. A Qwen3-TTS repo has the exact shape of a vision
repo, one backbone GGUF plus one mmproj-*.gguf, so the projector's own header
is the only honest signal: mtmd writes clip.has_gen_audio_encoder for the
projectors it can drive as a speech pipeline and refuses to build one without
it. Probe the selected mmproj for that flag, reusing the range-fetch the MTP
detection already does, and declare tts when it is set. The mmproj assignment
then stops reading as vision on its own, since a declared-TTS model already
exempts its projector from vision detection.

The probe is best-effort like the MTP one: a network blip leaves the chat
default in place rather than failing the import.

Verified against the real artifacts on disk: the Qwen3-TTS projector reports
gen-audio, its backbone does not.

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

* fix(llama-cpp): stop non-TTS models crashing on the new pin

Two regressions, both hit every ordinary llama-cpp model and neither was
caught locally because every test on this branch loaded a TTS model.

The first is a null dereference. server_slot::tts_ctx::reset() called
mtmd_helper_gen_audio_reset() unconditionally, but the gen-audio pipeline
is only allocated for models carrying a gen-audio mmproj, and upstream's
implementation reads ctx->pipeline before null-checking anything. Since
server_slot::reset() runs during slot initialization for every model, any
non-TTS model segfaulted the backend the moment it loaded. Guard the call
on the is_supported() predicate already defined beside it, and keep the
plain field resets unconditional.

The second is unrelated to TTS and came in with the pin bump.
PredictOptions.Penalty is a bare proto float, so a caller that names no
repetition penalty sends 0 rather than omitting the field. Since
9de0fcf2b, common_sampler_init() rejects a non-positive penalty_repeat
outright because it would divide logits by zero, turning every such
request into "Failed to initialize samplers". Treat 0 as unset and leave
llama.cpp's own neutral default in place.

Verified with the same suite CI runs, which is what caught both:
tests/e2e-backends passes 6 of 6 including the load and predict specs
that were red. Qwen3-TTS still synthesises on both paths, 24 kHz mono
16-bit WAV with exactly one RIFF header on the streamed output.

Assisted-by: Claude:claude-fable-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-08-10 10:18:47 +02:00
localai-org-maint-botandlocalai-org-maint-bot 7a22586399 feat(gallery): add Shieldstral 1.0 3B variants (#11441)
Add Q4_K_M and Q8_0 llama.cpp builds of Mistral AI’s multimodal safety classifier, including the shared Pixtral projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-10 10:17:44 +02:00
localai-org-maint-botandlocalai-org-maint-bot 7ef3c6f786 fix(vllm): align Intel basekit runtime (#11437)
* fix(vllm): align Intel basekit runtime

The latest vLLM XPU requirements install oneAPI 2026 runtime packages. The 2025.3.0 base image ships an older libsycl/UR loader pair and fails while importing torch with an undefined urDeviceWaitExp symbol. Use the current repository-wide 2025.3.2 Intel basekit patch level, which carries the compatible loader.

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

* fix(vllm): pin Intel source build to release

Build the Intel XPU backend from vLLM 0.26.0 instead of the moving main branch, and use the Triton XPU version required by that release's torch 2.12 dependency.

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

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-10 09:44:52 +02:00
localai-org-maint-botandlocalai-org-maint-bot 2b62f1815f feat(gallery): add Grug 12B variants (#11438)
Add Q4_K_M and Q8_0 llama.cpp builds of the compact-reasoning Gemma 4 fine-tune, including its multimodal projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-10 09:44:38 +02:00
mudler's LocalAI [bot]andmudler 68e2b127b1 chore: ⬆️ Update CrispStrobe/CrispASR to 8898b0fee199b1765321a5d0d8314aee51a34583 (#11422)
⬆️ 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-08-10 08:13:19 +02:00
mudler's LocalAI [bot]andmudler f129a76cc4 chore: ⬆️ Update 0xShug0/audio.cpp to 748c5e28f6a7228b8f38ad7142ca97d29584544b (#11401)
⬆️ 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-08-10 00:48:23 +02:00
localai-org-maint-botandlocalai-org-maint-bot daa8d2adbd fix(gallery): identify invalid preload JSON (#11434)
Wrap PRELOAD_MODELS decoding failures with the setting name and expected top-level shape so startup errors point directly to the invalid configuration. Document the required array format and cover scalar and empty-array inputs.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-10 00:18:01 +02:00
mudler's LocalAI [bot]andmudler e9cfc2d284 chore(model-gallery): ⬆️ update checksum (#11433)
⬆️ 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-08-09 23:26:55 +02:00
mudler's LocalAI [bot]andmudler 1f5dbe8ffc chore: ⬆️ Update ikawrakow/ik_llama.cpp to a7c81affa48c6800d63111bdb33469a01d062daa (#11431)
⬆️ 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-08-09 22:56:28 +02:00
mudler's LocalAI [bot]andmudler 1ffc68a153 chore: ⬆️ Update antirez/ds4 to 84cc882352757baf628a1776badf7cc54d584e28 (#11432)
⬆️ Update antirez/ds4

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-08-09 22:56:12 +02:00
localai-org-maint-botandlocalai-org-maint-bot 06ff56e674 feat(pii): restore request-scoped pseudonyms (#11272)
* feat(pii): restore request-scoped pseudonyms

Replace masked request values with unique per-request tokens when response restoration is enabled, then restore them across JSON and SSE write boundaries. Document the opt-in model setting and expose it in config metadata.\n\nAssisted-by: Codex:gpt-5

* fix(pii): wrap reversible redaction tokens

Use configurable token delimiters to avoid restoring ordinary model text that happens to match an internal identifier. Rename the option and document the confidentiality tradeoff.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-09 22:37:13 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto a0f50b2af2 feat(vllm-cpp): serve MiniMax-H3 video+audio generation (#11424)
* feat(vllm-cpp): serve MiniMax-H3 video+audio generation

vllm.cpp's C ABI grew a video slice (ABI v12): a second engine handle
loaded from the MiniMax-H3 checkpoint SET, one blocking generate, and a
composed ffmpeg argv the caller execs. This wires that into LocalAI's
existing /video endpoint, so `vllm-cpp` now serves both text and video
and a clip comes back as an MP4 with a real audio track rather than a
silent render.

The video engine is a separate handle rather than a mode of the text
one because H3 is not a model directory: the DiT, the text encoder and
two VAEs are separate artifacts, and vllm.cpp has the two loaders refuse
each other's checkpoints. `Load` takes the video branch when the config
declares any of the video options; `parameters.model` is the DiT and the
rest of the set is named in `options:`.

Three details are worth calling out because getting them wrong is
expensive:

- The partition is DECLARED, not detected. The community quantisations
  strip the release metadata and the FL2VA and Ref2VA DiTs are
  byte-structurally identical, so the engine refuses to generate until
  it is told which it has. Worse, a mismatch does not fail cleanly: a
  reference passed to an FL2VA DiT renders for hours and returns a
  coloured lattice over the frame. The backend refuses that combination
  up front instead.
- ffmpeg comes from the host. libvllm writes frames plus a WAV and
  composes the mux argv, then spawns nothing - that process boundary is
  upstream's decision. The backend execs it, the same arrangement
  vibevoice-cpp uses for transcoding, and ffmpeg also converts a
  start_image upload into the binary PPM at the exact output canvas the
  engine requires.
- It is slow. Roughly 176 s per denoise step at the default 1344x768
  canvas on a 20-SM device, so the 50-step default is a multi-hour job.
  Nothing on this path imposes a deadline.

The /video endpoint no longer forces 512x512 when the request omits the
geometry. Every video backend already supplies its own default for a
zero (512x512 for stablediffusion-ggml, 1280x720 for diffusers, 832x480
for longcat-video, 1344x768 for H3), so the hardcoded value only ever
overrode the model's trained canvas with one three of the four were
never trained at.

Moving the engine pin from ABI v10 to v16 also grows the text
vllm_model_params mirror by the v14 device field and the v16 KV-sizing
knobs. LocalAI sets none of them - 0 is the pre-v14 engine byte for byte
- but the struct SIZE is part of the layout contract, so leaving them
out would have vllm_engine_load read past the allocation.

Gallery: `minimax-h3-fl2va-q4` installs the Q4_K_M FL2VA set (~40 GB
across five weight files plus the two VAE configs that carry the latent
statistics).

Assisted-by: Claude:claude-opus-5 golangci-lint yamllint go-vet

* fix(vllm-cpp): unbreak the Darwin build at the new engine pin

src/capi/vllm_c.cpp opens one `extern "C" {` for the whole ABI surface,
so file-local helpers declared inside it inherit C linkage. The video
slice added one that returns std::string, which Apple Clang reports as
-Wreturn-type-c-linkage and vllm.cpp's target-local -Werror turns into a
build failure. GCC and upstream Clang do not diagnose it, so only the
metal-darwin-arm64 job saw it.

Suppress it the same way this Makefile already suppresses Apple Clang's
-Wgnu-folding-constant on the Metal build. The helper is never called
across the boundary so the warning describes no hazard here, but it is a
real upstream wart: the fix belongs in vllm.cpp, hoisting the helper
above the extern "C" block, and this flag should go when a pin carrying
that fix lands.

Assisted-by: Claude:claude-opus-5

* fix(vllm-cpp): patch the engine clone instead of the warning flag

The -Wno-return-type-c-linkage added in the previous commit does nothing.
vllm_cpp_set_warnings adds `-Wall -Wextra -Werror` as PRIVATE target
options, so they land after anything CMAKE_CXX_FLAGS contributes, and
-Wall re-enables the -Wreturn-type group that -Wreturn-type-c-linkage
belongs to. The darwin job failed again on the same line, which is the
evidence: a consumer cannot wave this off from outside the engine.

Position is the only fix, so carry it as a patch against the pinned SHA,
the way longcat-video patches its own upstream. It hoists the helper
above the `extern "C" {` that gives it C linkage; it is file-local and
never called across the boundary, so nothing else moves.

`git apply` is unguarded on purpose: a patch that stops applying must
fail the clone loudly, because the alternative is a pin that silently
ships without a fix it is documented to carry. The patch header names
what retires it - a pin carrying the fix upstream, where it belongs.

Verified by applying the patch with `git apply` to the exact blob at the
pinned SHA and diffing the result against the intended file.

Assisted-by: Claude:claude-opus-5

* chore(vllm-cpp): bump the engine pin to ABI v17 and drop the vendored OrEmpty patch

The OrEmpty linkage fix this backend carried as patches/0001-* landed upstream
(mudler/vllm.cpp#195, 7534da65), so the patch has done its job. It is deleted
rather than left in place: the Makefile applies patches/*.patch unguarded and
documents that "a patch that no longer applies must FAIL the clone", so keeping
it against fixed source would break the build the moment the pin moved. Bumping
the pin and deleting the patch therefore have to be the SAME change.

Pin f921062b -> 776c56f1 (current vllm.cpp main).

That range also carries the engine's ABI v17 (vllm_server_main: the OpenAI server
published on the public surface). registerLib compares the library's
vllm_abi_version against `abiVersion` for EXACT equality, so the constant moves
16 -> 17 in the same commit or every load fails with an ABI mismatch.

The bump is safe for the layout assertions in video_test.go: diffing include/vllm.h
across the two pins shows zero struct-field changes -- v17 adds one function
declaration, the version macro and a doc comment, nothing else -- so every
unsafe.Offsetof in the video params test still holds.

Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]

* chore(vllm-cpp): re-pin to pick up the VLLM_CPP_SERVER=OFF link fix

The previous pin carried vllm.cpp's ABI v17 (vllm_server_main) but not the guard
that makes it link when the server is compiled out. This backend builds libvllm
with VLLM_CPP_SERVER off, so the darwin lane failed at the dylib link with
vllm::entrypoints::openai::VllmServerMain undefined.

Fixed upstream in mudler/vllm.cpp#202: the C entry point is now guarded, so the
symbol is still exported (ABI v17 stays resolvable for dlopen) while the
no-server arm reports the missing capability instead of dragging in a translation
unit that was never compiled.

Verified upstream in BOTH arms before re-pinning: SERVER=ON builds and runs, and
SERVER=OFF configures, links, produces libvllm.so, and `nm -D` shows
vllm_server_main exported next to vllm_video_generate and vllm_transcribe.

Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:34:51 +02:00
Matheus C. França f31c3bbf1b feat(i18n): add pt-BR translation (#11427)
Adds a complete Brazilian Portuguese (pt-BR) translation for the
LocalAI WebUI across 14 namespaces with full key parity against the
English locale, including modelEditor.json. Registers pt-BR in
SUPPORTED_LANGUAGES with the code 'pt-BR', name 'Português (Brasil)'
and flag 'BR'. Brand/model/product names and technical identifiers are
kept untranslated, matching the existing locale conventions.

Assisted-by: opencode:deepseek-v4-flash-free python3

Signed-off-by: Matheus C. França <matheus-catarino@hotmail.com>
2026-08-09 22:34:19 +02:00
mudler's LocalAI [bot]andmudler 1f30ecc398 chore(model-gallery): ⬆️ update checksum (#11423)
⬆️ 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-08-08 23:04:23 +02:00
mudler's LocalAI [bot]andmudler 6c4acece2a chore: ⬆️ Update ikawrakow/ik_llama.cpp to f2328aa0c19954d0ab31a3de60fbf50e47c2429f (#11421)
⬆️ 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-08-08 23:04:08 +02:00
mudler's LocalAI [bot]andmudler ea9f4f5bc5 chore: ⬆️ Update CrispStrobe/CrispASR to 17a6cc99422bfafadf7161e96dd7294c89da9c36 (#11404)
⬆️ 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-08-08 08:23:40 +02:00
mudler's LocalAI [bot]andmudler 53637e5397 docs: ⬆️ update docs version mudler/LocalAI (#11415)
⬆️ 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-08-08 08:23:24 +02:00
Copilotandmudler c5645795ba fix(kokoros): add missing upscale_image stub to Backend trait impl (#11414)
* Initial plan

* fix(kokoros): add missing upscale_image stub to Backend impl

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 08:23:05 +02:00
mudler's LocalAI [bot]andmudler 7047ae7210 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 40dffce6857b4fe051f096379dc464764c718458 (#11403)
⬆️ 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-08-08 08:22:34 +02:00
mudler's LocalAI [bot]andmudler 40343ebeed chore(model-gallery): ⬆️ update checksum (#11418)
⬆️ 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-08-08 08:22:16 +02:00
mudler's LocalAI [bot]andmudler 18041b615e chore: ⬆️ Update ggml-org/whisper.cpp to 592feef04a1802b18cbeffd0fd0eb5d02570c2ec (#11416)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 08:21:59 +02:00
Adiraandlocalai-org-maint-bot ab52813342 feat(modelartifacts): support bounded parallel Hugging Face file downloads (#11162)
* feat(modelartifacts): support bounded parallel Hugging Face file downloads

Closes #11114.

Snapshot materialization fetched every file through the sequential
executor in DownloadFilesWithContext, so a repository split into many
shards spent most of its wall clock in per-file request latency rather
than moving bytes.

Add DownloadFilesWithConcurrency, an errgroup with SetLimit, and keep
DownloadFilesWithContext as a wrapper that passes a limit of 1. That
leaves the two non-artifact callers (core/gallery and the model config
loader) on exactly the path they had: tasks still run in slice order,
and the first failure still returns before any later task starts.

Only whole files run in parallel. A single file is never split, so the
.partial resume machinery and the per-file SHA check in
downloadTaskWithRetry are untouched.

Two details the parallel path forced:

- completedBytes becomes an atomic.Int64. Several AfterDownload hooks
  add to it while other files' progress callbacks read it; without this
  the race detector reports three races on the new specs.
- The caller's status callback is serialized. The sequential path gave
  it an implicit guarantee of never being entered twice at once, and it
  belongs to the caller, so the executor keeps that promise rather than
  pushing locking onto every caller. AfterDownload is deliberately not
  serialized -- it does the verify-and-promote work that parallelism
  exists to overlap.

Manifest order needed no work: each hook already writes its own
manifest.Files slot by snapshot index, so entries stay in snapshot
order whatever the completion order. A spec now pins that.

The default is 1, unchanged behaviour. A shared models volume is often
the bottleneck rather than the link, so raising it is a deployment
decision; --artifact-download-concurrency and
LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY expose it on both `run` and
`models install`.

Not done here, per the issue: no chunk-level parallelism within a single
file, and no throughput measurements across concurrency 1/2/4/8 -- that
needs a representative sharded repo and a real link.

Assisted-by: Claude:claude-opus-5 go-test gofmt
Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>

* feat(modelartifacts): expose download concurrency in settings

Follow-up to review feedback on #11162:

- The CLI flag and docs no longer describe the limit as Hugging Face
  specific. It applies to any artifact source, as @mudler pointed out.
- artifact_download_concurrency is now a persisted runtime setting and
  is editable from the WebUI, so it can be changed without a restart.

The manager's limit becomes an atomic.Int64 behind
SetDownloadConcurrency, because a live runtime setting can be updated
while a materialization is already in flight. Injected materializers
stay compatible through an optional setter interface, so a manager that
does not implement it is simply left alone.

Verified before taking this on: go build, go vet and go test -race all
pass for pkg/modelartifacts, pkg/downloader and core/config. The React
UI builds with vite, artifact_download_concurrency is present in the
built Settings chunk, and eslint reports the same 8 pre-existing
warnings on Settings.jsx as it does without the change.

Implementation contributed by localai-org-maint-bot on the review
thread; reviewed, verified and signed off by me.

Assisted-by: Codex:gpt-5
Assisted-by: Claude:claude-opus-5 go-test vite eslint
Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>

---------

Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-08-07 18:00:45 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 5ff25d9d14 fix(ci): remove unsupported cosign bundle flag (#11413)
Cosign v2.4.1 emits the current Sigstore bundle format by default and rejects --new-bundle-format. Keep recursive OCI 1.1 referrer signing, and update the producer contract test to reject the unsupported option.

Assisted-by: Codex:gpt-5

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-07 17:51:31 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 9a6156d808 feat(nemo-speech-cpp): add the NVIDIA NeMo-Speech.cpp backend (#11406)
* feat(nemo-speech-cpp): scaffold the backend and upstream build

Adds the backend skeleton and the NeMo-Speech.cpp build, pinned at
2e12e2def8a98ed06666f7ee3ca94e7193e04be4. The Go side is deliberately a stub:
it dlopens the runtime and starts the gRPC server, later work fills in the
symbol table and the model logic.

Three details of the upstream layout differ from what the plan assumed, and the
build reflects the real tree:

* The TTS C ABI ships as libnemo_speech_tts, not libnemo_speech_tts_c. Upstream
  compiles c_api.cpp straight into the implementation library and only aliases
  the nemo_speech_tts_c CMake target, so no _c object exists on disk. ASR and
  NMT do build a real _c shim.
* Shared objects land in build/bin, since upstream points
  CMAKE_LIBRARY_OUTPUT_DIRECTORY at ${CMAKE_BINARY_DIR}/bin.
* The ASR and NMT _c shims carry a DT_NEEDED on libnemo_speech_asr and
  libnemo_speech_nmt, so those are staged and packaged alongside them.
  Otherwise dlopen fails at startup.

The ggml patch step uses an order-only prerequisite. cmake writes into the
checkout and bumps its mtime past the sentinel, which would otherwise re-run
git apply over an already-patched tree and break every incremental build.

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

* fix(nemo-speech-cpp): make 'build' produce the package and bundle the ITN stack

Addresses the review of the scaffold commit.

backend/Dockerfile.golang runs 'make -C backend/go/$(BACKEND) build' and then
copies package/ into the final image, so 'build' has to end with a populated
package/. It only staged shared objects, which would have shipped an image with
no binary and no libraries at all. The old staging recipe is now stage-libs and
the chain is stage-libs, nemo-speech-cpp-grpc, package, build, matching every
sibling Go backend.

Text normalization was packaged incorrectly. nemo_speech_text_normalization is
STATIC but links sparrowhawk, fstfar and fst PUBLIC, so they land as DT_NEEDED
on libnemo_speech_asr.so, and they live in a project-local prefix that nothing
else provides. WITH_NORM stays ON by default on Linux, since normalization is a
wanted feature. Instead stage_libs now copies .deps/itn/lib when WITH_NORM=ON,
and package.sh bundles it.

Staging that prefix is still not enough on its own: Sparrowhawk drags in
protobuf, re2 and absl, which neither build_itn_deps.sh nor
package-system-libs.sh provides. Rather than hard-code another hand-maintained
list, package.sh now walks the DT_NEEDED entries of everything staged and copies
whatever is unresolved, skipping the core set and the GPU set that the shared
scripts already own. It fails at package time, not at first dlopen, when
something cannot be resolved. On a WITH_NORM=OFF build the closure is already
complete and it copies nothing.

Restore CGO_ENABLED=0 on the Go build to match whisper, parakeet-cpp and
omnivoice-cpp. Note that purego reaches dlopen through fakecgo, so the binary is
dynamically linked either way; what the flag changes is the NEEDED set, and
lib/ld.so routing in run.sh exists precisely because the binary is not static.

Replace the hand-rolled .patched sentinel with upstream's
scripts/apply-ggml-patches.sh. It applies the series in filename order, exits
non-zero when a patch does not apply, and detects "already applied" by comparing
the full-series tree hash rather than an mtime, so it is safe to run every time
and there is no sentinel left to go stale or to wedge the build when deleted. It
is wired as an order-only prerequisite so running it does not force a relink.

Also: correct the package.sh header, which claimed three shared objects when
there are five and none of the TTS ones carry a _c suffix; give 'make test' the
LD_LIBRARY_PATH the dlopen tests will need; document that a NEMO_SPEECH_VERSION
bump needs 'make purge'; and extend 'clean' to remove package/ and the ITN
libraries.

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

* fix(nemo-speech-cpp): make the closure guard fail closed and give CI its toolchain

Addresses the second review round.

The dependency-closure guard failed open. Its glob expands once per pass, so
each pass advanced the closure by exactly one level, and the fixed count of five
passes then fell out of the loop without checking whether anything remained. An
eight-deep chain packaged six libraries, exited zero and reported success. That
is the case the guard was written for: asr to sparrowhawk to protobuf to absl
already runs several levels deep, so a WITH_NORM build could ship missing its
deepest libraries and fail at first dlopen. The loop now runs until the staged
set stops growing, and exhausting the bound is a hard error rather than a silent
exit.

For the same reason, a build image with neither readelf nor objdump no longer
warns and skips. It cannot show the package is complete, so it refuses to ship
it. The guard is entered only when there is something to check, so an empty
package cannot trip the new error.

Dockerfile.golang installed ninja-build only in the Vulkan branch while this
Makefile runs cmake -G Ninja unconditionally, so the CPU, cuBLAS and L4T images
could not configure at all. ninja-build moves to the common apt list; it does
not change CMake's default generator, so it is inert for the other backends.

gcc-12 was nowhere in the tree, yet WITH_NORM defaults ON and
build_itn_deps.sh needs it, so the committed default was unbuildable in CI.
Install it, with the protobuf, absl, re2 and autotools that Sparrowhawk and
OpenFST need, gated on BACKEND so the other Go images do not carry it. The list
follows upstream's own docker/Dockerfile, trimmed of the gRPC, portaudio and
python entries a BUILD_GRPC=OFF build does not use. Text normalization stays ON:
downgrading it silently would ship a backend advertising a feature it lacks.

Also: make test depend on stage-libs, so LD_LIBRARY_PATH is not an empty
directory on a clean tree, and add an engine target so Dockerfile.golang's
cacheable prebuild layer is not skipped and a CUDA build stops recompiling all
of upstream on every Go-side change.

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

* fix(nemo-speech-cpp): pin protoc for ITN and make the norm stack its own target

Addresses the third review round.

Dockerfile.golang installs protoc 27.1 into /usr/local/bin, ahead of /usr/bin,
while libprotobuf-dev is the distro's 3.21 on noble and 3.12 on jammy.
Sparrowhawk resolves protoc from PATH at make time (configure.ac uses
AC_CHECK_PROG, so PROTOC substitutes to the bare word, and src/proto/Makefile.am
invokes it) and commits no pregenerated stubs, so the rule always runs. Code
generated by 27.1 includes google/protobuf/runtime_version.h and a
PROTOBUF_VERSION guard the older headers lack, so the WITH_NORM build could not
complete. Pin PROTOC to the apt one for that step; configure documents that a
pre-set value wins. The apt protoc and libprotobuf-dev come from one source
package at one version, which is the property that makes this correct.

The text-normalization stack is now a target keyed on a file build_itn_deps.sh
actually produces, rather than a side effect of the runtime library rule. As a
side effect make could not see whether it existed, so once the library was up to
date the script could never run again: a tree built WITH_NORM=OFF could not move
to ON, and make test hard-failed with no escape but a full 345 MB clean. It is
now built on demand and reachable on its own as 'make itn'. Staging keys on the
prefix existing rather than on WITH_NORM, so it stages what the tree actually
built, and package.sh's closure guard remains the backstop.

An already-configured build tree also now wins over the platform default, so a
tree built WITH_NORM=OFF is not silently reconfigured to ON by a bare make test,
which is what demanded gcc-12 from developers who chose not to have it. An
explicit WITH_NORM= on the command line still overrides both, and the ITN rule
preflights for gcc-12 with an error that names the alternative.

Move ninja-build out of the shared apt layer into the existing BACKEND-gated
block. Dockerfile.golang serves 225 matrix entries and only this backend
configures with -G Ninja, so the common list is byte-identical to master again
and no other image loses its cache.

Drop libabsl-dev and correct the comment that justified it. No base image here
ships protobuf 25, so nothing needs the absl split, and the cmake glob looks in
/usr/lib rather than the multiarch directory Ubuntu actually uses, so the
package could never have contributed anything.

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

* fix(nemo-speech-cpp): move the backend apt gate below the expensive layers

Addresses the fourth review round.

The nemo-speech-cpp apt block sat immediately after the shared apt layer, above
the Vulkan SDK build, the CUDA and ROCm installs, the Go toolchain and the
protoc download. Docker keys each layer on its parent, so inserting a step there
re-keys everything below it: a byte-identical shared layer is not enough, and
merging as it stood would have forced all of those to re-execute once for every
Go backend image. Move it down beside the existing opus, crispasr and
sherpa-onnx gates, which sit after those layers for the same reason.

Checked the ordering both ways before moving. Nothing between the two positions
uses these packages: the Vulkan and opus blocks install their own ninja and
pkg-config, go install protoc-gen-go needs the Go toolchain rather than protoc,
and the protoc 27.1 step is a release-binary download that needs neither
protobuf-compiler nor libprotobuf-dev. Nothing in the block needs anything those
layers provide; it uses only apt, and the mirror rewrite from the first RUN
persists in the image. It also runs no update-alternatives, so the default
compiler stays untouched for later layers. The diff against master is now a
single additive hunk with no shared layer touched.

Also preflight ITN_PROTOC. configure gates a preset PROTOC on test -n alone, so
a path that does not exist is accepted and the error surfaces much later as a
bare "No such file or directory" from inside make -C src/proto. The pin
introduced that failure on a box whose only protoc is in /usr/local/bin, which
worked before. Check it alongside the gcc-12 check and name the ITN_PROTOC=
override in the message.

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

* feat(nemo-speech-cpp): parse model options

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

* feat(nemo-speech-cpp): guard the empty option value and warn on a bad gpu index

An empty value like "vad_model:" must stay empty, since callers read the
empty string as "unset". That branch of resolve() had no spec: dropping the
guard left every spec green while parseOptions started returning the models
directory itself. Add the spec that fails without the guard.

A known key with an unparseable value is a typo, not a config from a newer
backend, and "gpu:banna" failed expensively: the model loaded, produced
correct output, and ran on CPU with no signal anywhere. Log it. Unknown keys
stay silently ignored, which is what keeps configs forward compatible.

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

* feat(nemo-speech-cpp): detect model family and discover TTS assets

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

* feat(nemo-speech-cpp): bind the C ABI with layout assertions

purego binds by name at runtime and the config structs are passed by pointer,
so both a renamed symbol and a mismatched struct layout would otherwise survive
a green build. registerSymbols names the failing symbol, and the layout specs
compare each Go mirror against the size the library reports for itself, against
the offsets a C compiler produces for the installed headers, and against the
default values upstream writes into the structs it returns.

Two of the bindings differ from the plan because the headers do. The plan's
nemo_speech_diar_segments signature omits the segmentation-config pointer that
diar.h declares as the second parameter, which would have shifted the output
buffer, the capacity and the count pointer one position each. And
nemo_speech_diar_stream_push_f32 was missing from the symbol table although
standalone diarization cannot work without it.

Also close the two panic and equality gaps left in family.go: ValueString panics
on a mistyped general.architecture, and the self-codec guard compared a Cleaned
candidate path against an uncleaned one, so a doubled separator let the primary
GGUF be selected as its own codec.

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

* test(nemo-speech-cpp): run the ABI specs in CI and refuse to skip them

The layout assertions were inert. TEST_PATHS does not cover this backend and
the per-backend list in test-extra had no entry for it, so nothing invoked the
package's tests. Add it next to depth-anything-cpp, supertonic and vllm-cpp,
the group whose own test target carries its build prerequisites; stage-libs
already pulls the native build chain, so no prepare-test-extra entry is needed.

The skip guard was also loader-inconsistent: librariesPresent stats bare
filenames relative to the working directory while openLibraries resolves them
through the loader search path, so any invocation other than make test skipped
every library-backed spec and still reported green. NEMO_SPEECH_REQUIRE_LIBS=1
turns that into a failure naming the directory and the remedy, and the Makefile
test target sets it. Unset, the plain skip survives so a developer without a
build can still run the pure-Go layer specs.

Trim the default-value fingerprint from roughly forty assertions to eight. It
was pinning tunables such as threads and flush_partial_chunk, so a legitimate
pin bump would have failed with a message reading like a layout error. What
survives is only header-documented contract: the lone non-zero max_alternatives,
the run of -1 sentinels and the zero that witnesses where it stops. Verified the
narrowed spec still catches a mirror and offset table corrupted in lockstep,
which is the one class only this layer sees.

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

* feat(nemo-speech-cpp): select the family at load and gate RPCs on it

Load sniffs the GGUF architecture, maps it to a family and dispatches to
the family's loader. requireFamily gates every other RPC, returning
Unimplemented naming both the loaded and the wanted family so a
misconfigured model YAML produces a message a user can act on.

The family is committed only once its loader has succeeded. A load that
fails part way through would otherwise leave the gate open on a handle
that was never created.

cstr uses runtime.Pinner rather than an ordinary Go allocation. The
address crosses the ABI as a uintptr, which the collector does not
trace, so incidental reachability through the release closure is not a
guarantee: a caller discarding that closure could have the bytes
collected before the create call reads them. Pinning is the sanctioned
mechanism, makes the release function do real work, and turns a dropped
release into a loud leaked-Pinner panic instead of silent corruption.

Free overrides the base no-op to destroy the handle and reset the
family. Every family owns C memory only its own destroy entry point can
release, so without this an unloaded model leaks an acoustic model.

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

* fix(nemo-speech-cpp): pin the load ordering, close the engineMu race

Three review items, plus a defect the race detector turned up.

The spec covering "no family selected after a failed load" wrote junk to
a .gguf, so Load returned at ggufArchitecture before a family was ever
chosen and the assertion was vacuous. Generalised the GGUF test helper
to take a string architecture, and added a spec that loads a magpietts
GGUF with no sibling codec, so familyFor succeeds and discoverTTSAssets
then fails. It self-guards on ggufArchitecture so it cannot degrade back
into the earlier path.

requireFamily read n.fam unlocked while Free wrote it under engineMu,
which the race detector confirms is a real race. pkg/grpc/server.go
calls Free without the backend lock every other RPC holds, so teardown
can land mid-request. withEngine now takes the lock, checks the family
and runs the body under one acquisition; two would leave a window for
Free to destroy the handle between check and use. The locking protocol
is stated in both directions for the RPCs still to be written.

Running -race also enables checkptr, which aborts on cstr's pointer
being read back by goString: converting a uintptr to a pointer is fatal
whenever the address lands in a Go allocation, so a pinned Go buffer can
never be dereferenced from Go. The pointer is for C alone. Both helpers
now document the one-way contract, and goString is tested against a real
C-owned string by rebinding the version symbol to return a raw char*.

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

* feat(nemo-speech-cpp): implement offline transcription

Create the ASR recognizer in loadASR and serve AudioTranscription.

Segment times are int64 nanoseconds, not seconds: the proto field is an
int64 that core/backend reads straight into a time.Duration, while the
runtime reports word offsets in milliseconds. Words are grouped into one
segment per consecutive speaker run, with the 1-based speaker tag carried
through and 0 (untagged) left unlabelled.

The whole RPC body runs inside withEngine so the family check and the C
calls happen under one acquisition of engineMu. Free runs without the
backend lock, so checking the family and then relocking would let a
teardown destroy the handle in the gap. The audio decode is inside the
closure too, which costs nothing: base.SingleThread already serialises
this backend's RPCs.

recognizeF32 guards zero-length PCM. &pcm[0] panics on an empty slice, so
Go never reaches the C side's own "empty audio" rejection, and a silent
clip or a truncated upload is ordinary input.

pkg/utils has no WAV decode helper, only the ffmpeg normalisation, so
audio.go pairs AudioToWav with go-audio the way parakeet-cpp does. It
returns the sample rate rather than a duration, since the C API resamples
off that number.

Also closes the write-side half of the race Task 5 fixed on the read
side: Load now holds engineMu across the family switch and the n.fam
commit, matching Free. The loaders still must not take it.

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

* feat(nemo-speech-cpp): implement streaming and live transcription

AudioTranscriptionStream drives a whole clip through the cache-aware
streaming API in 100 ms pushes, emitting each finalized utterance as a
delta and closing with the assembled result. AudioTranscriptionLive
serves the bidirectional RPC over the same session: config first, a ready
ack, deltas with word timings as utterances land, and a terminal result
when the caller closes its send side.

Both wrap their body in withEngine, so a stream holds engineMu for its
whole life and Free waits on it rather than destroying the recognizer
underneath a half-finished stream. That makes the way out load-bearing:
the file loop honours the request context between pushes, and the live
loop ends when the host closes the request channel, so a disconnected
client cannot pin the model against unload.

Only finals become deltas. The runtime applies punctuation and inverse
text normalization on finals only, so a final rewrites the utterance
rather than extending its interim, and delta on the wire is
newly-finalized text that consumers concatenate. Forwarding interims
would duplicate and mispunctuate every utterance.

The four streaming entry points sit behind an asrSession interface. No
NeMo GGUF is small enough to keep in the tree, so without that seam the
need-more-audio drain would have no test at all: nemo_speech_asr_stream_next
reports OK with a NULL handle when it wants more audio, which is a pause
rather than an end, and reading it either way round drops results or
spins forever.

Also folds in three items from the offline transcription review:

  - empty audio is now refused before anything crosses the ABI, not
    inside recognizeF32. The added integration spec caught the old
    ordering panicking on an unbound entry point instead of failing;
  - an undecodable sample rate is an error rather than 0, which this
    runtime reads as "already at the model rate" and would have made a
    wrong rate silently pitch-shift the audio;
  - AudioTranscription guards its result pointer instead of relying on
    an unstated invariant.

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

* fix(nemo-speech-cpp): make live deltas concatenate and fill segment words

runLive wrote the inter-utterance separator into the accumulated
transcript but emitted the delta without it, so a two-utterance turn sent
"one." and "two." while the terminal result read "one. two.". The live
consumer is the one that really concatenates: the realtime semantic-VAD
path joins the accumulated deltas with the empty string and clears them
only at a turn reset, never at an endpoint, so the running caption read
"one.two.". The separator now goes into the delta, as it already did on
the file path, and the terminal text is the verbatim concatenation rather
than a trimmed rebuild.

TranscriptSegment.Words was never populated, so a request asking for
timestamp_granularities ["word"] came back with no words at all even
though the timings were decoded. wordsToSegments now attaches them,
gated on the granularity the same way parakeet-cpp gates it, so a
transcript that did not ask for word timestamps does not pay for them.

Also: the final that comes back from the tail flush no longer claims an
end-of-utterance. It is the end of the stream, not a user yielding the
turn, and eou is what the realtime turn detector acts on.

The comment explaining why interims are suppressed led with the runtime's
postprocessing. The wire contract is the stronger reason and now comes
first: consumers concatenate deltas, so forwarding a growing hypothesis
assembles to "hehellhelloHello.". The postprocessing only explains why no
diffing trick would rescue them. It is also ITN and strip_formatting
rather than punctuation, which is off by default here.

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

* feat(nemo-speech-cpp): implement standalone diarization

loadDiarizer creates the Sortformer diarizer and Diarize serves the RPC
over a diarization stream: decode, chunked push, finish, then the
count-then-fill segments protocol.

nemo_speech_diar_segment carries start_time and end_time in SECONDS
already, not frame indices, so no conversion happens on the way to
DiarizeSegment.start/end and the model's seconds-per-frame is not
involved at all. The speaker label is the runtime's 1-based tag as a
decimal string, matching what wordsToSegments emits on the ASR path, so
the same speaker reads the same way whether a caller diarized a file or
transcribed it.

The six frame-geometry overrides are written as -1 rather than left
zero. c_api.cpp applies left_context_frames when it is >= 0 while every
other override needs > 0, so a zeroed config would silently pin the left
context to zero and change the model's streaming geometry.

nemo_speech_diar_segments writes *count before it rejects a buffer that
is too small, so a rejected fill still reports the size to retry with.
collectSegments uses that rather than truncating, bounded at four
attempts because the RPC holds engineMu for its whole body and an
unbounded retry would block an unload behind it.

Two DiarizeRequest knobs map onto the segmentation config, and the
proto and header names cross over: min_duration_on is the C
min_duration_sec and min_duration_off is the C min_gap_sec. Six fields
have no equivalent in this pipeline and are logged rather than dropped
in silence: num_speakers, min_speakers and max_speakers (Sortformer's
capacity is fixed by the checkpoint), clustering_threshold (there is no
clustering stage), include_text (no ASR here) and threads.

The empty-PCM guard fires before the stream is opened, so a silent clip
never reaches a purego entry point that would dereference &pcm[0].

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

* fix(nemo-speech-cpp): pin the diarizer geometry sentinels and cap the segment buffer

The six frame-geometry overrides were written as -1 with nothing
asserting it. c_api.cpp applies left_context_frames at >= 0 while the
other five need > 0, so a dropped sentinel there pins the model's left
context to zero, and the struct keeps exactly the same shape, which is
all the layout assertions can see. Extracting diarModelConfig makes the
values assertable: five specs now pin all six frame fields, the device
index, the declared size and the NULL preset, each frame field on its
own line so a missing sentinel names itself.

distinctSpeakers had a spec with three segments over three distinct
labels, which len(segs) satisfies just as well as the real thing. Four
segments over three labels makes it a spec that can fail.

collectSegments sized its buffer straight from a count the C side
reported, and make() panics rather than erroring on a length it cannot
satisfy, so an uninitialised size_t coming back across the ABI killed
the backend process instead of failing one request. A ceiling of 2^22
segments, upwards of 93 hours of audio at one 80 ms frame each, turns
that into a diagnosable error.

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

* feat(nemo-speech-cpp): implement TTS and streaming TTS

The PCM callback is compiled once per process behind a sync.Once, not once per
request and not once per load. purego.NewCallback writes into a fixed table of
2000 entries (purego/syscall_sysv.go) and never releases one, so a per-request
callback panics the backend process on the 2001st synthesis, and a per-load one
reaches the same ceiling on a server that swaps models. Synthesis is routed
through that single callback plus a user_data id: engineMu is per-model, one
process holds several models, so a single current-sink pointer would be
overwritten by two TTS models synthesizing at once.

Deviations from the brief, all verified against the real headers and proto:

  - TTS is TTS(*pb.TTSRequest) error and TTSStream is
    TTSStream(*pb.TTSRequest, chan []byte) error, per pkg/grpc/interface.go.
    The brief's context/pb.Result and server-stream forms do not implement the
    interface. The channel is closed on every path, including the family
    rejection, because pkg/grpc/server.go blocks on its drain goroutine and an
    unclosed channel hangs the RPC with the backend lock held.
  - The callback takes unsafe.Pointer, not uintptr. Converting a uintptr
    parameter back to a pointer is a checkptr violation that aborts under
    -race.
  - resolveSpeaker refuses to turn a negative number into a speaker index. -1
    is the C API's "use the default" sentinel, so the brief's rule would have
    made a request naming an invalid voice synthesize in the default voice
    instead of being rejected.

temperature and cfg_scale each write their override flag as well:
magpietts/runtime.cpp reads the float only when the flag is set, so a
temperature without it is silently discarded.

Also folds in Task 8's review finding on asr.go: the six bare -1 sentinels in
loadASR move to an asrDiarConfig builder reusing diarGeometryDefault, with
specs. src/asr/c_api.cpp applies left_context_frames at >= 0, so a dropped
sentinel pins the model geometry to 0 and no layout assertion can see it.

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

* feat(nemo-speech-cpp): surface NMT translation through Predict

nemo_speech_nmt_translate takes explicit source and target languages and has no
free-form generation or token-callback entry point, so there is no prompt in the
LLM sense. The pair comes from the source_language / target_language model
options, with an optional leading [src->tgt] directive as the only per-request
override, and PredictStream emits the whole translation as a single chunk
because the C API has nothing finer to give it.

Both RPCs wrap their body in withEngine so the family check and the C calls that
trust the handle share one acquisition of engineMu. PredictStream closes its
channel on every path, including the family rejection: this is the legacy
streaming contract, and pkg/grpc/server.go blocks on a drain goroutine that only
finishes when the channel closes, so leaving it open hangs the RPC rather than
failing it.

nmtTranslatorConfig is extracted so its four adjacent pointer fields can be
asserted against distinct sentinels. Transposing two of them changes neither the
struct size nor any field offset, so the layout assertions cannot see it.

Also removes goString, which had no production caller: every string-returning
symbol in abi.go is bound with a Go string return that purego converts itself.

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

* test(nemo-speech-cpp): pin the three-segment pair tag in an NMT directive

The directive regex allowed an unbounded run of two-letter segments per side, but
nothing tested it: narrowing that run back to a single optional segment left every
spec green. resolve_tag accepts a ready pair tag in one field with the other empty
(src/nmt/langpairs.cc), and those tags run to three segments (en-zh-cn, pt-br-en),
so a shorter pattern does not mis-split the tag, it fails to match the directive at
all and the whole bracket is handed to the model as text to translate.

The justification on the regex was also wrong and is corrected: pt-br and zh-cn are
two segments and parse either way. It is the single-field form that needs the run.

Renames the NMT handle to n.nmt so it stops sharing a name with the translator
interface, following n.synth, which is shortened for the same reason.

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

* feat(nemo-speech-cpp): register the backend and give its specs a CI job

Registers nemo-speech-cpp across every surface .agents/adding-backends.md
requires, and adds the CI job its unit suite never had.

backend/index.yaml gets the meta backend (capabilities map, no uri), a
development meta and 12 image entries. No amd and no intel capability keys:
upstream NeMo-Speech.cpp builds ggml with CUDA, Vulkan or Metal only, and
SystemState.Capability falls back to "default", so those hosts get the CPU
build rather than a tag that does not exist. The nvidia-cuda-* and
nvidia-l4t-cuda-* keys are present because getSystemCapabilities() refines an
NVIDIA host to them whenever the CUDA directory exists; without them every
modern CUDA host and Jetson would miss the map and quietly run on CPU.

.github/backend-matrix.yml gets 7 include rows and 1 includeDarwin row. No
hipblas and no sycl rows, for the same upstream reason. cpu and vulkan are
per-arch pairs sharing a tag-suffix so backend-merge-jobs builds a multi-arch
manifest: an ARM host with no NVIDIA GPU reports "default" and the Jetson image
does not cover it.

The CI job is the substantive part. make test-extra is dead on master, because
prepare-test-extra depends on a protogen-python target that does not exist and
no workflow invokes it anyway, so the entry added earlier in this series ran
nowhere. abi_test.go asserts the size and field offsets of every Go mirror
struct against the C ABI it is dlopened into, and those assertions are the only
defence against silent memory corruption after a purego symbol rename or an
upstream header change. tests-nemo-speech-cpp in test-extra.yml now executes
them on pull_request and on master, gated on the backend's own path filter.
The recipe sets NEMO_SPEECH_REQUIRE_LIBS=1, so a missing library fails rather
than skips. WITH_NORM=OFF skips the OpenFST leg and costs no coverage: nothing
in the four C ABI headers is conditional on it, so the layouts are identical.

Also registers the upstream pin with the bump bot, which the backend Makefile
already claimed but was never wired up, and adds the BackendCapabilities entry
so a hand-written model config gets a real usecase surface. PossibleUsecases is
the union of the four families and DefaultUsecases is transcript alone, the
audio-cpp pattern. No VoiceCloning key: MagpieTTS synthesizes from baked
speaker ids, not a reference clip.

No gallery entries: publishing converted GGUFs is a follow-up.

ModelIdentity needs no work in this backend. main.go serves through
grpc.StartServer, so every RPC lands on pkg/grpc's shared server wrapper first,
and checkModelIdentity is the first statement of all seven handlers this
backend implements. A second check inside NemoSpeech would be unreachable and
would risk diverging from the cross-language sentinel the router matches on.
AudioTranscriptionLive stays unguarded because TranscriptLiveRequest carries no
ModelIdentity field at all, which is a proto-level gap affecting every backend
and needs its own change.

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

* fix(nemo-speech-cpp): build the CUDA-13 Jetson image the l4t-cuda-13 key needs

The nvidia-l4t-cuda-13 capability pointed at nvidia-l4t-arm64-nemo-speech-cpp,
which is built on nvcr.io/nvidia/l4t-jetpack:r36.4.0 and therefore links ggml
against CUDA 12. A Jetson whose CUDA 13 runtime is present reports that
capability and would have pulled an image with no libcudart.so.12 to dlopen,
failing hard at load. That is worse than omitting the key: with no key
Capability() falls back to "default" and the host gets a working CPU build.

Fixed the way parakeet-cpp and moss-transcribe-cpp already do it, by shipping
the second L4T image rather than dropping the key. Nothing prevents building it
here: those peers use plain ubuntu:24.04 on ubuntu-24.04-arm with the same
Dockerfile.golang as this backend's other rows, and every package in the
nemo-speech-cpp apt gate exists on noble arm64.

Adds the -nvidia-l4t-cuda-13-arm64-nemo-speech-cpp matrix row and its two index
entries, repoints the key on both metas, and rewrites the capability-map comment,
which had the reasoning backwards.

Also adds the documentary inferBackendPath branch, matching all six sibling
*-cpp Go backends. Behaviour is unchanged; the generic golang fallthrough
already resolved this backend correctly.

The previous commit message said "all seven handlers" of the shared gRPC
wrapper. There are eight RPC entry points: seven are guarded by
checkModelIdentity and AudioTranscriptionLive is the unguarded eighth, which
that message already called out separately. Wording only.

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

* docs(nemo-speech-cpp): document the backend and list it in the importer

Adds docs/content/features/nemo-speech-cpp.md, alongside the audio.cpp page
that is its closest sibling, and cross-links it from the speech-to-text,
diarization, text-to-speech, backend-type and compatibility-table pages so the
backend is reachable from every surface that lists its modalities.

The page covers the architecture-to-family table, every option key with a model
YAML per family, the translation prefix directive, the acceleration matrix, and
the four limitations this backend ships with: Linux-only inverse text
normalization, suppressed interim streaming results, the library's default
translation context and generation limits, and the absence of gallery entries.

knownPrefOnlyBackends gains the backend so it appears in the /import-model
dropdown. It stays preference-only and AutoDetect=false: general.architecture
lives inside the GGUF where no remote-repo probe can read it, and a translation
model carries an ordinary LLM architecture with no NeMo-specific marker.

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

* docs(nemo-speech-cpp): correct the translation limits, the macOS gap and the TTS conversion

Three factual errors found in review, all of them the kind a user would act on.

The translation limits were described backwards. Input longer than the 1024-token
context is rejected, not truncated: translator.cpp throws "nmt: prompt too long
(N tokens) for context 1024", which reaches the caller as a failed request. What
is silently cut is the output, by the max_new_tokens loop at 256. The bullet now
separates the two and says which one fails quietly.

The macOS gap covers TTS text normalization as well. Both directions sit behind
the single NEMO_SPEECH_WITH_NORM flag, which the Makefile forces off on Darwin,
so tn_dir is as inert there as itn_dir. Neither fails the load: both warn and
carry on. pnc_model really is unaffected, since punctuation is compiled in
unconditionally. The tn_dir row in the option reference gained the caveat the
itn_dir row already had.

The TTS conversion procedure produced a model that could not load. It converted
MagpieTTS and stopped, leaving no NanoCodec, which the same page lists as
required; following it gave "no NanoCodec GGUF found next to ...". Both halves
are now there, each with the download that feeds it, so the block runs top to
bottom on a clean machine.

Also: any negative gpu value pins TTS to the CPU, not only -1, and FLAG_CHAT
additionally surfaces the model in the web UI chat picker.

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

* fix(nemo-speech-cpp): map every C status, not just the NMT one

INVALID_ARGUMENT was translated to codes.InvalidArgument at exactly one of
sixteen C call sites. Everywhere else a non-zero status collapsed to
codes.Internal, so the same backend answered an unsupported language pair with
HTTP 400 and an unknown TTS voice, which is the same class of caller mistake
against the same process, with HTTP 500. Status 4 is CANCELLED on the ASR and
TTS surfaces and was reported as a backend failure rather than as the consumer
having stopped listening.

asr.h, tts.h and nmt.h each declare their own status enum and diar.h reuses the
ASR one; the values they share agree, and the single divergence is that NMT
declares no CANCELLED because nemo_speech_nmt_translate has no callback for a
consumer to stop with. That is an absence, not a disagreement, so one table
serves all three. status.go carries it, with the header line numbers and a note
that a pin bump has to recheck it: purego binds by name and the status crosses
as a bare int32, so nothing in the build or the linker can see a drift.

New specs cover the whole enum, unknown values, and one real INVALID_ARGUMENT
per family driven through the shared objects rather than through the Go mapping
asserting against itself.

Also add UsecaseChat to this backend's capability entry, which the docs already
told operators to set for translation models. chat is a gallery filter key and
completion is not, so GET /api/backends/usecases would have greyed the Chat
filter out and hidden a Riva-Translate gallery entry from the one filter that
fits it. The flag gates no endpoint; it makes the model eligible as the default
chat model and puts it in the web UI chat picker, both of which Predict and
PredictStream already serve.

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

* fix(nemo-speech-cpp): audit the gosec unsafe and file-inclusion sites

gosec flags 13 alerts on this backend: one G304 and twelve G103. Each was
checked individually rather than blanket-suppressed, and each annotation
states what makes that particular site safe.

The G304 at audio.go is a false positive. The opened path is
filepath.Join of a directory the function just created with os.MkdirTemp
and a constant basename; the request-controlled path is the input to
AudioToWav and never reaches the open.

The twelve G103 sites are the package's three established shapes, and
every one was verified against them: cstr and pinPtr take the address of
something pinned on the line above and return it one-way (nothing in the
package converts either result back, which is what keeps checkptr out of
it under -race), and each *Create hands C a stack-local POD config whose
uintptr members are cstr allocations or pinPtr addresses held by a pinner
the loader unpins only after the call. The two slice-building sites are
bounded by construction: DiarSegments is handed exactly len(buf) with the
buffer sized under maxDiarSegments and a reported count larger than it
rejected rather than sliced to, and the TTS callback copies out a slice
whose length is the length the runtime declared for that buffer.

Separately, sampleRateOf gets a real fix rather than an annotation.
go-audio reads the WAV header's sample rate from an unsigned 32-bit field
into an int, so a header claiming more than 2^31-1 passed the "> 0" test
and then narrowed to a NEGATIVE rate, which the runtime would take as a
resampling ratio. AudioToWav cannot produce one today, but that is a
property of another package and this function exists precisely because
the rate is read back rather than assumed, so the bound is enforced here
and pinned by a spec.

The four remaining integer narrowings are annotated with the bound that
makes each safe: the WAV payload length is already checked against
maxWAVDataBytes, the speaker count is bounded by maxDiarSegments, and the
two segment ids are the proto's own int32 wire type.

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

* fix(nemo-speech-cpp): skip the CUDA-only ggml patch series on darwin

The macOS backend build died in patch-ggml:

    scripts/apply-ggml-patches.sh: line 56: mapfile: command not found
    make[1]: *** [patch-ggml] Error 127

mapfile is a bash 4 builtin (and its -d flag needs 4.4). macOS ships bash
3.2.57 as /bin/bash and GitHub's runner images add no newer one, so the
bare `bash` the recipe resolves from PATH cannot run upstream's script.

Rather than hunt for a capable bash that the runner does not have, drop
the step where it does nothing. ggml-patches/ is a CUDA series: every
kernel it adds is under src/ggml-cuda/, and its whole footprint outside
that directory is an op enum plus prototype in include/ggml.h, the
constructor and a name-table entry in src/ggml.c, and two ggml-cpu lines
that make the CUDA-only op report unsupported and abort. Nothing it
touches is compiled into a Metal kernel or changes a CPU one.

The project's own references to patch-only ggml symbols sit behind
NEMO_SPEECH_FUSED_RELPOS_ATTN and NEMO_SPEECH_FASTCONFORMER_CUDA_FUSIONS,
which cmake already forces OFF without GGML_CUDA, or behind
NEMO_SPEECH_GGML_PATCHED itself, which guards a GGML_TENSOR_FLAG_Q8_PLANAR
write that a non-CUDA buffer throws before reaching. So passing
NEMO_SPEECH_GGML_PATCHED=OFF costs the Metal build nothing, and it is
required once the series is skipped: that flag is what stops the ASR
sources referencing a tensor flag stock ggml does not define.

This is upstream's own Metal configuration. Its metal-* and vulkan-*
CMake presets inherit the cpu-* ones, which set NEMO_SPEECH_GGML_PATCHED
to OFF; docker/Dockerfile and scripts/windows/build.ps1 do the same for
their non-CUDA targets. LocalAI's Makefile never passed the flag at all
and so inherited the CUDA default everywhere.

Linux is untouched and keeps applying the series, including its
idempotency and its hard failure on a patch that does not apply. The gate
is the same uname test the WITH_NORM block above already uses, and both
branches keep the order-only clone prerequisite, which on a WITH_NORM=OFF
tree is the only thing that pulls sources/ in.

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

* fix(nemo-speech-cpp): restore std::binary_function for MeCab on libc++

NEMO_SPEECH_TTS_WITH_JA=ON compiles Open JTalk's bundled MeCab, and
mecab/src/dictionary.cpp derives a comparator from std::binary_function,
which C++17 removed. libstdc++ still ships it as deprecated-but-present
under -std=gnu++17, so Linux never notices. libc++ compiles it out and
the macOS arm64 build dies with "no template named 'binary_function' in
namespace 'std'".

This is ours, not an upstream regression: upstream defaults both
NEMO_SPEECH_TTS_WITH_JA and NEMO_SPEECH_TTS_WITH_ZH to OFF and the OSS
drop carries no CI at all, so that target is never built there. Upstream
does already carry the equivalent workaround for MSVC's STL
(_HAS_AUTO_PTR_ETC plus /FIfunctional) but has no libc++ branch.

libc++ gates the two templates on
_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION, and has since LLVM
16, older than any clang Xcode still ships. The name is the whole
problem: _LIBCPP_ENABLE_CXX17_REMOVED_BINDERS covers bind1st, bind2nd,
ptr_fun and mem_fun and not unary_function or binary_function, and the
umbrella _LIBCPP_ENABLE_CXX17_REMOVED_FEATURES no longer exists in
libcxx at all. A wrong name preprocesses fine and fixes nothing.

Applied through CMAKE_CXX_FLAGS rather than to the one target, because
the tokenizer CMakeLists is upstream's and sources/ is a pinned
checkout. Project-wide is also the safer scope: the macro decides
whether libc++'s internal __binary_function alias resolves to
std::binary_function or to __binary_function_keep_layout_base, a base
class of std::less and friends, so defining it for a subset of
translation units would give those class templates two spellings in one
binary. Both bases are empty and, at C++17, carry identical members, so
the define changes no layout and no ABI.

Darwin only. On Linux the branch is unreachable and the macro is not a
name libstdc++ knows, so it would be inert even if taken; a Linux
configure with the flag forced on puts it on all 23 C++ TUs of
nemo_speech_openjtalk_frontend including dictionary.cpp at -std=gnu++17,
and on none of the 16 C TUs.

Mandarin needs nothing: cppjieba v5.6.7 and limonp have no removed C++17
constructs left (limonp replaced std::not1 and std::bind2nd with
lambdas) and cppjieba's own CI builds macos-14 and macos-latest at C++11
through C++20.

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

* fix(nemo-speech-cpp): repair OpenFST's FstImpl::operator= for gcc-14

The first WITH_NORM=ON build failed compiling fst_normalizer.cpp against the
installed OpenFST 1.8.3 headers:

  fst.h:690:59: error: no match for 'operator=' (operand types are
    'std::unique_ptr<fst::SymbolTable, ...>' and 'fst::SymbolTable*')

FstImpl's copy-assignment operator assigns the raw pointer returned by
SymbolTable::Copy() straight to a std::unique_ptr member. No C++ standard
allows that, so the line is ill-formed everywhere; it survived because nothing
instantiates FstImpl::operator= and gcc up to 13 only checks a template
member's body when it is instantiated. gcc 14 resolves non-dependent operator
expressions at template definition time, so it rejects the line in any
translation unit that includes <fst/fst.h>. The CI diagnostic confirms the
phase: it reads "In member function", not "In instantiation of", and carries
no instantiation backtrace.

That is why this surfaces only here. build_itn_deps.sh compiles OpenFST with
gcc-12 and upstream's own images build the runtime with gcc-13, so neither
compiler reaches the check; backend/Dockerfile.golang installs gcc-14 and
promotes it with update-alternatives, and fst_normalizer.cpp is the one
translation unit in this backend that includes OpenFST.

Fix it in the installed ITN prefix, which is the only copy the cmake build
compiles against, using the same .reset() spelling FstImpl::SetInputSymbols
already uses for the identical operation. libfst.so is linked before this runs
and cannot contain the function, since no compiler could ever have emitted it,
so there is no ABI or ODR consequence. The rule is guarded on both sides so a
pin bump to a fixed OpenFST fails loudly rather than silently no-opping.

Verified with a real gcc 14.2: the CI error reproduces byte for byte from a
file whose entire content is '#include <fst/fst.h>', and gcc 14 reports
exactly two errors over the whole OpenFST include closure this backend uses,
both of them these two lines. After the patch that closure compiles clean
under gcc-14 with the target's own flags. The step is reachable only under
WITH_NORM=ON, so 'make -n stage-libs WITH_NORM=OFF' mentions neither it nor
the ITN build, and darwin, which defaults WITH_NORM to OFF, never evaluates it.

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

* fix(nemo-speech-cpp): install cmake 3.31 on bases that ship less than 3.26

The JetPack r36.4.0 row dies on the first line of NeMo-Speech.cpp's
CMakeLists.txt:

  CMake Error at CMakeLists.txt:3 (cmake_minimum_required):
  -- Configuring incomplete, errors occurred!

Upstream opens with cmake_minimum_required(VERSION 3.26). That base image is
Ubuntu 22.04 jammy, whose apt cmake is 3.22.1, so configure aborts before it
reads a single one of the backend's -D flags. Every other Linux row in this
block is noble, which ships 3.28 and clears the bar, so the failure is one
base image wide rather than a code problem. Everything before it on that row
had already worked, including the OpenFST and Sparrowhawk ITN build.

No other Go backend needs this. parakeet-cpp and moss-transcribe-cpp share
the same JetPack base and both declare cmake_minimum_required(VERSION 3.18),
and nothing in the repo installs a cmake newer than the distro's, so there is
no existing pattern to reuse. Nothing depends on jammy's cmake staying 3.22
either: build_itn_deps.sh never invokes cmake at all, since OpenFST and
Sparrowhawk are autotools builds.

Kitware's release tarball rather than their APT repo or pip. The tarball is a
pinned URL with a published checksum, so an upstream release cannot change
what lands here. The APT repo does carry jammy arm64, but it serves a moving
latest that today is CMake 4.4, and 4.x drops compatibility with
cmake_minimum_required below 3.5, which vendored third_party subprojects
still declare; pinning it there would mean tracking Kitware's Debian revision
string instead of an upstream version. pip would drag a Python toolchain into
a backend that has none. 3.31.12 is the last 3.x release, so it clears 3.26
while keeping the CMake 3 policy surface, and it stays close to the 3.28 the
green noble rows already use. The binaries need only glibc 2.17 and carry no
libstdc++ DT_NEEDED, well under jammy's 2.35. doc/, man/, ccmake and cmake-gui
are not extracted; the final image is FROM scratch, but there is no reason to
page 100 MB of Qt GUI and docs through the CI cache.

Gated on the installed cmake actually being older than 3.26, so the rows that
already build green keep configuring with exactly the cmake they use today,
and folded into the existing ${BACKEND} block rather than added as a new
instruction, so no other Go backend image gains a layer and nothing above the
Vulkan SDK, CUDA, Go and protoc layers moves.

The symlink lands in /usr/local/bin and shadows apt's cmake. Unlike the protoc
shadowing that broke Sparrowhawk earlier in this series that is inert: protoc
has to agree with the libprotobuf headers it generates against, whereas cmake
links nothing into the product and has no ABI relationship with anything in
the image, and it resolves the symlink back to /opt to find its own Modules/
tree, so a 3.31 binary can never read 3.22's modules.

The version test avoids $(...) deliberately. BuildKit delivers a RUN heredoc
through an outer shell with an unquoted delimiter, so a command substitution
runs there, too early, in a container where the files it reads do not exist
yet, and its empty output is pasted into the script; the first draft took the
install branch on every row because of it.

Verified by building the block against nvcr.io/nvidia/l4t-jetpack:r36.4.0
arm64 under qemu, the row's actual base image: cmake 3.22.1 detected, tarball
checksum verified, 3.31.12 installed, and a cmake_minimum_required(VERSION
3.26) project configures with -G Ninja and builds, with CMAKE_ROOT resolving
to /opt/cmake/share/cmake-3.31. Same on ubuntu:22.04 amd64 and arm64.
ubuntu:24.04 skips the install, gains no /opt/cmake and still configures on
/usr/share/cmake-3.28. The NeMo-Speech.cpp compile itself on JetPack CUDA 12
is not reproducible here and remains for CI.

Assisted-by: Claude Code:claude-opus-5[1m]
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-08-07 12:32:39 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 8845ccebf7 feat(gallery): default to index.localai.io with GitHub as a mirror (#11409)
The primary is a caching mirror of the same two files, so an install
resolves its gallery from infrastructure the project controls. The GitHub
URI stays as a mirror, so behaviour is unchanged whenever the primary is
unreachable.

Docs and agent guides that either state the shipped defaults or hand out a
copy-pasteable gallery list are updated to match, so following them no
longer silently demotes an install off the new primary.

Assisted-by: Claude:claude-opus-5 [go vet] [go test] [golangci-lint]

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-07 09:19:49 +02:00
mudler's LocalAI [bot]andmudler 07dfb32bc9 feat(swagger): update swagger (#11398)
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-08-07 01:25:45 +02:00
mudler's LocalAI [bot]andmudler 1101d72707 docs: ⬆️ update docs version mudler/LocalAI (#11397)
⬆️ 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-08-07 01:25:26 +02:00
dependabot[bot] b5137ad26f chore(deps): bump actions/checkout from 4 to 7 (#11396)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 01:25:07 +02:00
mudler's LocalAI [bot]andmudler ee1bf0e25b chore(model-gallery): ⬆️ update checksum (#11405)
⬆️ 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-08-07 01:24:30 +02:00
dependabot[bot] a5ba3577a4 chore(deps): bump actions/stale from 10.4.0 to 11.0.0 (#11395)
Bumps [actions/stale](https://github.com/actions/stale) from 10.4.0 to 11.0.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 11.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 00:35:18 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto a77780ad14 feat(gallery): fall back to mirrors and a cached index when the primary source fails (#11389)
* feat(version): include OS and arch in the outbound User-Agent

Registries and galleries already receive LocalAI/<version>; adding the
platform follows ordinary client convention and discloses nothing a
registry cannot infer from the manifest it is asked for.

Updates the User-Agent note in docs/content/getting-started/models.md,
which documented the old format.

Assisted-by: Claude:claude-opus-5 [go vet] [go test]

* feat(downloader): identify LocalAI on outbound requests

pkg/oci has always sent a User-Agent; the downloader sent none, so gallery
reads, model-file downloads, resume probes, content-length probes and the
HuggingFace safety scan all went out as a bare Go HTTP client, unattributable
to LocalAI by the hosts serving them.

HuggingFaceScan moves off the client's Get shorthand to an explicit request
for the same reason — the shorthand gives no place to hang a header.

Extends the User-Agent note in docs/content/getting-started/models.md, which
claimed the header was sent only to Ollama and OCI registries.

Assisted-by: Claude:claude-opus-5 [go vet] [go test]

* feat(gallery): add a mirrors list to gallery configuration

Mirrors are an availability fallback, tried in order only after the primary
URL fails. omitempty keeps existing configurations byte-identical.

The slice makes config.Gallery non-comparable with ==, which broke the two
slices.Equal callers in the runtime settings registry. Replace them with an
explicit Gallery.Equal / GalleriesEqual so a gallery list that differs from
the baseline only by its mirrors still counts as env/CLI-set. Equal compares
the Verification block by value; == compared it by pointer identity, which
called two structurally identical policies different.

Assisted-by: Claude:claude-opus-5 [go vet] [go test]

* fix(downloader): treat an HTTP error status as a failed read

ReadWithCallback handed the response body to its callback whatever the
status was, so a 404 page or a 502 from a CDN arrived as if it were a
gallery index or a model config: it parsed to nothing, got cached for an
hour, and no caller could tell the source had been down. DownloadFile has
always checked the status; this path never did.

Mirror fallback depends on it — a source that answers with an error page
has to count as unreachable, or the next candidate is never tried.

Assisted-by: Claude:claude-opus-5 [go vet] [go test]

* feat(gallery): fall back to mirrors when the primary source fails

Candidates are tried primary-first with a bounded timeout each, and a
source that just failed is skipped for a cooldown so a dead host is not
re-dialled on every listing. When every candidate is in cooldown they are
all tried anyway: refusing to serve a gallery we might be able to reach is
worse than one slow request.

The one-hour index cache is untouched and stays keyed on the gallery's own
identity, so a mirror-served fetch fills the entry the primary would have.

No SSRF validation is applied to the candidates. validateGalleryConfigURL
guards GetGalleryConfigFromURL because that URL arrives in a request body;
mirrors come from the operator's gallery configuration, the same place the
primary has always come from, and the index fetch has never validated the
primary. Validating mirrors while the primary goes unchecked would buy
nothing and would break the deployment mirrors exist for — an index served
from a host on the LAN.

Assisted-by: Claude:claude-opus-5 [go vet] [go test]

* fix(gallery): loosen the mirror fetch timeout and stop blaming the caller

The downloader only ever bounded response headers, never the body, so the
per-attempt deadline added with mirror fallback was the first whole-transfer
timeout this path has had. At 30s the default 2.2 MB index demanded ~75 KB/s
sustained: a rural-DSL, mobile or satellite user who used to wait 60s and
succeed would now fail, and then eat a 10-minute cooldown on a source that
was perfectly healthy. Raised to 120s (~19 KB/s), which no link that could
go on to download a model will miss, and made it a var so a test can shorten
it and prove a hanging candidate is actually abandoned.

Caller cancellation is no longer recorded as a failure of the source.
Unreachable today since getGalleryElements passes context.Background(), but
once a request context is wired through, a browser disconnect would have
blackholed every candidate for ten minutes over something the sources had
no part in.

Also document that mirrors do not cover a .ref gallery URL: the reference is
resolved before mirrors are considered, so a .ref that cannot be fetched
fails the gallery outright. Routing .ref resolution through the candidate
list needs a per-candidate resolve-and-fetch and a decision about cache
identity, which is more than this change should carry.

Assisted-by: Claude:claude-opus-5 [go vet] [go test]

* feat(gallery): serve the last known good index when everything is offline

A successful fetch is cached alongside the models directory and served when
no source is reachable, so an offline or airgapped machine can still list
its gallery. Entries may be stale in that state, and the fallback is logged.

The copy is deliberately kept out of the models directory, where a <name>.yaml
file is read as an installed model's configuration, and is named after a digest
of the gallery URL so the model and backend galleries cannot collide. Writing
it is best effort: a read-only or full disk must not fail a fetch that
otherwise succeeded.

Also corrects the mirror scheme list in the docs: the HuggingFace prefixes are
huggingface://, hf:// and hf.co/, not huggingface:.

Assisted-by: Claude:claude-opus-5 [go vet] [go test]

* fix(gallery): only cache a response that is really a gallery index

The last known good copy was written on any 2xx, before anything looked
at the bytes: the parse only happens later, in getGalleryElements. A
captive portal, a corporate proxy or a CDN error page all answer HTTP 200
with HTML, so any of them could overwrite a good copy. The listing fails
then and there, and the next offline start — the one case this cache
exists for — serves the interception page instead of the gallery it
already had.

Probe the body before persisting it: unmarshal into a []any and keep the
older copy unless the result is a non-empty sequence. An empty document
is rejected too. It parses fine, so a parse-only check would still let a
blank response replace a populated index with one that lists nothing,
which from the user's side is the same outage; and an empty index is
worth nothing offline, so there is no case where caching it beats keeping
what came before. The live body is still returned to the caller — the
probe gates persistence only, and getGalleryElements remains the thing
that reports a real parse failure.

Also in this pass:

- The empty-basePath guard only caught exact "". galleryCachePath(".")
  and galleryCachePath("models") still resolved the cache sibling against
  the process working directory, which is what the guard was written to
  prevent. Reject any non-absolute base.

- The docs claimed the offline cache "applies to every gallery, with or
  without mirrors". Not true for a .ref URL: the reference is resolved
  before the cache is consulted, so a .ref gallery fails offline even
  after a successful earlier fetch, and the cache file it writes can
  never be read. Extend the .ref warning and qualify the sentence.

- pkg/oci's UserAgent comment never mentioned the platform component
  added earlier on this branch.

- resetGalleryFailures and expireGalleryFailure had no non-test callers;
  move them into the test file.

- The all-candidates-failed error reported len(attempt), so a three
  mirror gallery with two sources in cooldown said "all 1 source(s)
  failed" — which reads as a misconfiguration. Report how many were
  configured and how many were skipped.

- Give the package's tests their own TMPDIR. The cache is a sibling of
  the models directory, which is right in production, but specs that
  build a models directory directly under /tmp made the sibling resolve
  to /tmp/cache and left it behind after every run.

Assisted-by: Claude:claude-opus-5 [go vet] [go test]

* fix(gallery): convert the new tests to Ginkgo and clear the lint gate

.agents/coding-style.md requires Ginkgo v2 + Gomega for every Go test and
has forbidigo enforce it; the stdlib-style tests still in the tree are tech
debt, not a pattern. Every test file this branch added was written in the
forbidden style, which is what turned CI red.

Convert all five of them. internal had no suite bootstrap, so add one;
core/config, core/gallery and pkg/downloader already have theirs and are
reused, so no package mixes styles. pkg/downloader/useragent_test.go and
read_status_test.go were not in CI's forbidigo list but used the same
forbidden calls, so they are converted too.

The one conversion with a trap in it is core/gallery. Go's t.TempDir()
yields $TMPDIR/<TestName>NNNN/001, so the gallery cache — a sibling of the
models directory — was isolated per test. GinkgoT().TempDir() yields a flat
$TMPDIR/ginkgoNNNN, which would put every spec's cache in one shared
directory and break the specs that count files in it. tempModelsDir()
restores the original isolation.

Also make the deliberate cleanup-path ignores explicit with `_ =`, drop the
gallery cache directory to 0750 (nothing outside the server's own user and
group reads it), and justify the cache read with a #nosec G304 comment in
the form already used elsewhere in the tree: the path is a hex sha256 under
a fixed directory with a non-absolute base already rejected, so no
caller-supplied text reaches it.

Re-ran the mutations these specs were verified against — dropping the
platform suffix from UserAgent, making Gallery.Equal ignore Mirrors and
ignore Name, removing persistGalleryIndex's validity probe, removing the
!filepath.IsAbs guard, not skipping a cooled-down candidate, and dropping
the per-attempt timeout. All seven still fail the converted specs.

Assisted-by: Claude:claude-opus-5 [go vet] [go test] [golangci-lint] [gosec]

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 17:56:36 +02:00
1027 changed files with 113701 additions and 13258 deletions

No files matched your search

+34
View File
@@ -49,6 +49,40 @@ AI agents MUST NOT add `Co-Authored-By` trailers for themselves either.
A human reviewer owns the contribution; the AI's involvement is recorded
via `Assisted-by` (see below).
### Exception: automation operated by a maintainer
The rule above addresses the common case, an AI assistant helping a human
contributor who then signs off. It does not fit automation that a
maintainer runs themselves, which opens pull requests with no human
submitter to sign. Applied literally there, nothing ever signs and the
DCO check blocks the pull request permanently.
A maintainer-operated bot MUST therefore add a `Signed-off-by` trailer
naming **the maintainer who operates it**, not the bot and not the model:
```
Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
```
This is not the AI certifying the DCO. The maintainer is, exactly as they
do for a commit they typed by hand: they configured the automation, they
own its output, and they take responsibility for it when they merge it.
The `Assisted-by` trailer still records that a model produced the code, so
the provenance trail is unchanged.
The exception is narrow and does not widen the rule for anyone else:
- It applies only to automation a LocalAI maintainer operates and whose
output that maintainer reviews before merge.
- The sign-off names a real person who accepts DCO responsibility.
- An AI assistant helping an outside contributor still MUST NOT sign off.
That contributor adds their own trailer.
- A bot MUST NOT sign off on behalf of anyone other than its operator, and
MUST NOT add a trailer for a contributor whose branch it pushes to. If
automation contributes to someone else's branch, it leaves the sign-off
to that contributor.
## Attribution
When AI tools contribute to LocalAI development, proper attribution helps
+52
View File
@@ -236,6 +236,58 @@ Use these HTTP status codes:
If your endpoint should be tracked for usage (token counts, request counts), add the `usageMiddleware` to its middleware chain. See `core/http/middleware/usage.go` and how it's applied in `routes/openai.go`.
## Control-plane database health metrics
In distributed mode the frontend registers three OpenTelemetry gauges over the
PostgreSQL control-plane database (`core/services/monitoring/control_plane_db.go`,
wired in `core/application/distributed.go`). They reach `/metrics` through the
same Prometheus exporter as the rest of the API metrics.
| Metric | Meaning | Page when |
|--------|---------|-----------|
| `localai_control_plane_oldest_xmin_age` | Transactions elapsed since the oldest snapshot any backend still holds | above a few million, and rising |
| `localai_control_plane_longest_transaction_seconds` | Age of the longest open transaction | above 3600 |
| `localai_control_plane_dead_tuple_ratio` | Dead tuples per live tuple, labelled by `table`, on `backend_nodes`, `node_models` and `gallery_operations` | sustained above ~10 on a small table |
A sustained high `localai_control_plane_oldest_xmin_age` is the one to page on.
While it grows, autovacuum can reclaim nothing anywhere in the database no
matter how often it runs, so the dead tuple ratio keeps climbing and a six-row
registry table can reach hundreds of megabytes. Tuning autovacuum does not help.
The fix is to find the transaction holding the horizon open and clear it:
```sql
SELECT pid, state, age(backend_xmin) AS xmin_age, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;
```
Then `pg_terminate_backend(pid)` on the offenders, and `VACUUM (VERBOSE)` the
bloated tables once the horizon has moved.
**A healthy-looking xmin age does not on its own prove the horizon is free.**
The gauge reads `pg_stat_activity`, which only sees live backends. Two other
things pin the very same horizon and are invisible there, so either one can hold
vacuum back while the gauge reads 0:
```sql
SELECT gid, prepared, database, transaction FROM pg_prepared_xacts;
SELECT slot_name, active, xmin, catalog_xmin FROM pg_replication_slots;
```
An orphaned prepared transaction is cleared with `ROLLBACK PREPARED '<gid>'`,
and a stale slot with `pg_drop_replication_slot('<slot_name>')`. Check both
before concluding that a bloated table has some other cause.
Sampling is scrape-driven behind a 30 second cache, so scrape frequency does not
translate into database load. Failed and timed-out samples cost the same interval
as successful ones, so a database that is already struggling is not retried on
every scrape. A failed sample reports the last good values rather than failing the
scrape, because these gauges matter most when the database is struggling. Before
the first successful sample the gauges are absent rather than zero, since a zero
xmin age would read as a healthy horizon: alert on `absent()` too if you need to
distinguish "healthy" from "never sampled".
## Advertising surfaces — where to register a new capability
Beyond routing and auth, LocalAI publishes its capability surface in **four independent places**. When you add an endpoint — especially one introducing a net-new capability like a new media type or a new auth-gated feature — you must update every relevant surface. These aren't optional: missing them means the endpoint works but is invisible to clients, admins, and the UI.
+3 -1
View File
@@ -67,7 +67,9 @@ entry (`backend/index.yaml`):
```yaml
- name: localai
url: github:mudler/LocalAI/backend/index.yaml@master
url: https://index.localai.io/backends
mirrors:
- github:mudler/LocalAI/backend/index.yaml@master
verification:
issuer: "https://token.actions.githubusercontent.com"
identity_regex: "^https://github\\.com/mudler/LocalAI/\\.github/workflows/backend_merge\\.yml@refs/(heads/master|tags/.+)$"
+23
View File
@@ -45,3 +45,26 @@ Rules (both gates):
- **Don't weaken the gate:** never hand-lower a baseline or widen a tolerance to turn a red gate green. The ratchet only moves up.
- If a change drops coverage, **add tests** (sort `coverage-summary.json` by line% ascending to find untested code) rather than editing the baseline. When coverage legitimately rises, commit the regenerated baseline (`make test-coverage-baseline` / `test-ui-coverage-baseline`).
- The Go gate is **strict — no tolerance**; `covermode=atomic` keeps it deterministic. The UI gate keeps a small tolerance only because its e2e coverage isn't.
## Distributed-mode test suites
Two suites cover distributed mode (frontend replicas, worker nodes, PostgreSQL), split by a Ginkgo label. Neither starts a message broker: a distributed deployment needs PostgreSQL and the frontends' own HTTP listener, and the `nats-io` modules are not in the build.
- `make test-e2e-distributed` runs `Distributed && !VLLMMultinode && !Cluster` over `./tests/e2e/distributed` recursively. Services are wired directly into the test binary. 231 specs in about 2m20s (measured 133.3s for the main suite plus the 8 argument-validation specs in the subpackage).
- `make test-e2e-cluster` runs `Cluster` and spawns real `local-ai` child processes through the `tests/e2e/distributed/cluster` helper package. It **builds `./local-ai` itself** (the `e2e-binary` target), so you do not run `make build` first and cannot forget to. 26 specs, 933.8s of specs in a 15m37s target run (four measurements; the suite grew from 6 specs and roughly 509s, then 21 specs and 802.5s, then 24 specs and 897 to 907s, so budget on the newest number).
Both jobs live in `.github/workflows/tests-e2e-distributed.yml`, with `timeout-minutes: 45` each. They trigger on pull requests *and* on every push to `master`; the `paths-ignore` filter (see [.agents/ci-caching.md](ci-caching.md)) sits on the pull-request trigger only, so a master push always runs both. They are advisory only because `master` carries no branch protection, which is a repository setting and not a YAML key: `continue-on-error: true` would flip the run's *conclusion* to success and hide the failure, so it is not used.
- **The container is suite-scoped, not spec-scoped.** `SetupInfra` used to start a PostgreSQL (~10s) and a broker (~3.5s) per spec. Across the 213 specs behind it that was roughly **48 minutes of pure container startup per run**, which is why this suite was never in CI. (213 rather than the ~240 above: the larger number is everything the label filter selects, the smaller one is just the specs that call `SetupInfra`.) The broker container is gone entirely, and the PostgreSQL one now starts once in `BeforeSuite` with each spec getting its own database via `CREATE DATABASE` (~67ms), which is what the `dbName` argument was always describing. Adding a spec needs no change: call `SetupInfra("some-name")` as before, the name is a prefix and a counter keeps it unique.
- **Consequence for new specs:** a fan-out carrier is *shared* within a Ginkgo process, so a wildcard subscriber can observe another spec's traffic. Filter assertions on an identifier your spec owns (a node ID, a job ID) instead of counting everything on `jobs.*.progress`, and verify the spec with `--randomize-all`.
- **`BeforeSuite`, not `SynchronizedBeforeSuite`.** Under `ginkgo -p` each process then gets its own container, keeping each process's carrier isolated. A single shared database across parallel processes would let specs on different processes see each other's messages on the same subject.
- **The label split.** The 8 argument-validation specs under `tests/e2e/distributed/cluster/` carry `Label("Distributed")` only, on purpose: they need no binary and no PostgreSQL, so they belong in the fast job. That is why `test-e2e-distributed` keeps `-r` (it must reach the subpackage) and `test-e2e-cluster` deliberately does **not** (the subpackage is out of its scope).
- **The binary is built, and a stale one is refused.** `test-e2e-cluster` used to take `./local-ai` as given and only check that a file by that name existed. An edit that was never rebuilt therefore ran the entire suite against an older build while reporting on the working tree, which surfaced once as a spec failing on caller line numbers that matched no line in any source file. Two things close it. The target depends on `e2e-binary`, which is `go build -o local-ai ./cmd/local-ai` and not `make build` (that one also builds the React UI, which this suite never touches and CI stubs). And `localAIBinary` in the harness **fails**, on a developer box as well as under CI, when the binary is older than the newest non-test Go source in the tree, which covers a run started with `LOCALAI_E2E_BINARY` or by invoking ginkgo directly. `_test.go` files are excluded from that scan on purpose: they compile into the ginkgo suite and never into `local-ai`, so counting them would fire on every edit to the spec being run. Set `LOCALAI_E2E_BINARY` to opt out of the build; there is no opt-out of the staleness check, because a rebuild is the remedy.
- **`--fail-on-empty` is load-bearing on both targets.** Ginkgo exits 0 when a label filter selects nothing, so without it a refactor that renames or drops `Label("Cluster")` leaves the target reporting "Test Suite Passed" having started no cluster at all. `LOCALAI_E2E_REQUIRE_BINARIES` does not cover this case: it only fires inside a spec that is actually running.
- **The binary gate.** `localAIBinary()` and `mockBackendBinary()` **fail** rather than skip when `CI` is set, or when `LOCALAI_E2E_REQUIRE_BINARIES` is truthy; `LOCALAI_E2E_REQUIRE_BINARIES=0` (also `off`, `no`, `n`, `disabled`, and anything `strconv.ParseBool` reads as false) forces skipping even under CI. **Any value that parses as neither reads as ON**, not off: setting the variable to something meaningless means someone meant to turn the gate on, and reading it as false would quietly restore the silent skip the flag exists to remove. The whole polarity is deliberate, because in CI a skipped cluster spec is indistinguishable from a passing one: Ginkgo exits 0 on skips. Locally a missing binary still just skips, since `CI` is unset in an ordinary shell.
- **Flake budget: no retries at all.** `--flake-attempts` is *total attempts*, not retries (ginkgo v2.29.0 `internal/group.go` sets `maxAttempts = FlakeAttempts` and loops `attempt < maxAttempts`; the flag's own usage string reads "0 - failed tests are not retried"). `DISTRIBUTED_TEST_FLAKES` defaults to **1**, so each spec runs once and a failure is a failure, and `test-e2e-cluster` pins `--flake-attempts 1` outright rather than reading the variable. The repo-wide `TEST_FLAKES=5` means up to five attempts, so up to four retries. These suites exist to surface nondeterminism, and a retry converts exactly that signal into a green run. Raise it locally when bisecting something unrelated, not in the Makefile.
- **Coverage:** `tests/e2e/distributed` is excluded from the coverage roots (`COVERAGE_E2E_ROOTS = ./tests/e2e`, run non-recursively), and so is the `cluster` helper package beneath it. Neither suite moves the baseline, so production code that these suites are the only cover for reads as **uncovered**. Unit tests for such code belong under `./core/...` with `testutil.SetupTestDB()`.
- **The cluster job builds against a stubbed React UI.** `core/http/react-ui/dist` is gitignored and built by Node, so the workflow writes a one-line `index.html` there to satisfy the `//go:embed react-ui/dist/*` in `core/http/app.go` and skips a full Node and Vite install. That holds only while the suite drives the HTTP API and never the UI, which has its own e2e suite. A spec that ever asserts on a UI asset would pass locally, where a real `dist/` exists, and be served the stub in CI: if you write one, the stub step has to go and the real build come back.
- **The topology that matters is two frontends and two workers.** `cluster_busless_test.go` runs the whole programme on it, and it is the only shape in which the OWNER path and the RELAY path are live at the same time: with the two workers' tunnels landing on different replicas, every request either goes to the replica holding that worker's tunnel or is relayed through the one that does. A one-worker cluster cannot tell those apart, and cannot state that killing a replica re-homes only ITS worker. Which worker served a request is read back from `node_models` rather than assumed, and the two models are pinned to one worker each through the admin API (`PUT /api/nodes/:id/labels` plus `POST /api/nodes/scheduling`), so a selector that failed open reddens the spec instead of hiding in it.
- **The fan-out carrier is proven under real binaries by `cluster_fanout_test.go`.** Both specs run two frontends and no workers against one PostgreSQL, publish at frontend 0 and read at frontend 1, and cost 7.0s together. They drive a gallery operation on purpose: `GET /models/jobs/<id>` answers from `galleryop`'s in-memory statuses map, which on a peer replica is filled by the `gallery.*.progress` subscriber and by nothing else (`Hydrate` runs once at startup, long before these operations exist), so a peer that answers was told over the carrier. Every other broadcast family has a durable table behind it that a peer would converge through anyway, and a spec on one of those cannot separate "the broadcast arrived" from "the row was read". The specs make that checkable rather than argued: the `gallery_operations` row is written when the gallery worker DEQUEUES an operation, so an operation still queued has none, and both specs assert zero rows while the peer is already answering with the operation's own bytes. Holding the queue is what `cluster.Options.Galleries` is for: it points every frontend at one gated index and turns the startup estimate warmer off, so the operation the spec submits is parked inside a fetch only the spec can complete. The second spec adds the oversized path: an element name of 9000 bytes puts the encoded notification past PostgreSQL's 8000-byte cap (exclusive), so the broadcast becomes a `bus_messages` row plus a notification carrying its id, and the spec asserts the row exists, decodes it, and compares its element name byte for byte with what frontend 1 answers. A one-byte control operation in the same run is asserted to leave NO row, which is what keeps "a row exists" a statement about size rather than one that is true of every operation.
- **Do not shorten the cluster suite's waits.** Three of its six specs sit at ~167s each because they wait out a 60s staleness threshold plus a 15s health-check tick. That wait is what stops the assertions from passing before the system could have reacted, which was a real false green earlier on. If the job has to get faster, the levers are CI concurrency or making the thresholds configurable, not shorter waits.
+3 -3
View File
@@ -153,7 +153,7 @@ This is worth more than it looks. Measured over the week to 2026-07-30, **97% of
The volume is real: 13 gallery-only PRs merged that week with 10 open at once, and 78 of the 137 PRs opened were bot-generated.
`paths-ignore` on the PR trigger of `image-pr.yml` (7 jobs), `build-test.yaml` (3), `lint.yml` (2) and `tests-e2e.yml` (1) drops 13 of those 20. The excluded set:
`paths-ignore` on the PR trigger of `image-pr.yml` (7 jobs), `build-test.yaml` (3), `lint.yml` (2) and `tests-e2e.yml` (1) drops 13 of those 20, measured before `tests-e2e-distributed.yml` (2 jobs) landed. That workflow carries the same exclusion set for the same reason: its dependency graph is 99 packages, so an allowlist of paths would silently stop guarding the moment code moved, while a diff confined to the paths below provably cannot reach it. The excluded set:
| Path | Why no image or Go build can see it |
|---|---|
@@ -192,13 +192,13 @@ What still runs, and why it has to:
Two properties this relies on:
- `paths-ignore` skips a run only when **every** changed file matches, so a PR touching the gallery *and* Go code still runs everything. That is what makes the exclusion safe rather than a hole.
- `master` carries no branch protection and no rulesets, so a skipped workflow reports no status and nothing waits on it. If required status checks are ever introduced, these four entries must be excluded from the required set or PRs will hang on "Expected — Waiting for status to be reported".
- `master` carries no branch protection and no rulesets, so a skipped workflow reports no status and nothing waits on it. If required status checks are ever introduced, these five entries must be excluded from the required set or PRs will hang on "Expected — Waiting for status to be reported".
### `image.yml` on master push is gated too, by a job rather than a path filter
The same reasoning applies to master pushes, and the volume is larger there: on 2026-07-30, **12 of the 23 queued `image.yml` runs** were commits like "add 1 new model to gallery" or a docs fix, each rebuilding all 18 container images.
`image.yml` now has a `changes` job that decides once whether the push can affect any image; the other 11 jobs carry `needs: changes` plus an `if:` on its output. Verified against the shipped `Dockerfile`: the final stage copies only `entrypoint.sh`, `healthcheck.sh` and the `local-ai` binary, there is no `go:embed` of `gallery/` or `docs/`, and the gallery is fetched at runtime from `github:mudler/LocalAI/gallery/index.yaml@master`. A gallery-only commit therefore produces byte-identical images, and the gallery change reaches users through GitHub immediately whether or not an image is rebuilt.
`image.yml` now has a `changes` job that decides once whether the push can affect any image; the other 11 jobs carry `needs: changes` plus an `if:` on its output. Verified against the shipped `Dockerfile`: the final stage copies only `entrypoint.sh`, `healthcheck.sh` and the `local-ai` binary, there is no `go:embed` of `gallery/` or `docs/`, and the gallery is fetched at runtime from `https://index.localai.io/models` (a caching mirror of `gallery/index.yaml` on master, with `github:mudler/LocalAI/gallery/index.yaml@master` as the fallback mirror). A gallery-only commit therefore produces byte-identical images, and the gallery change reaches users over the network immediately whether or not an image is rebuilt.
Two properties to preserve if you touch it:
+50
View File
@@ -77,6 +77,56 @@ spectrum. **Metal (Darwin) only** - it is a no-op on CUDA/CPU. Enable with
budget). Gallery entries built on this: `deepseek-v4-flash-q4-ssd` (153 GB Flash
on a 128 GB Mac) and `deepseek-v4-pro-q2-ssd` (433 GB Pro, experimental).
## CUDA architecture (do not build without one)
`backend/cpp/ds4/Makefile` drives upstream's **object targets** directly
(`$(MAKE) -C ds4 ds4.o ds4_cuda.o ...`), which bypasses upstream's own guard:
its `cuda` target refuses to build unless `CUDA_ARCH` is set, and offers
`cuda-spark` (sm_121, DGX Spark / GB10) and `cuda-generic` (native) instead.
Built with no `-arch`, nvcc targets its default architecture and the kernels run
as JIT'd PTX. On GB10 that silently corrupted every prefill batch of >=128
tokens - the model emitted text unrelated to the prompt and never closed its
thinking block, so `content` came back empty - and cost close to two orders of
magnitude of prefill throughput (4.21 t/s vs 325.70 t/s, same box, same model).
Short prompts stayed correct, which is why it went unnoticed.
The Makefile therefore picks a gencode list from `CUDA_MAJOR_VERSION` (a build
arg the backend matrix already declares, forwarded by `Dockerfile.ds4`) and
`uname -m`, and passes it as `NVCC_ARCH_FLAGS` to the sub-make. Upstream's
`CUDA_ARCH` accepts a single value, so it cannot express the fat binary the
shipped images need; a command-line assignment beats its `:=`. An empty
`CUDA_MAJOR_VERSION` falls back to upstream's `native` for local developer
builds, and an unrecognised one is a hard error - no CI runner has a GPU, so a
silent `native` there is exactly the failure mode this guards against.
`DS4_CUDA_HAVE_MXF4` is deliberately unset: upstream defines it only for
single-arch sm_120/sm_121 builds and guards it with a plain `#ifdef` rather than
`__CUDA_ARCH__`, so it cannot be combined with older archs. It gates an optional
MXFP4 indexer fast path whose `#ifndef` branch returns 0, so omitting it costs
speed, not correctness.
### Verifying a build
Check which flags a configuration resolves to, without compiling anything:
```
make -C backend/cpp/ds4 BUILD_TYPE=cublas CUDA_MAJOR_VERSION=13 NATIVE=false \
--eval='show: ; @echo [$(DS4_ARCH_MAKEVARS)]' show
```
Do not use `make -n` for this: the recipe is `+$(MAKE) ...`, and the `+` prefix
makes it run even under `-n`.
Then exercise the failure mode itself against a built backend. It only appears
above one prefill batch, so the ordinary `predict` spec cannot catch it:
```
BACKEND_BINARY=$(pwd)/backend/cpp/ds4/package/run.sh \
BACKEND_TEST_MODEL_FILE=/path/to/ds4flash.gguf \
BACKEND_TEST_CAPS=health,load,predict,long_prefill \
go test -count=1 -timeout=30m -v ./tests/e2e-backends/...
```
## Build matrix
| Build | Where | Notes |
+1 -1
View File
@@ -94,4 +94,4 @@ The user chose KISS. Every mutating tool has a safety rule (`prompts/10_safety.m
## Distributed mode
The in-memory MCP server runs only on the head node (where the chat handler runs). `inproc.Client` wraps services that are already distributed-aware (`GalleryService` coordinates with workers; `ListNodes` reads the NATS-populated registry). No NATS routing of MCP tools — the admin surface lives on the head, period.
The in-memory MCP server runs only on the head node (where the chat handler runs). `inproc.Client` wraps services that are already distributed-aware (`GalleryService` coordinates with workers; `ListNodes` reads the PostgreSQL-backed node registry). MCP tools are not routed anywhere: the admin surface lives on the head, period.
+2
View File
@@ -59,7 +59,9 @@ backend/rust/*/target
backend-images
local-backends
local-ai
.claude
.crush
.tools
protoc
tests
+1 -1
View File
@@ -5,7 +5,7 @@ This PR fixes #
**Notes for Reviewers**
**[Signed commits](../CONTRIBUTING.md#signing-off-on-commits-developer-certificate-of-origin)**
**[Signed commits](../CONTRIBUTING.md#commit-messages)**
- [ ] Yes, I signed my commits.
- [ ] Documentation updated (docs/content/) for user-facing changes, or not applicable
+142 -1
View File
@@ -860,6 +860,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-nemo-speech-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "nemo-speech-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1911,6 +1924,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-nemo-speech-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "nemo-speech-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1963,6 +1989,24 @@ include:
backend: "parakeet-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
# The CUDA-13 counterpart to the JetPack r36.4.0 row in the nemo-speech-cpp
# block below. A Jetson whose CUDA 13 runtime is present reports the
# nvidia-l4t-cuda-13 capability, and pointing that key at the JetPack image
# would hand it a ggml linked against CUDA 12 whose libcudart.so.12 is not
# there to dlopen. Same base and runner as the parakeet-cpp row above.
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-nemo-speech-cpp'
base-image: "ubuntu:24.04"
ubuntu-version: '2404'
runs-on: 'ubuntu-24.04-arm'
backend: "nemo-speech-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -2540,7 +2584,7 @@ include:
tag-latest: 'auto'
tag-suffix: '-gpu-intel-vllm'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
base-image: "intel/oneapi-basekit:2025.3.2-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "vllm"
dockerfile: "./backend/Dockerfile.python"
@@ -3710,6 +3754,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-stablediffusion-ggml'
runs-on: 'ubuntu-latest'
base-image: "rocm/dev-ubuntu-24.04:7.2.1"
skip-drivers: 'false'
backend: "stablediffusion-ggml"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
@@ -4183,6 +4240,86 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# nemo-speech-cpp
#
# No hipblas and no sycl rows, unlike the parakeet-cpp block above: upstream
# NeMo-Speech.cpp builds ggml with CUDA, Vulkan or Metal only, so a ROCm or
# SYCL image would be a CPU build wearing a GPU tag.
#
# cpu and vulkan are per-arch pairs sharing one tag-suffix, so
# backend-merge-jobs assembles a multi-arch manifest from the two digests.
# The arm64 legs are not redundant with the Jetson image below: an ARM server
# with no NVIDIA GPU reports the "default" capability and would otherwise pull
# an amd64-only manifest.
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-nemo-speech-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "nemo-speech-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-nemo-speech-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "nemo-speech-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-nemo-speech-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "nemo-speech-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-nemo-speech-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "nemo-speech-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-arm64-nemo-speech-cpp'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "nemo-speech-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
# moss-transcribe-cpp
- build-type: ''
cuda-major-version: ""
@@ -6226,6 +6363,10 @@ includeDarwin:
tag-suffix: "-metal-darwin-arm64-moss-transcribe-cpp"
build-type: "metal"
lang: "go"
- backend: "nemo-speech-cpp"
tag-suffix: "-metal-darwin-arm64-nemo-speech-cpp"
build-type: "metal"
lang: "go"
- backend: "ced"
tag-suffix: "-metal-darwin-arm64-ced"
build-type: "metal"
+15 -10
View File
@@ -3,9 +3,9 @@
# darwin (Apple Silicon) install path. The macOS/Metal build
# (backend/python/vllm/install.sh, Darwin branch) installs vllm-metal, which is
# version-locked to a specific vLLM source release. install.sh derives that vLLM
# version at build time from vllm-metal's own installer (`vllm_v=`) at the pinned
# tag, so there is only ONE value to bump here -- mirroring bump_vllm_wheel.sh,
# which bumps the Linux cu130 wheel pin.
# version, and the wheel asset name, at build time from the pinned tag, so there
# is only ONE value to bump here -- mirroring bump_vllm_wheel.sh, which bumps the
# Linux cu130 wheel pin.
#
# This deliberately tracks vllm-project/vllm-metal, NOT vllm-project/vllm: the
# darwin build can only use the exact vLLM version vllm-metal supports, so it may
@@ -23,19 +23,24 @@ if [ -z "$FILE" ] || [ -z "$REPO" ] || [ -z "$VAR" ]; then
exit 1
fi
# vllm-metal ships frequent dev releases, all flagged as non-prerelease, so
# /releases/latest returns the newest one (with its cp312 wheel asset).
# vllm-metal ships frequent .dev releases, flagged as prereleases, alongside the
# stable ones. /releases/latest skips the prereleases and returns the newest
# stable tag, which is what darwin should pin: upstream deletes and re-cuts .dev
# tags, and a pin to a deleted tag 404s the whole build.
LATEST_TAG=$(gh_curl -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/releases/latest" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])")
# The coupled vLLM source version lives in vllm-metal's installer at that tag.
NEW_VLLM_VERSION=$(gh_curl \
"https://raw.githubusercontent.com/$REPO/$LATEST_TAG/install.sh" \
| grep -oE 'vllm_v="[0-9]+\.[0-9]+\.[0-9]+"' | head -1 | cut -d'"' -f2)
# The coupled vLLM release lives in .github/vllm-release-tag.commit at that tag
# (since vllm-metal 0.28); releases predating that file pinned it inline in their
# own install.sh. The extractor reads both forms.
NEW_VLLM_VERSION=$( { gh_curl \
"https://raw.githubusercontent.com/$REPO/$LATEST_TAG/.github/vllm-release-tag.commit" \
|| gh_curl "https://raw.githubusercontent.com/$REPO/$LATEST_TAG/install.sh"; } \
| "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib/extract-vllm-metal-version.sh")
if [ -z "$LATEST_TAG" ] || [ -z "$NEW_VLLM_VERSION" ]; then
echo "Could not resolve vllm-metal tag ($LATEST_TAG) or its vllm_v ($NEW_VLLM_VERSION)." >&2
echo "Could not resolve vllm-metal tag ($LATEST_TAG) or its vLLM version ($NEW_VLLM_VERSION)." >&2
exit 1
fi
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
home = Path("website/layouts/index.html").read_text()
css = Path("website/static/css/site.css").read_text()
install = Path("docs/content/getting-started/install.md").read_text()
containers = Path("docs/content/getting-started/containers.md").read_text()
def require(condition, message):
if not condition:
raise SystemExit(f"FAIL: {message}")
require("Drop-in replacement for most upstream APIs." in home,
"homepage must use the requested drop-in API heading")
require("Everything else plugs into LocalAI." not in home,
"old runtime heading must be removed")
require("When the engine we need" not in home,
"hero must describe user outcomes instead of team implementation")
require('href="mailto:contact@localai.io"' in home and "business" in home.lower(),
"homepage must provide a direct business contact action")
require(home.index('id="localai"') < home.index('id="proof-quotes"') < home.index('id="mission"'),
"headline testimonials must directly follow the runtime section")
require(home.count('id="proof-quotes"') == 1,
"headline testimonials must appear exactly once")
require('id="engines"' not in home and "Engines we build" not in home,
"homepage engine showcase must be removed")
require('href="/docs/installation/index.html"' in home,
"installation guide action must use the direct installation URL")
require('<iframe' in install and "youtube.com/embed/cMVNnlqwfw4" in install,
"installation page must embed the walkthrough video")
require("## Quick Start" not in install,
"installation landing page must not duplicate Quick Start")
for text in ("CUDA 12", "CUDA 13", "ROCm", "Intel", "Jetson", "Vulkan", "fallback"):
require(text.lower() in containers.lower(), f"GPU chooser must explain {text}")
require('class="sn__e"><a href="https://github.com/mudler/parakeet.cpp">parakeet.cpp</a>' in home,
"capability engine names must link to their repositories")
require(".pane{min-height:" in css.replace(" ", ""),
"all installation panes must have a fixed minimum height")
print("website review 143 source checks passed")
PY
+1 -65
View File
@@ -29,10 +29,6 @@ updates:
schedule:
# Check for updates to GitHub Actions every weekday
interval: "weekly"
- package-ecosystem: "pip"
directory: "/backend/python/bark"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/backend/python/common/template"
schedule:
@@ -55,30 +51,10 @@ updates:
ignore:
- dependency-name: "torch"
- dependency-name: "transformers"
- package-ecosystem: "pip"
directory: "/backend/python/exllama"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/backend/python/exllama2"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/backend/python/mamba"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/backend/python/openvoice"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/backend/python/rerankers"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/backend/python/sentencetransformers"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/backend/python/transformers"
schedule:
@@ -86,44 +62,4 @@ updates:
- package-ecosystem: "pip"
directory: "/backend/python/vllm"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/examples/chainlit"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/examples/functions"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/examples/langchain/langchainpy-localai-example"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/examples/langchain-chroma"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/examples/streamlit-bot"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/examples/k8sgpt"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/examples/kubernetes"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/examples/langchain"
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
directory: "/examples/semantic-todo"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/examples/telegram-bot"
schedule:
interval: "weekly"
interval: "weekly"
+2 -4
View File
@@ -71,8 +71,8 @@ jobs:
# cosign signs each pushed manifest list with --recursive so the
# index and every per-arch entry get an attached Sigstore bundle.
# The pinned cosign v2 release needs --new-bundle-format explicitly;
# the verifier only consumes OCI 1.1 Sigstore bundle referrers.
# Cosign v2.4.1 emits the current bundle format by default; the
# verifier discovers those bundles through OCI 1.1 referrers.
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3
@@ -159,7 +159,6 @@ jobs:
# manifest before checking signatures need the per-arch
# signatures, not just the list-level one.
cosign sign --yes --recursive \
--new-bundle-format \
--registry-referrers-mode=oci-1-1 \
"quay.io/go-skynet/local-ai-backends@${digest}"
@@ -186,7 +185,6 @@ jobs:
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')
cosign sign --yes --recursive \
--new-bundle-format \
--registry-referrers-mode=oci-1-1 \
"localai/localai-backends@${digest}"
+7 -3
View File
@@ -62,6 +62,10 @@ jobs:
variable: "MOSS_VERSION"
branch: "master"
file: "backend/go/moss-transcribe-cpp/Makefile"
- repository: "NVIDIA/NeMo-Speech.cpp"
variable: "NEMO_SPEECH_VERSION"
branch: "main"
file: "backend/go/nemo-speech-cpp/Makefile"
- repository: "localai-org/ced.cpp"
variable: "CED_VERSION"
branch: "main"
@@ -162,7 +166,7 @@ jobs:
push-to-fork: ci-forks/LocalAI
commit-message: ':arrow_up: Update ${{ matrix.repository }}'
title: 'chore: :arrow_up: Update ${{ matrix.repository }} to `${{ steps.bump.outputs.commit }}`'
branch: "update/${{ matrix.variable }}"
branch: "bump/${{ matrix.variable }}"
body: ${{ steps.bump.outputs.message }}
signoff: true
@@ -199,7 +203,7 @@ jobs:
push-to-fork: ci-forks/LocalAI
commit-message: ':arrow_up: Update vllm-project/vllm cu130 wheel'
title: 'chore: :arrow_up: Update vllm-project/vllm cu130 wheel to `${{ steps.bump.outputs.commit }}`'
branch: "update/VLLM_VERSION"
branch: "bump/VLLM_VERSION"
body: ${{ steps.bump.outputs.message }}
signoff: true
@@ -237,6 +241,6 @@ jobs:
push-to-fork: ci-forks/LocalAI
commit-message: ':arrow_up: Update vllm-project/vllm-metal (darwin)'
title: 'chore: :arrow_up: Update vllm-metal (darwin) to `${{ steps.bump.outputs.commit }}`'
branch: "update/VLLM_METAL_VERSION"
branch: "bump/VLLM_METAL_VERSION"
body: ${{ steps.bump.outputs.message }}
signoff: true
+4 -3
View File
@@ -31,13 +31,14 @@ jobs:
messages: [
{
role: "system",
content: "Write a discord message with a bullet point summary of the release notes."
content: "Write a Discord message with a bullet point summary of the release notes. Keep the complete message under 1800 characters."
},
{
role: "user",
content: $input
}
]
],
max_tokens: 450
}')
# Send the request to LocalAI API
@@ -46,7 +47,7 @@ jobs:
-d "$json_payload")
# Extract the summary from the response
summary=$(echo $response | jq -r '.choices[0].message.content')
summary=$(printf '%s' "$response" | jq -er '.choices[0].message.content | strings | .[0:1800]')
# Print the summary
# -H "Authorization: Bearer $API_KEY" \
+21 -10
View File
@@ -14,6 +14,7 @@ on:
permissions:
contents: write
pull-requests: write
concurrency:
group: refresh-site-counters
@@ -23,22 +24,32 @@ jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Read the counts off the GitHub API
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/ci/refresh-site-counters.sh
- name: Commit only if something moved
- name: Show changes
run: |
if git diff --quiet -- website/data/stats.yaml; then
echo "counters unchanged, nothing to commit"
exit 0
echo "counters unchanged"
else
git diff --unified=0 -- website/data/stats.yaml
fi
git diff --unified=0 -- website/data/stats.yaml
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add website/data/stats.yaml
git commit -m "chore(website): refresh the counters"
git push
- name: Create pull request when counters moved
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.UPDATE_BOT_TOKEN }}
push-to-fork: ci-forks/LocalAI
commit-message: "chore(website): refresh the counters"
title: "chore(website): refresh the counters"
body: |
Weekly refresh of the landing-page counters from the GitHub API.
This PR was created automatically by the `refresh-site-counters` workflow.
branch: update/site-counters
delete-branch: true
labels: automated
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
if: github.repository == 'mudler/LocalAI'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v9
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v9
with:
stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
stale-pr-message: 'This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 10 days.'
+53
View File
@@ -50,6 +50,7 @@ jobs:
sherpa-onnx: ${{ steps.detect.outputs.sherpa-onnx }}
whisper: ${{ steps.detect.outputs.whisper }}
parakeet-cpp: ${{ steps.detect.outputs.parakeet-cpp }}
nemo-speech-cpp: ${{ steps.detect.outputs.nemo-speech-cpp }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -525,6 +526,7 @@ jobs:
- name: Build llama-cpp backend image and run gRPC e2e tests
run: |
make test-extra-backend-llama-cpp
make test-extra-backend-llama-cpp-embeddings
tests-llama-cpp-grpc-transcription:
needs: detect-changes
if: needs.detect-changes.outputs.llama-cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
@@ -900,6 +902,57 @@ jobs:
- name: Test magpie-tts-cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/magpie-tts-cpp test
# Per-backend unit suite for nemo-speech-cpp. This job exists for one reason
# above all: abi_test.go asserts the size and field offsets of every Go mirror
# struct against the C ABI it is dlopened into. Those assertions are the only
# thing standing between a purego symbol rename or an upstream header change
# and silent memory corruption at run time, and they are worthless unless
# something executes them. `make -C backend/go/nemo-speech-cpp test` sets
# NEMO_SPEECH_REQUIRE_LIBS=1, which turns "library missing" from a skip into a
# failure, so this job cannot report green having checked nothing.
#
# The backend Makefile's `test` target depends on `stage-libs`, so it clones
# upstream at the pinned SHA and builds the native runtime itself. There is no
# separate build step for that reason, and no model download: the specs are
# ABI and pure-Go only.
#
# WITH_NORM=OFF skips the Sparrowhawk/OpenFST inverse-text-normalization
# stack, which is the single most expensive leg of the build and needs a gcc-12
# pin because OpenFST's templates ICE on gcc-13/14. It costs no coverage here:
# nothing in include/nemo_speech/{asr,tts,diar,nmt}.h is conditional on it (the
# only preprocessor conditionals in those headers are include guards,
# __cplusplus and the _WIN32 export macros), so every struct layout this suite
# checks is identical either way. The shipped images still build WITH_NORM=ON;
# that path is covered by the backend image build in backend_pr.yml.
tests-nemo-speech-cpp:
needs: detect-changes
if: needs.detect-changes.outputs.nemo-speech-cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Clone
uses: actions/checkout@v7
with:
submodules: true
- name: Dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake ninja-build curl libopenblas-dev ffmpeg
- name: Setup Go
uses: actions/setup-go@v5
- name: Display Go version
run: go version
- name: Proto Dependencies
run: |
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- name: Test nemo-speech-cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/nemo-speech-cpp WITH_NORM=OFF test
# Per-backend smoke for rfdetr-cpp: builds the .so + Go binary and runs
# `make -C backend/go/rfdetr-cpp test`. test.sh fetches the small (~20 MB)
# rfdetr-nano-q8_0 GGUF from the published mudler/rfdetr-cpp-nano HF repo
+18 -2
View File
@@ -65,6 +65,12 @@ jobs:
- name: Test (with coverage gate)
run: |
PATH="$PATH:/root/go/bin" make --jobs 5 --output-sync=target test-coverage-check
# tests/integration is outside the coverage roots because its store specs
# need a live backend. test-stores builds and installs local-store before
# running the complete suite, so new local-store specs are collected
# automatically without adding another workflow entry.
- name: Test local-store integration
run: PATH="$PATH:$HOME/go/bin" make test-stores
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v4
@@ -74,8 +80,13 @@ jobs:
coverage/coverage.out
coverage/coverage.html
if-no-files-found: ignore
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
@@ -119,8 +130,13 @@ jobs:
export PATH="/opt/homebrew/opt/make/libexec/gnubin:$PATH"
PATH="$PATH:$HOME/go/bin" make protogen-go
PATH="$PATH:$HOME/go/bin" BUILD_TYPE="GITHUB_CI_HAS_BROKEN_METAL" CMAKE_ARGS="-DGGML_F16C=OFF -DGGML_AVX512=OFF -DGGML_AVX2=OFF -DGGML_FMA=OFF" make --jobs 4 --output-sync=target test
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+6 -1
View File
@@ -77,8 +77,13 @@ jobs:
- name: Test
run: |
PATH="$PATH:$HOME/go/bin" make backends/local-store backends/silero-vad backends/llama-cpp backends/whisper backends/piper backends/stablediffusion-ggml docker-build-e2e e2e-aio
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+206
View File
@@ -0,0 +1,206 @@
---
name: 'E2E Distributed Tests'
on:
pull_request:
# The suite's dependency graph is 99 packages, so an allowlist of paths
# silently stops guarding the moment code moves. At ~75s the job is cheap
# enough to run unless the diff is confined to paths it provably cannot
# reach. See .agents/ci-caching.md.
paths-ignore:
- 'gallery/**'
- 'docs/**'
- 'examples/**'
- '**/*.md'
push:
branches:
- master
concurrency:
group: ci-tests-e2e-distributed-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
tests-e2e-distributed:
runs-on: ubuntu-latest
# Advisory because it is deliberately not in branch protection, so a failure
# is a visible red X rather than a blocked merge. Promoting it to a required
# check is a repository-settings change, to be made once it has a track
# record; a heavy suite made required on day one gets disabled instead of
# fixed.
timeout-minutes: 45
steps:
- name: Clone
uses: actions/checkout@v7
with:
submodules: true
- name: Configure apt mirror on runner
uses: ./.github/actions/configure-apt-mirror
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.0'
cache: false
- name: Dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential libopus-dev
- name: Proto Dependencies
run: |
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- name: Pre-pull test images
# Pulling here rather than inside the suite keeps container-start timing
# out of the spec timeouts and makes a registry outage read as a
# setup failure instead of a test failure. This is the only image the
# suite needs once the testcontainers reaper is disabled below: the
# suite stands up no message broker, because nothing under test dials
# one.
run: |
docker pull postgres:16-alpine
- name: Distributed E2E
# TESTCONTAINERS_RYUK_DISABLED keeps the pre-pull above meaningful. The
# reaper exists to clean up leaked containers on a long-lived host, but
# this runner is ephemeral and every container dies with the VM. Leaving
# it enabled would pull a third, unpinned image (testcontainers/ryuk)
# from Docker Hub mid-suite: exactly the registry dependency the
# pre-pull step exists to remove.
env:
TESTCONTAINERS_RYUK_DISABLED: "true"
run: |
PATH="$PATH:$HOME/go/bin" make test-e2e-distributed
- name: Setup tmate session if tests fail
if: ${{ failure() }}
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
connect-timeout-seconds: 180
limit-access-to-actor: true
tests-e2e-cluster:
runs-on: ubuntu-latest
# Advisory for the same reason as the job above: master has no branch
# protection, so a failure here is a visible red X rather than a blocked
# merge. That is a repository-settings property, not a YAML key. The key
# that looks like it says "advisory" instead flips the run's conclusion to
# success, which hides the failure rather than flagging it, so it appears in
# none of this repo's workflows and must not be added here.
#
# Separate job from tests-e2e-distributed so the fast in-process suite is
# not held behind a Go build of local-ai. Serial on purpose: each Ginkgo
# process would get its own PostgreSQL container and each spec spawns two or
# three local-ai children, so --procs on an unmeasured runner is a change to
# make with numbers, not by default.
#
# The two timeouts bound different things and are not alternatives. Ginkgo's
# --timeout=20m bounds the SUITE only; this job timeout must additionally
# cover setup, which here is the larger and more variable half: submodule
# checkout, apt, protoc plus two go installs plus protogen-go, a cold-cache
# module download (cache: false), a full go build of ./cmd/local-ai, and a
# separate ginkgo test compile. That build alone is ~316s of CPU, so on a
# 4-vCPU runner setup is realistically 8-12 minutes.
#
# 45 minutes therefore, matching the sibling job. A tighter number does not
# make a hang fail faster, it just moves the kill from Ginkgo, which prints
# which spec hung, to the runner, which prints nothing: a red job with no
# evidence, which is how a suite gets disabled rather than fixed.
#
# The suite itself is about 8m30s over three consecutive runs (509.1s /
# 509.8s / 512.3s, so 8m29s to 8m32s) on a developer box, and will be slower
# here. Three specs sit at ~167s each because they wait out a 60s staleness
# threshold plus a 15s health-check tick (HealthCheckInterval, in
# core/config/distributed_config.go; core/services/nodes/health.go runs the
# ticker on the unexported checkInterval, not one of the reconcilers). Do
# not shorten those windows to make this job faster: the wait is what stops
# the assertions from passing before the system could have reacted, which
# was a real false green earlier on.
timeout-minutes: 45
steps:
- name: Clone
uses: actions/checkout@v7
with:
submodules: true
- name: Configure apt mirror on runner
uses: ./.github/actions/configure-apt-mirror
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.0'
cache: false
- name: Dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential libopus-dev
- name: Proto Dependencies
run: |
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- name: Stub the embedded React UI
# core/http/react-ui/dist is gitignored and built by Node, but this
# suite drives the HTTP API and never the UI, which has its own e2e
# suite. A single index.html satisfies the //go:embed react-ui/dist/*
# in core/http/app.go, so the job skips a full Node and Vite install.
# If a cluster spec ever asserts on a UI asset, this step must go and
# the real build come back: a developer box has a real dist/, so such a
# spec would pass locally and fail only here, or worse be served the
# stub and pass in both places.
run: |
mkdir -p core/http/react-ui/dist
printf '<!doctype html><title>stub</title>\n' > core/http/react-ui/dist/index.html
- name: Pre-pull test images
# Same reasoning as the job above: pulling here keeps container-start
# timing out of the spec timeouts and makes a registry outage read as a
# setup failure rather than a test failure.
run: |
docker pull postgres:16-alpine
- name: Cluster E2E
env:
# No LOCALAI_E2E_BINARY and no separate build step: make test-e2e-cluster
# builds ./local-ai itself, AFTER the protogen-go it also depends on.
# Building it in a step of its own put the generated .pb.go files newer
# than the binary, which the harness's staleness check reads, correctly,
# as a binary that does not contain the tree it is about to be measured
# against. One owner for the build is also what makes a local run and
# this job exercise the same bytes.
#
# Must live under the workspace so the upload step below can reach it.
# The harness defaults to GinkgoT().TempDir(), which lands under
# TMPDIR and would leave the artifact glob matching nothing.
LOCALAI_E2E_LOG_DIR: ${{ github.workspace }}/cluster-logs
# Belt and braces: the harness already fails rather than skips when CI
# is set, and GitHub Actions always sets CI. Stating it here means a
# future edit to that default cannot silently turn this job into one
# that passes without ever starting a cluster, since a skipped cluster
# spec is indistinguishable from a passing one.
LOCALAI_E2E_REQUIRE_BINARIES: "true"
# See the job above: the runner is ephemeral, so the reaper buys
# nothing and would pull a third, unpinned Docker Hub image mid-suite.
TESTCONTAINERS_RYUK_DISABLED: "true"
run: |
PATH="$PATH:$HOME/go/bin" make test-e2e-cluster
- name: Upload process logs
# The per-process logs are the only way to read a cluster failure: the
# Ginkgo output says which assertion failed, not what the four child
# processes were doing. Without this a red job is undebuggable.
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: cluster-process-logs
path: cluster-logs/**/*.log
if-no-files-found: ignore
retention-days: 7
- name: Setup tmate session if tests fail
if: ${{ failure() }}
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
connect-timeout-seconds: 180
limit-access-to-actor: true
+6 -1
View File
@@ -63,8 +63,13 @@ jobs:
- name: Test Backend E2E
run: |
PATH="$PATH:$HOME/go/bin" make build-mock-backend test-e2e
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+6 -1
View File
@@ -88,8 +88,13 @@ jobs:
# CPU and runs the token_classify capability spec (byte-offset contract).
- name: Run live PII NER backend E2E
run: PATH="$PATH:$HOME/go/bin" make test-extra-backend-privacy-filter
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+8 -1
View File
@@ -52,6 +52,8 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y build-essential libopus-dev
- name: Run stale chunk recovery tests
run: PATH="$PATH:$HOME/go/bin" make test-ui-stale-chunk
# Builds an instrumented UI bundle, runs the Playwright specs, and fails
# if line coverage regressed beyond the jitter tolerance (the gate is
# in `make test-ui-coverage-check`). PLAYWRIGHT_CHROMIUM_PATH is unset
@@ -73,8 +75,13 @@ jobs:
path: core/http/react-ui/coverage/
if-no-files-found: ignore
retention-days: 7
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+22
View File
@@ -19,7 +19,22 @@ linters:
- staticcheck
enable:
- forbidigo
# gocritic is enabled for ONE checker: ruleguard, which runs the rules in
# hack/lint/. Every other gocritic check is off (disable-all below), so
# this adds no style noise; it is here purely as the gate that catches a
# gRPC backend wrapper written without Unwrap. See
# hack/lint/backend_wrappers.go for why that cannot be a compile-time
# assertion.
- gocritic
settings:
gocritic:
disable-all: true
enabled-checks:
- ruleguard
settings:
ruleguard:
failOn: all
rules: '${base-path}/hack/lint/backend_wrappers.go'
forbidigo:
forbid:
- pattern: '^t\.Errorf$'
@@ -126,3 +141,10 @@ linters:
- path: ^backend/go/whisper/sources/
text: 'http\.(DefaultClient|Get|Post|PostForm|Head)'
linters: [forbidigo]
# Test doubles embed grpc.Backend to inherit the interface's method set
# over a NIL value; they decorate nothing, hold no inner client, and have
# no transport answer to forward. The rule targets production wrappers,
# which is where swallowing that answer deletes replica rows.
# gocritic here is only the backend-wrapper ruleguard rule.
- path: _test\.go$
linters: [gocritic]
+21
View File
@@ -0,0 +1,21 @@
## Design Context
### Users
LocalAI serves both single-host users who want to install and try models quickly and experienced developers, ML engineers, system administrators, and DevOps operators who manage production hosts or distributed clusters. The interface must support first-time discovery without hiding the runtime state, configuration, and control that returning operators need.
### Brand Personality
Capable, easy to use, and trustworthy. The interface should make sophisticated local-AI infrastructure feel understandable and under control. It should be direct and calm rather than playful, ornamental, or intimidating.
### Aesthetic Direction
Use LocalAI's established technical, editorial design language: Geist typography, compact information density, sharp geometry, deep blue-black surfaces, action blue, mint for healthy/local/live state, and amber only for decisions requiring attention. Support both dark and light themes. Avoid generic card dashboards, decorative gradients, glass effects, and visual noise.
### Design Principles
1. Use progressive disclosure to serve newcomers and operators in the same workflow: make the common path obvious, then reveal operational depth in context.
2. Organize navigation around user intent and lifecycle state, not implementation concepts or nested containers.
3. Give each resource one canonical home; expose discovery, installed state, and runtime state as clear views of that resource instead of duplicating management surfaces.
4. Keep operational status visible and trustworthy through precise labels, explicit scope, and actionable state—not decoration.
5. Preserve information density for expert use while flattening navigation and reducing repeated summaries, tabs, rails, and panels.
+1
View File
@@ -27,6 +27,7 @@ To be removed, open a pull request deleting your row, or email
| Organisation | What they use it for | Status |
|---|---|---|
| [walcz.de](https://walcz.de) | Self-hosted appliance for a German B2B consultancy: local-only inference on AMD Strix Halo (gfx1151/ROCm), agents with MCP tools, RAG over an internal knowledge base, and a document/bookkeeping pipeline. | Production |
| _Your organisation here_ | | |
## What this list is not
+2 -1
View File
@@ -8,7 +8,7 @@ Human contributors: see [CONTRIBUTING.md](CONTRIBUTING.md) for the development w
LocalAI follows the Linux kernel project's [guidelines for AI coding assistants](https://docs.kernel.org/process/coding-assistants.html). Before submitting AI-assisted code, read [.agents/ai-coding-assistants.md](.agents/ai-coding-assistants.md). Key rules:
- **No `Signed-off-by` from AI.** Only the human submitter may sign off on the Developer Certificate of Origin.
- **No `Signed-off-by` from AI.** Only the human submitter may sign off on the Developer Certificate of Origin. One exception: automation a maintainer operates signs off with *that maintainer's* identity, since no other human submitter exists to certify it. See [.agents/ai-coding-assistants.md](.agents/ai-coding-assistants.md).
- **No `Co-Authored-By: <AI>` trailers.** The human contributor owns the change.
- **Use an `Assisted-by:` trailer** to attribute AI involvement. Format: `Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]`.
- **The human submitter is responsible** for reviewing, testing, and understanding every line of generated code.
@@ -33,6 +33,7 @@ LocalAI follows the Linux kernel project's [guidelines for AI coding assistants]
| [.agents/localai-assistant-mcp.md](.agents/localai-assistant-mcp.md) | LocalAI Assistant chat modality — adding admin tools to the in-process MCP server, editing skill prompts, keeping REST + MCP + skills in sync |
| [.agents/backend-signing.md](.agents/backend-signing.md) | Backend OCI image signing (keyless cosign + sigstore-go) — producer-side CI setup, consumer-side gallery `verification:` block, strict mode (`LOCALAI_REQUIRE_BACKEND_INTEGRITY`), revocation via `not_before` |
| [.agents/preparing-a-release.md](.agents/preparing-a-release.md) | Cutting a release: PR labels, `RELEASE_NOTES_vX.Y.Z.md`, the blog post under `website/content/blog/`, and the demo clips under `website/static/media/` |
| [.impeccable.md](.impeccable.md) | Design context for UI/UX work — users, brand personality, aesthetic direction, and design principles |
## Quick Reference
+32 -1
View File
@@ -218,7 +218,7 @@ LocalAI follows the **same guidelines as the Linux kernel project** for AI-assis
The full policy for this repository lives in [`.agents/ai-coding-assistants.md`](.agents/ai-coding-assistants.md). Summary:
- **AI agents MUST NOT add `Signed-off-by` tags.** Only humans can certify the Developer Certificate of Origin.
- **AI agents MUST NOT add `Signed-off-by` tags.** Only humans can certify the Developer Certificate of Origin. Automation operated by a maintainer is the one exception: it signs off with that maintainer's identity, because there is no other human submitter to certify it.
- **AI agents MUST NOT add `Co-Authored-By` trailers** attributing themselves as co-authors.
- **Attribute AI involvement with an `Assisted-by` trailer** in the commit message:
@@ -265,6 +265,37 @@ The e2e tests run LocalAI in a Docker container and exercise the API:
make test-e2e
```
### Running distributed-mode tests
Distributed mode (several frontend replicas, worker nodes and PostgreSQL) has two suites. Both bring up their PostgreSQL with testcontainers, so Docker has to be available. There is no message broker to bring up: a distributed deployment needs PostgreSQL and the frontends' own HTTP listener, and nothing else.
```bash
make test-e2e-distributed # in-process: services wired directly into the test binary
make test-e2e-cluster # process-level: real local-ai child processes
```
`make test-e2e-distributed` is the fast one (231 specs in roughly 2m20s). It starts one PostgreSQL for the whole run and gives each spec its own database. It runs each spec exactly once, with no retry: `DISTRIBUTED_TEST_FLAKES` defaults to 1 and feeds ginkgo's `--flake-attempts`, which counts *total attempts*, not retries. That is deliberately below the repo-wide `TEST_FLAKES=5`, because this suite exists to catch nondeterministic cluster behaviour and a retry hides exactly the failure it is meant to catch. Raise it locally when bisecting something unrelated.
`make test-e2e-cluster` runs `local-ai` as real child processes, one per frontend replica and one per worker, so a spec can kill a replica and assert what the survivors do. Budget about 13m30s (one measurement: 802.5s of specs in a 13m26s target run, which includes building the binary): several of its 21 specs wait out real staleness and health-check windows. An earlier record of 6 specs and roughly 509s is superseded. It needs a built binary and the mock backend:
```bash
make build build-mock-backend
make test-e2e-cluster
```
Two environment variables steer it:
| Variable | Purpose |
|---|---|
| `LOCALAI_E2E_BINARY` | path to the `local-ai` binary (default: `local-ai` in the repository root) |
| `LOCALAI_E2E_LOG_DIR` | directory for the per-process logs (default: a Ginkgo temp dir) |
Set `LOCALAI_E2E_LOG_DIR` when debugging. A cluster failure is unreadable without the individual frontend and worker logs, and Ginkgo only tells you which assertion failed.
A missing binary skips the cluster specs locally but fails them whenever `CI` is set, so a build problem cannot turn the CI job green without ever starting a cluster. `LOCALAI_E2E_REQUIRE_BINARIES=1` forces that failing behaviour anywhere; `LOCALAI_E2E_REQUIRE_BINARIES=0` forces the skip back on even under CI.
Both suites run in `.github/workflows/tests-e2e-distributed.yml`, on pull requests and on every push to `master`. The `paths-ignore` filter is on the pull-request trigger only, so a master push always runs them.
### React UI tests and coverage
The React UI (`core/http/react-ui/`) is covered by Playwright e2e specs, gated by a **monotonic line-coverage ratchet** (`make test-ui-coverage-check`, run in CI). The metric is non-deterministic — a fast local box reads higher than a slow CI runner for the same code — so a small tolerance is unavoidable.
+145 -12
View File
@@ -1,5 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
GOCMD=go
GOTEST=$(GOCMD) test
@@ -34,6 +34,11 @@ TEST_FLAKES?=5
RANDOM := $(shell bash -c 'echo $$RANDOM')
VERSION?=$(shell git describe --always --tags || echo "dev" )
# fyne package only accepts numeric x[.y[.z]] app versions, so reduce git
# describe output (v4.9.0, v4.9.0-14-gabc1234, or a bare sha on untagged
# checkouts) to its numeric core; anything non-numeric falls back to 0.0.0.
# Without this the packaged launcher reports itself as version 0.0.0 (#11673).
LAUNCHER_APP_VERSION?=$(shell v=$$(echo "$(VERSION)" | sed -E 's/^v//; s/[+-].*$$//'); echo "$$v" | grep -qE '^[0-9]+(\.[0-9]+){0,2}$$' && echo "$$v" || echo "0.0.0")
# go tool nm ./local-ai | grep Commit
LD_FLAGS?=-s -w
override LD_FLAGS += -X "github.com/mudler/LocalAI/internal.Version=$(VERSION)"
@@ -103,7 +108,7 @@ COVERAGE_E2E_LABELS?=!real-models
COVERAGE_EXCLUDE_RE?=grpc/proto/.*[.]pb[.]go
.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-backend-cpp test-build-scripts test-ui test-ui-coverage-baseline test-ui-coverage-check build vendor lint lint-all
.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-backend-cpp test-build-scripts test-ui test-ui-stale-chunk test-ui-coverage-baseline test-ui-coverage-check build vendor lint lint-all
all: help
@@ -335,12 +340,108 @@ run-e2e-aio: protogen-go
@echo 'Running e2e AIO tests'
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e-aio
# Distributed architecture e2e (PostgreSQL + NATS via testcontainers).
# Includes NatsJWT specs (JWT-enabled NATS). Requires Docker.
# Total ginkgo attempts per spec for the distributed suite: --flake-attempts counts
# attempts, not retries. Defaults to 1, so each spec runs once and is never retried,
# unlike TEST_FLAKES=5. This suite exists to catch nondeterministic cluster behaviour,
# and a retry hides exactly the failures it is meant to surface. Raise it locally if
# you are bisecting something unrelated.
DISTRIBUTED_TEST_FLAKES?=1
# Distributed architecture e2e (PostgreSQL via testcontainers). Requires Docker.
# There is no broker container and no NatsJWT label any more: a distributed
# deployment needs PostgreSQL and the frontends' own HTTP listener, and this
# suite stands up exactly that.
# VLLMMultinode is excluded here; use test-e2e-vllm-multinode for that.
# Cluster is excluded too and runs in test-e2e-cluster below, which needs a
# built binary. The argument-validation specs under tests/e2e/distributed/cluster
# carry Label("Distributed") only, so they run here and not there, on purpose.
# -r stays because of those: they are in a subpackage this target must reach.
# --fail-on-empty because ginkgo exits 0 when a label filter matches nothing, so
# without it a rename of the label would turn this target into a silent no-op
# that still reports "Test Suite Passed".
#
# This target needs NO built binary and therefore has no stale-binary hole:
# ginkgo compiles the suite from the working tree on every run, and everything
# these specs exercise runs in that process against containers. The one target
# in this file that does exec a prebuilt binary is test-e2e-cluster below, and
# the reason it now BUILDS it is written there.
test-e2e-distributed: protogen-go
@echo 'Running distributed e2e tests (label Distributed, incl. NatsJWT)'
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Distributed && !VLLMMultinode' --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e/distributed
@echo 'Running distributed e2e tests (label Distributed)'
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Distributed && !VLLMMultinode && !Cluster' --fail-on-empty --flake-attempts $(DISTRIBUTED_TEST_FLAKES) --timeout=40m -v -r ./tests/e2e/distributed
# The local-ai binary the cluster e2e execs as its frontend and worker
# processes.
#
# Deliberately not `make build`: that target also builds the embedded React UI,
# which this suite never touches, and CI stubs core/http/react-ui/dist for
# exactly that reason. What is left is the go build, which is incremental and
# costs seconds when nothing changed, so making it a prerequisite of the suite
# is cheap enough that nobody has a reason to route around it.
#
# LOCALAI_E2E_BINARY means the caller has its own binary and owns keeping it
# current, so this builds nothing. The staleness check in the harness still
# applies to it, which is the point: a binary this target did not produce is
# precisely the one nothing else vouches for.
.PHONY: e2e-binary
e2e-binary: protogen-go
@if [ -n "$$LOCALAI_E2E_BINARY" ]; then \
echo "LOCALAI_E2E_BINARY=$$LOCALAI_E2E_BINARY is set; not building $(BINARY_NAME)"; \
else \
echo "Building $(BINARY_NAME) for the cluster e2e"; \
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o $(BINARY_NAME) ./cmd/local-ai; \
fi
# Cluster e2e: runs local-ai as real child processes (frontend replicas +
# workers) against PostgreSQL, and kills them to assert failover.
# It BUILDS that binary rather than checking that a file by that name exists,
# and that is a correctness fix rather than a convenience. This target used to
# take ./local-ai as given, so an edit to core/ that was never rebuilt left the
# whole suite exercising an older build while reporting on the working tree. The
# failure mode is not theoretical and not loud: it surfaced as a spec failing on
# caller line numbers that matched no line in any source file, and it would
# otherwise have surfaced as a green run that proved nothing. build-mock-backend
# already rebuilds unconditionally, so ./local-ai was the only gap.
#
# The harness carries the other half. Building here cannot help a run started
# with LOCALAI_E2E_BINARY or by invoking ginkgo directly, so localAIBinary also
# FAILS when the binary it is about to exec is older than the newest non-test Go
# source in the tree. Together: this target makes the common path correct, and
# the harness makes every other path loud.
#
# The argument-validation specs in tests/e2e/distributed/cluster deliberately
# stay in test-e2e-distributed above: they need no binary and no PostgreSQL, so
# no -r here and that package is simply out of scope.
#
# --fail-on-empty is load-bearing, not tidiness. Ginkgo exits 0 when a label
# filter selects nothing, so without it a refactor that renames or drops
# Label("Cluster") leaves this target reporting "Test Suite Passed" having
# started no cluster at all. LOCALAI_E2E_REQUIRE_BINARIES does not cover this:
# it only fires inside a spec that is actually running.
#
# --flake-attempts is pinned to 1 rather than $(DISTRIBUTED_TEST_FLAKES), and
# should stay there: this suite exists to catch nondeterministic cluster
# behaviour, and a retry turns exactly that signal into a green run.
#
# Budget: 26 specs, measured at 933.8 seconds of Ginkgo time (15m37s wall
# including the compile) on a fast developer box. It was 591 to 612 seconds
# before the phase 3 control-plane specs and 800 to 830 after them; the three
# two-frontend two-worker specs in cluster_busless_test.go added 118 to 127
# seconds (3s, 70s and 46 to 53s), nearly all of it in the churn spec, which
# cannot be shortened: it waits for a killed replica to leave the live set
# (cluster.InstanceLiveness is 30s, measured at 27s) before it may assert
# anything, and then holds a window inside the reconnect grace. The two
# cross-replica fan-out specs added 7.0 seconds of spec time (5.0s and 2.0s):
# they run two frontends and no workers, so they pay for no registration, and
# what they wait on is a broadcast rather than a threshold.
#
# --timeout is 30m rather than 20m because of that. The margin is not slack: a
# Ginkgo timeout kills the suite mid-spec and reports a spec name rather than a
# cause, and 20m on a loaded CI runner was one slow health tick away from
# turning a green suite into an unreadable red one. At 15 minutes measured, 30m
# is still twice the budget.
test-e2e-cluster: protogen-go build-mock-backend e2e-binary
@echo 'Running cluster e2e tests (label Cluster, real local-ai processes)'
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Cluster' --fail-on-empty --flake-attempts 1 --timeout=30m -v ./tests/e2e/distributed
# vLLM multi-node DP smoke (CPU). Builds local-ai:tests and the
# cpu-vllm backend from the current working tree, then drives a
@@ -378,19 +479,29 @@ prepare-e2e:
run-e2e-image:
docker run -p 5390:8080 -e MODELS_PATH=/models -e THREADS=1 -e DEBUG=true -d --rm -v $(TEST_DIR):/models --name e2e-tests-$(RANDOM) localai-tests
# Distributed subpackages have separate targets that provision PostgreSQL and
# build the host binaries; this target exercises the container-backed API only.
test-e2e: build-mock-backend build-cloud-proxy-backend prepare-e2e run-e2e-image
@echo 'Running e2e tests'
BUILD_TYPE=$(BUILD_TYPE) \
LOCALAI_API=http://$(E2E_BRIDGE_IP):5390 \
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v ./tests/e2e
$(MAKE) clean-mock-backend
$(MAKE) clean-cloud-proxy-backend
$(MAKE) teardown-e2e
docker rmi localai-tests
# `docker stop` returns as soon as the container exits, but Docker reaps a
# `--rm` container asynchronously after that. The `docker rmi localai-tests` in
# test-e2e then loses the race against the reaper and fails on a still
# referenced image, turning a green suite red. Removing the container ourselves
# is synchronous, so the image reference is gone before we return. It also
# covers the case where nothing is running, which `docker stop` could not
# because it rejects an empty argument list.
teardown-e2e:
rm -rf $(TEST_DIR) || true
docker stop $$(docker ps -q --filter ancestor=localai-tests)
@CONTAINERS=$$(docker ps -aq --filter ancestor=localai-tests 2>/dev/null); \
if [ -n "$$CONTAINERS" ]; then docker rm -f $$CONTAINERS || true; fi
########################################################
## Integration and unit tests
@@ -654,6 +765,7 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/go/depth-anything-cpp test
$(MAKE) -C backend/go/supertonic test
$(MAKE) -C backend/go/vllm-cpp test
$(MAKE) -C backend/go/nemo-speech-cpp test
$(MAKE) -C backend/go/trellis2cpp test
$(MAKE) -C backend/go/valkey-store test
@@ -675,6 +787,7 @@ test-extra: prepare-test-extra
## BACKEND_TEST_PROMPT Override the prompt used in predict/stream specs.
## BACKEND_TEST_OPTIONS Comma-separated Options[] entries forwarded to LoadModel,
## e.g. "tool_parser:hermes,reasoning_parser:qwen3".
## BACKEND_TEST_EMBEDDING_LAYOUT Expected EmbeddingResult layout: "final" or "per_token".
##
## Direct usage (image already built, no docker-build-* dependency):
##
@@ -704,6 +817,7 @@ test-extra-backend: protogen-go
BACKEND_TEST_CAPS="$$BACKEND_TEST_CAPS" \
BACKEND_TEST_PROMPT="$$BACKEND_TEST_PROMPT" \
BACKEND_TEST_OPTIONS="$$BACKEND_TEST_OPTIONS" \
BACKEND_TEST_EMBEDDING_LAYOUT="$$BACKEND_TEST_EMBEDDING_LAYOUT" \
BACKEND_TEST_TOOL_PROMPT="$$BACKEND_TEST_TOOL_PROMPT" \
BACKEND_TEST_TOOL_NAME="$$BACKEND_TEST_TOOL_NAME" \
BACKEND_TEST_CACHE_TYPE_K="$$BACKEND_TEST_CACHE_TYPE_K" \
@@ -723,6 +837,15 @@ test-extra-backend-llama-cpp: docker-build-llama-cpp
BACKEND_TEST_CAPS=health,load,predict,stream,logprobs,logit_bias \
$(MAKE) test-extra-backend
## Raw llama.cpp embeddings are required by Go-side pooling. This exercises the
## real C++ backend and verifies that it marks the flattened matrix per-token.
test-extra-backend-llama-cpp-embeddings: docker-build-llama-cpp
BACKEND_IMAGE=local-ai-backend:llama-cpp \
BACKEND_TEST_CAPS=health,load,embeddings \
BACKEND_TEST_OPTIONS=pooling:none \
BACKEND_TEST_EMBEDDING_LAYOUT=per_token \
$(MAKE) test-extra-backend
test-extra-backend-ik-llama-cpp: docker-build-ik-llama-cpp
BACKEND_IMAGE=local-ai-backend:ik-llama-cpp $(MAKE) test-extra-backend
@@ -812,6 +935,7 @@ test-extra-backend-tinygrad-embeddings: docker-build-tinygrad
BACKEND_IMAGE=local-ai-backend:tinygrad \
BACKEND_TEST_MODEL_NAME=Qwen/Qwen3-0.6B \
BACKEND_TEST_CAPS=health,load,embeddings \
BACKEND_TEST_EMBEDDING_LAYOUT=final \
$(MAKE) test-extra-backend
## tinygrad — Stable Diffusion 1.5. The original CompVis/runwayml repos have
@@ -1298,6 +1422,7 @@ BACKEND_WHISPER = whisper|golang|.|false|true
BACKEND_CRISPASR = crispasr|golang|.|false|true
BACKEND_PARAKEET_CPP = parakeet-cpp|golang|.|false|true
BACKEND_MOSS_TRANSCRIBE_CPP = moss-transcribe-cpp|golang|.|false|true
BACKEND_NEMO_SPEECH_CPP = nemo-speech-cpp|golang|.|false|true
BACKEND_DEPTH_ANYTHING_CPP = depth-anything-cpp|golang|.|false|true
BACKEND_VOXTRAL = voxtral|golang|.|false|true
BACKEND_ACESTEP_CPP = acestep-cpp|golang|.|false|true
@@ -1400,6 +1525,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_WHISPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_CRISPASR)))
$(eval $(call generate-docker-build-target,$(BACKEND_PARAKEET_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_MOSS_TRANSCRIBE_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_NEMO_SPEECH_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_DEPTH_ANYTHING_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_VOXTRAL)))
$(eval $(call generate-docker-build-target,$(BACKEND_OPUS)))
@@ -1456,7 +1582,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_SUPERTONIC)))
docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-nemo-speech-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
########################################################
### Mock Backend for E2E Tests
@@ -1502,6 +1628,13 @@ test-ui: build-mock-backend protogen-go
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui
cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG)
## The stale-chunk specs need the production code-split bundle. The V8 coverage
## bundle below inlines dynamic imports to keep every page in its denominator.
test-ui-stale-chunk: build-mock-backend protogen-go
cd core/http/react-ui && bun install && bun run build
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui
cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test --grep @production-chunks --workers=1
## React UI code coverage from the Playwright e2e suite. Builds a
## NON-instrumented bundle with source maps (COVERAGE_V8=true), re-embeds it
## into the ui-test-server (the dist is //go:embed'ed at compile time), runs the
@@ -1517,7 +1650,7 @@ test-ui-coverage: build-mock-backend protogen-go
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui && \
( cd core/http/react-ui && rm -rf .nyc_output coverage && \
sh $(CURDIR)/scripts/ensure-playwright-browser.sh && \
PW_V8_COVERAGE=1 bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG) && bun run coverage:report )
PW_V8_COVERAGE=1 bunx playwright test --grep-invert @production-chunks $(PLAYWRIGHT_WORKERS_FLAG) && bun run coverage:report )
## UI coverage baseline (committed) and the strict gate that compares against
## it — the React mirror of test-coverage-baseline / test-coverage-check.
@@ -1600,7 +1733,7 @@ site-serve: site
build-launcher-darwin:
rm -rf dist/LocalAI.app cmd/launcher/LocalAI.app
mkdir -p dist
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME) --app-version $(LAUNCHER_APP_VERSION)
mv cmd/launcher/LocalAI.app dist/LocalAI.app
bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.app
@@ -1627,4 +1760,4 @@ release-launcher-darwin: notarize-launcher-darwin
@echo "dist/LocalAI.dmg is ready"
build-launcher-linux:
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux && mv LocalAI.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux --app-version $(LAUNCHER_APP_VERSION) && mv LocalAI.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz
+2 -9
View File
@@ -5,9 +5,6 @@
</h1>
<p align="center">
<a href="https://github.com/go-skynet/LocalAI/stargazers" target="blank">
<img src="https://img.shields.io/github/stars/go-skynet/LocalAI?style=for-the-badge" alt="LocalAI stars"/>
</a>
<a href='https://github.com/go-skynet/LocalAI/releases'>
<img src='https://img.shields.io/github/release/go-skynet/LocalAI?&label=Latest&style=for-the-badge'>
</a>
@@ -212,7 +209,7 @@ For older news and full release notes, see [GitHub Releases](https://github.com/
- [Object Detection](https://localai.io/features/object-detection/)
- [Reranker API](https://localai.io/features/reranker/)
- [P2P Inferencing](https://localai.io/features/distribute/)
- [Distributed Mode](https://localai.io/features/distributed-mode/) — Horizontal scaling with PostgreSQL + NATS
- [Distributed Mode](https://localai.io/features/distributed-mode/): horizontal scaling on PostgreSQL, with no message broker to run
- [Model Context Protocol (MCP)](https://localai.io/docs/features/mcp/)
- [Built-in Agents](https://localai.io/features/agents/) — Autonomous AI agents with tool use, RAG, skills, SSE streaming, and [Agent Hub](https://agenthub.localai.io)
- [Backend Gallery](https://localai.io/backends/) — Install/remove backends on the fly via OCI images
@@ -231,7 +228,7 @@ Most backends wrap a best-in-class upstream engine. A handful of them are native
| Backend | What it does |
|---------|-------------|
| [vllm.cpp](https://github.com/mudler/vllm.cpp) | From-scratch C++20 port of vLLM for text generation: paged KV cache, continuous batching, prefix caching, safetensors + GGUF loading, engine-enforced structured output, on CPU, CUDA, Metal and Vulkan |
| [vllm.cpp](https://github.com/mudler/vllm.cpp) | From-scratch C++20 port of vLLM for text generation: paged KV cache, continuous batching, prefix caching, safetensors + GGUF loading, engine-enforced structured output, on CPU, CUDA, Metal and Vulkan. Also serves MiniMax-H3 joint video+audio generation |
| [parakeet.cpp](https://github.com/mudler/parakeet.cpp) | C++/GGML port of NVIDIA NeMo Parakeet ASR (tdt/ctc/rnnt/hybrid), with cache-aware streaming transcription |
| [moss-transcribe.cpp](https://github.com/localai-org/moss-transcribe.cpp) | C++/GGML port of OpenMOSS MOSS-Transcribe-Diarize: joint long-form transcription, speaker diarization and timestamping in a single pass |
| [moss-tts.cpp](https://github.com/mudler/moss-tts.cpp) | C++/GGML port of the OpenMOSS MOSS-TTS family: text-to-speech (MOSS-TTS-Local v1.5, 48 kHz stereo) with reference-audio voice cloning, through the MOSS-Audio-Tokenizer neural codec |
@@ -318,10 +315,6 @@ Past sponsors
A special thanks to individual sponsors, a full list is on [GitHub](https://github.com/sponsors/mudler) and [buymeacoffee](https://buymeacoffee.com/mudler). Special shout out to [drikster80](https://github.com/drikster80) for being generous. Thank you everyone!
## Star history
[![LocalAI Star history Chart](https://api.star-history.com/svg?repos=go-skynet/LocalAI&type=Date)](https://star-history.com/#go-skynet/LocalAI&Date)
## License
LocalAI is a community-driven project created by [Ettore Di Giacinto](https://github.com/mudler/) and maintained by the [LocalAI team](#team).
+3 -1
View File
@@ -10,6 +10,7 @@ FROM ${BASE_IMAGE} AS builder
ARG BUILD_TYPE
ARG TARGETARCH
ARG TARGETVARIANT
ARG CUDA_MAJOR_VERSION
ENV BUILD_TYPE=${BUILD_TYPE} \
DEBIAN_FRONTEND=noninteractive \
@@ -35,7 +36,8 @@ RUN apt-get update && \
COPY . /LocalAI
RUN --mount=type=cache,target=/root/.ccache,id=ds4-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
make -C /LocalAI/backend/cpp/ds4 BUILD_TYPE=${BUILD_TYPE} NATIVE=false grpc-server package
make -C /LocalAI/backend/cpp/ds4 BUILD_TYPE=${BUILD_TYPE} \
CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION} NATIVE=false grpc-server package
FROM scratch
COPY --from=builder /LocalAI/backend/cpp/ds4/package/. ./
+128
View File
@@ -248,6 +248,134 @@ RUN <<EOT bash
fi
EOT
# nemo-speech-cpp builds NVIDIA NeMo-Speech.cpp with text normalization enabled,
# which compiles the Sparrowhawk/OpenFST WFST stack from source via
# scripts/build_itn_deps.sh. That step needs gcc-12 specifically: OpenFST's
# template-heavy translation units ICE on gcc-13 and gcc-14 at -O2, so upstream
# pins gcc-12 for it while the runtime itself builds with the image default.
# No update-alternatives here, so the default compiler is untouched; the backend
# Makefile reaches gcc-12 by name for that one step.
#
# The rest is what build_itn_deps.sh and the WITH_NORM cmake block expect:
# protobuf (headers plus protoc, which must come from the same apt set so the
# generated stubs match the headers they compile against) and re2 for
# Sparrowhawk, and autotools because OpenFST and Sparrowhawk ship autoconf
# builds. ninja is not in the common apt list because this is the only Go
# backend that configures with -G Ninja, and that list is a layer shared by
# every backend image in the matrix.
#
# No libabsl-dev, despite upstream's Dockerfile installing it: upstream builds
# against protobuf 25, which splits its runtime across libabsl_*, whereas every
# base image in this matrix carries protobuf 3.21 (noble) or 3.12 (jammy), which
# has no absl dependency. The cmake block's file(GLOB ... /usr/lib/libabsl_*.so)
# would not match on Ubuntu anyway, since multiarch puts those under
# /usr/lib/<triplet>/.
#
# Placed down here with the other per-backend gates rather than next to the
# shared apt layer: Docker re-keys every layer below an inserted one, so adding
# a step above the Vulkan SDK, CUDA, Go and protoc layers would force all of
# them to re-execute once for every Go backend image, not just this one.
# Nothing between there and here needs any of these packages (the Vulkan and
# opus blocks install their own ninja and pkg-config, and the protoc download is
# a release binary that needs neither libprotobuf-dev nor protoc from apt), and
# nothing here needs anything those layers provide.
#
# The second half of this block backfills cmake. NeMo-Speech.cpp opens with
# cmake_minimum_required(VERSION 3.26), which every noble base in the matrix
# satisfies (24.04 ships 3.28) but the JetPack r36.4.0 row does not: that image
# is jammy, whose apt cmake is 3.22, so configure aborts before it reads a
# single one of our -D flags. This is the only Go backend that needs more than
# jammy's cmake; parakeet-cpp and moss-transcribe-cpp share the same JetPack
# base and both declare cmake_minimum_required(VERSION 3.18).
#
# Taken from Kitware's own release tarball rather than from their APT repo or
# from pip. The tarball is a pinned URL with a published checksum, so the build
# is reproducible and an upstream release cannot change what lands here; the
# APT repo serves a moving 'latest', which today would be CMake 4.x, and 4.x
# drops compatibility with cmake_minimum_required below 3.5 and so would break
# vendored third_party subprojects that still declare one. pip would drag a
# Python toolchain into a backend that otherwise has none. The binaries need
# only glibc 2.17 and carry no libstdc++ DT_NEEDED, so jammy's 2.35 is far
# above the floor. doc/, man/, ccmake and cmake-gui are left in the tarball;
# this is a builder stage and the final image is FROM scratch, but there is no
# reason to page 50 MB of Qt GUI and docs through the CI cache.
#
# Conditional on the installed cmake being too old rather than unconditional,
# so the rows that already build green (noble cpu, vulkan, cublas and hipblas)
# keep configuring with exactly the cmake they configure with today.
#
# The version test compares through two temp files and a grep on the exit
# status rather than the obvious "$(sort -V ... | head -n1)". BuildKit delivers
# a RUN heredoc through an outer shell with an unquoted delimiter, so the outer
# shell expands the body before bash ever sees it: a $(...) here runs once, too
# early, in a container where the files it reads do not exist yet, and its empty
# output is then pasted into the script. Same reason there are no shell
# variables below. ${BACKEND} and ${TARGETARCH} are fine because they are build
# args, which BuildKit exports into that outer shell's environment.
#
# The symlink goes in /usr/local/bin, which precedes /usr/bin on PATH, so it
# shadows apt's cmake. That is deliberate and, unlike the protoc shadowing that
# broke Sparrowhawk earlier in this PR, it is inert: protoc has to agree with
# the libprotobuf headers it generates against, whereas cmake is a standalone
# build driver with no ABI relationship to anything in the image, and it locates
# its own Modules/ tree by resolving the symlink back to /opt, so a 3.31 binary
# can never read 3.22's modules. Scope is the ${BACKEND} gate: no other Go
# backend image gets /opt/cmake or the symlink. Inside this image the only
# other cmake consumers, the base apt layer and the Vulkan SDK build, both run
# in layers above this one and have already finished.
RUN <<EOT bash
if [ "${BACKEND}" = "nemo-speech-cpp" ]; then
set -e
apt-get update
apt-get install -y --no-install-recommends \
gcc-12 g++-12 \
ninja-build \
libprotobuf-dev protobuf-compiler \
libre2-dev \
autoconf automake libtool pkg-config
apt-get clean
rm -rf /var/lib/apt/lists/*
echo 3.26.0 > /tmp/cmake-required
cmake --version 2>/dev/null | head -n1 | cut -d' ' -f3 > /tmp/cmake-present
if [ ! -s /tmp/cmake-present ]; then
echo 0.0.0 > /tmp/cmake-present
fi
if sort -V /tmp/cmake-required /tmp/cmake-present | head -n1 | grep -qxF 3.26.0; then
echo "==> cmake is new enough for NeMo-Speech.cpp:"
cmake --version | head -n1
else
echo "==> cmake is below the 3.26 NeMo-Speech.cpp requires; installing 3.31.12. Found:"
cat /tmp/cmake-present
mkdir -p /opt/cmake
if [ "${TARGETARCH}" = "arm64" ]; then
curl -fsSL -o /tmp/cmake.tar.gz https://github.com/Kitware/CMake/releases/download/v3.31.12/cmake-3.31.12-linux-aarch64.tar.gz
echo "83f8fd91d2038a56556e1400390fcfe42f79602940c494f6c6f1cdae7f9e7f40 /tmp/cmake.tar.gz" | sha256sum -c -
tar -xzf /tmp/cmake.tar.gz -C /opt/cmake --strip-components=1 \
cmake-3.31.12-linux-aarch64/bin/cmake \
cmake-3.31.12-linux-aarch64/bin/cpack \
cmake-3.31.12-linux-aarch64/bin/ctest \
cmake-3.31.12-linux-aarch64/share
else
curl -fsSL -o /tmp/cmake.tar.gz https://github.com/Kitware/CMake/releases/download/v3.31.12/cmake-3.31.12-linux-x86_64.tar.gz
echo "0dc2e9a6860f06bf10bd8fadc03e35d9eeb4df46e33763a7e480e987758f385c /tmp/cmake.tar.gz" | sha256sum -c -
tar -xzf /tmp/cmake.tar.gz -C /opt/cmake --strip-components=1 \
cmake-3.31.12-linux-x86_64/bin/cmake \
cmake-3.31.12-linux-x86_64/bin/cpack \
cmake-3.31.12-linux-x86_64/bin/ctest \
cmake-3.31.12-linux-x86_64/share
fi
rm -f /tmp/cmake.tar.gz
ln -sf /opt/cmake/bin/cmake /usr/local/bin/cmake
ln -sf /opt/cmake/bin/cpack /usr/local/bin/cpack
ln -sf /opt/cmake/bin/ctest /usr/local/bin/ctest
hash -r
cmake --version
fi
rm -f /tmp/cmake-required /tmp/cmake-present
fi
EOT
RUN git config --global --add safe.directory /LocalAI
# Prebuild the native engine from a layer that depends on this backend's own
+20
View File
@@ -536,8 +536,28 @@ message Result {
bool success = 2;
}
// EmbeddingLayout describes whether embeddings contains one final vector or
// a matrix of per-token vectors. Go-side pooling must never infer this from
// tokens/dim alone: a one-token raw matrix and a final vector have the same
// shape.
enum EmbeddingLayout {
EMBEDDING_LAYOUT_UNSPECIFIED = 0;
EMBEDDING_LAYOUT_FINAL = 1;
EMBEDDING_LAYOUT_PER_TOKEN = 2;
}
message EmbeddingResult {
repeated float embeddings = 1;
// Shape of the payload above: dim is the embedding width, tokens is the
// number of vectors packed into `embeddings` (1 when the backend pooled
// server-side, N with pooling:none; total across prompts if a request
// carried several). tokens=0/dim=0 means the backend predates shape
// reporting. prompt_tokens is the number of prompt tokens evaluated, for
// usage accounting.
int32 tokens = 2;
int32 dim = 3;
int32 prompt_tokens = 4;
EmbeddingLayout layout = 5;
}
message TranscriptRequest {
+1 -1
View File
@@ -9,7 +9,7 @@
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
# rebuild and so the bump bot can see the pin.
AUDIO_CPP_VERSION?=7efbb58def443722ea540d931dd3debee3e4d5e8
AUDIO_CPP_VERSION?=f6277c1695a83cf388a8282c1c1a8757cf626f18
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
@@ -29,6 +29,7 @@ const NamedTask kTaskNames[] = {
{Task::VoiceDesign, "vdes"},
{Task::SpeakerRecognition, "spk"},
{Task::Svc, "svc"},
{Task::Midi, "midi"},
};
// Accepted on input but never emitted. "spkrec" was this backend's own earlier
@@ -25,6 +25,7 @@ enum class Task {
VoiceDesign,
SpeakerRecognition,
Svc,
Midi,
};
// Mirrors engine::runtime::RunMode.
@@ -361,7 +361,7 @@ static void test_names_round_trip() {
Task::SourceSeparation, Task::AudioGeneration, Task::Tts,
Task::VoiceCloning, Task::VoiceConversion,
Task::SpeechToSpeech, Task::Alignment, Task::VoiceDesign,
Task::SpeakerRecognition, Task::Svc};
Task::SpeakerRecognition, Task::Svc, Task::Midi};
for (const Task t : all) {
Task parsed = Task::Vad;
const bool ok = parse_task_name(task_name(t), parsed);
+5 -1
View File
@@ -69,7 +69,8 @@ static_assert(kEngine(engine::runtime::VoiceTaskKind::VoiceDesign) == 10, "Voice
static_assert(kEngine(engine::runtime::VoiceTaskKind::SpeakerRecognition) == 11, "VoiceTaskKind drifted");
// The last member. Pinning it pins the member count too, as long as the
// enumerators stay contiguous and unassigned, which upstream's declaration is.
static_assert(kEngine(engine::runtime::VoiceTaskKind::Svc) == 12,
static_assert(kEngine(engine::runtime::VoiceTaskKind::Svc) == 12, "VoiceTaskKind drifted");
static_assert(kEngine(engine::runtime::VoiceTaskKind::Midi) == 13,
"engine::runtime::VoiceTaskKind gained, lost or reordered a member. "
"audiocpp_backend::Task mirrors it positionally: update capability_routing.h, "
"to_engine_task and from_engine_task together, then move this pin.");
@@ -87,6 +88,7 @@ static_assert(kMirror(Task::Alignment) == 9, "Task drifted from VoiceTaskKind");
static_assert(kMirror(Task::VoiceDesign) == 10, "Task drifted from VoiceTaskKind");
static_assert(kMirror(Task::SpeakerRecognition) == 11, "Task drifted from VoiceTaskKind");
static_assert(kMirror(Task::Svc) == 12, "Task drifted from VoiceTaskKind");
static_assert(kMirror(Task::Midi) == 13, "Task drifted from VoiceTaskKind");
static_assert(static_cast<int>(engine::runtime::RunMode::Offline) == 0, "RunMode drifted");
static_assert(static_cast<int>(engine::runtime::RunMode::Streaming) == 1,
@@ -241,6 +243,7 @@ engine::runtime::VoiceTaskKind to_engine_task(Task task) {
case Task::VoiceDesign: return K::VoiceDesign;
case Task::SpeakerRecognition: return K::SpeakerRecognition;
case Task::Svc: return K::Svc;
case Task::Midi: return K::Midi;
}
// Unreachable for any valid enumerator. No `default:` label, so -Wswitch
// still reports a member this switch stops covering.
@@ -263,6 +266,7 @@ Task from_engine_task(engine::runtime::VoiceTaskKind kind) {
case K::VoiceDesign: return Task::VoiceDesign;
case K::SpeakerRecognition: return Task::SpeakerRecognition;
case K::Svc: return Task::Svc;
case K::Midi: return Task::Midi;
}
return Task::Vad;
}
+5 -1
View File
@@ -1,7 +1,7 @@
# Pinned to the HEAD of the `prism` branch on https://github.com/PrismML-Eng/llama.cpp.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
BONSAI_VERSION?=9ca265a57f85f2117942490f421f64a226dd9847
BONSAI_VERSION?=312bb2a93ea2bf798333fa859614fbf913ecb9e2
LLAMA_REPO?=https://github.com/PrismML-Eng/llama.cpp
CMAKE_ARGS?=
@@ -41,7 +41,9 @@ define bonsai-build
# and are applied by apply-patches.sh below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build purge
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
$(info $(GREEN)I bonsai build info:$(1)$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build llama.cpp
@@ -78,7 +80,9 @@ bonsai-cpu-all:
# and are applied by apply-patches.sh below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build purge
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
$(info $(GREEN)I bonsai build info:cpu-all-variants$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build llama.cpp
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Adapt the shared llama.cpp gRPC source to the older JSON API in Bonsai.
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 <grpc-server.cpp>" >&2
exit 2
fi
SRC=$1
if [[ ! -f "$SRC" ]]; then
echo "grpc-server.cpp not found at $SRC" >&2
exit 2
fi
if grep -q 'common_json_error' "$SRC"; then
echo "==> patching $SRC to use the Bonsai JSON exception type"
awk '{ gsub(/common_json_error/, "json::parse_error"); print }' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> Bonsai JSON exception patch OK"
else
echo "==> $SRC already uses a Bonsai-compatible JSON exception type, skipping"
fi
+4 -3
View File
@@ -84,9 +84,10 @@ elseif(DS4_GPU STREQUAL "cpu")
set(DS4_OBJS "${DS4_DIR}/ds4_cpu.o")
endif()
# Upstream splits distributed inference, tensor-parallel transport, the SSD
# expert cache, and layer placement into GPU-agnostic translation units. Link
# them regardless of DS4_GPU.
# Upstream splits image preprocessing, distributed inference, tensor-parallel
# transport, the SSD expert cache, and layer placement into GPU-agnostic
# translation units. Link them regardless of DS4_GPU.
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_image.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_distributed.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_tp.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_ssd.o")
+73 -11
View File
@@ -1,10 +1,10 @@
# ds4 backend Makefile.
#
# Upstream pin lives below as DS4_VERSION?=b0309611041655f4e45671cfd9c9886aff161406
# Upstream pin lives below as DS4_VERSION?=9ab705347c1775e7599ede7eb81a6255ec7dccb5
# (.github/bump_deps.sh) can find and update it - matches the
# llama-cpp / ik-llama-cpp / turboquant convention.
DS4_VERSION?=b0309611041655f4e45671cfd9c9886aff161406
DS4_VERSION?=9ab705347c1775e7599ede7eb81a6255ec7dccb5
DS4_REPO?=https://github.com/antirez/ds4
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
@@ -18,21 +18,83 @@ UNAME_S := $(shell uname -s)
CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
# Upstream splits distributed inference, tensor-parallel transport, the SSD
# expert cache, and layer placement into GPU-agnostic translation units. They
# are shared by every GPU mode, so append them unconditionally below.
# nvcc must be told the target architecture explicitly for a cublas build, and
# this is not a tuning knob. Upstream's Makefile leaves CUDA_ARCH empty and its
# `cuda` target REFUSES to build without one, offering `cuda-spark`
# (CUDA_ARCH=sm_121) and `cuda-generic` (CUDA_ARCH=native) instead. We drive its
# object targets directly, which bypasses that guard: nvcc then compiles with no
# -arch at all, and the kernels run as JIT'd PTX for its default architecture.
# On GB10 (sm_121) that silently produced corrupt inference output above a
# ~128-token prefill batch and ~77x slower prefill (4.21 t/s vs 325.70 t/s,
# measured on the same box with the same model). No CI runner has a GPU, so
# `native` has nothing to enumerate there.
#
# Upstream's CUDA_ARCH takes a SINGLE value (see its sm_120/sm_121 special cases
# and the `-arch=$(CUDA_ARCH)` fallback), so it cannot express the fat binary
# these images need. NVCC_ARCH_FLAGS is overridden instead: a command-line
# assignment wins over the `:=` in upstream's Makefile, and its NVCCFLAGS
# expands whatever we pass.
#
# The architecture lists are copied from backend/go/vllm-cpp/Makefile rather
# than invented, so the two CUDA images cover the same GPUs: amd64 datacenter +
# consumer, and l4t/arm64 covering Orin (87), Thor (110) and GB10 (121a).
#
# -DDS4_CUDA_HAVE_MXF4=1 is deliberately NOT set. Upstream only defines it for
# single-arch sm_120/sm_121 builds and guards the code with a plain #ifdef
# rather than __CUDA_ARCH__, so it cannot be combined with older archs in one
# fat binary. It gates an optional MXFP4 indexer fast path whose #ifndef branch
# returns 0 and falls back to the generic path, so omitting it costs some speed
# on GB10, not correctness. Revisit if upstream adds __CUDA_ARCH__ guards.
#
# An EMPTY CUDA_MAJOR_VERSION means a local developer build, not CI: fall back
# to upstream's own `native` handling, which needs a GPU present but is what a
# developer building on their own machine wants. Both variables are `?=` so an
# explicit value on the command line always wins.
UNAME_M := $(shell uname -m)
CUDA_MAJOR_VERSION ?=
ifeq ($(BUILD_TYPE),cublas)
ifeq ($(CUDA_MAJOR_VERSION),13)
ifeq ($(UNAME_M),aarch64)
DS4_NVCC_ARCH_FLAGS ?= -gencode arch=compute_87,code=sm_87 \
-gencode arch=compute_90a,code=sm_90a \
-gencode arch=compute_100a,code=sm_100a \
-gencode arch=compute_110,code=sm_110 \
-gencode arch=compute_121a,code=sm_121a
else
DS4_NVCC_ARCH_FLAGS ?= -gencode arch=compute_80,code=sm_80 \
-gencode arch=compute_86,code=sm_86 \
-gencode arch=compute_89,code=sm_89 \
-gencode arch=compute_90a,code=sm_90a \
-gencode arch=compute_100a,code=sm_100a \
-gencode arch=compute_103a,code=sm_103a \
-gencode arch=compute_120a,code=sm_120a \
-gencode arch=compute_121a,code=sm_121a
endif
DS4_ARCH_MAKEVARS := NVCC_ARCH_FLAGS="$(DS4_NVCC_ARCH_FLAGS)"
else ifeq ($(CUDA_MAJOR_VERSION),)
# Local build: let upstream resolve the host GPU.
DS4_ARCH_MAKEVARS := CUDA_ARCH=native
else
$(error CUDA_MAJOR_VERSION=$(CUDA_MAJOR_VERSION) has no architecture list here (13 does). Leave it empty for a native build, or pass DS4_NVCC_ARCH_FLAGS explicitly.)
endif
endif
# Upstream splits image preprocessing, distributed inference, tensor-parallel
# transport, the SSD expert cache, and layer placement into GPU-agnostic
# translation units. They are shared by every GPU mode, so append them
# unconditionally below.
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS += -DDS4_GPU=cuda
DS4_OBJ_TARGET := ds4.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o \
DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o \
cuda/mmq/ds4_ggml_stubs.o cuda/mmq/ds4_mmq.o cuda/mmq/ds4_mmq_d2r.o \
cuda/mmq/quantize.o cuda/mmq/mmid.o cuda/mmq/mmvq.o cuda/mmq/ds4_repack.o
else ifeq ($(UNAME_S),Darwin)
CMAKE_ARGS += -DDS4_GPU=metal
DS4_OBJ_TARGET := ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
else
# CPU reference path (Linux only - macOS CPU path is broken by VM bug per ds4 README).
CMAKE_ARGS += -DDS4_GPU=cpu
DS4_OBJ_TARGET := ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
DS4_OBJ_TARGET := ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
endif
ifneq ($(NATIVE),true)
@@ -57,11 +119,11 @@ ds4:
# the right per-platform compile flags (Objective-C/Metal on Darwin, nvcc on Linux+CUDA).
ds4/ds4.o: ds4
ifeq ($(BUILD_TYPE),cublas)
+$(MAKE) -C ds4 $(DS4_OBJ_TARGET)
+$(MAKE) -C ds4 $(DS4_ARCH_MAKEVARS) $(DS4_OBJ_TARGET)
else ifeq ($(UNAME_S),Darwin)
+$(MAKE) -C ds4 ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+$(MAKE) -C ds4 ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
else
+$(MAKE) -C ds4 ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+$(MAKE) -C ds4 ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
endif
grpc-server: ds4/ds4.o
+2 -1
View File
@@ -92,7 +92,8 @@ std::string json_escape(const std::string &in) {
} // namespace
DsmlParser::DsmlParser() = default;
DsmlParser::DsmlParser(bool starts_in_thinking)
: state_(starts_in_thinking ? State::THINK : State::TEXT) {}
bool DsmlParser::IsInDsmlStructural() const {
switch (state_) {
+4 -2
View File
@@ -17,7 +17,9 @@ struct ParserEvent {
// Streaming parser. Stateless across instances; one per Predict call.
class DsmlParser {
public:
DsmlParser();
// The chat prompt may already contain the opening thinking marker, so the
// generated text can begin directly with reasoning bytes.
explicit DsmlParser(bool starts_in_thinking = false);
// Feed a chunk of raw model-emitted text. Appends classified events to
// `out`. May buffer the tail of `chunk` internally if it looks like a
@@ -43,7 +45,7 @@ public:
private:
enum class State { TEXT, THINK, TOOL_CALLS, INVOKE, PARAM_VALUE };
State state_ = State::TEXT;
State state_;
std::string buf_;
std::string current_tool_name_;
int tool_index_ = -1;
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: MIT
// Standalone regression tests for the DSML streaming parser.
//
// The repository's backend/cpp/run-unit-tests.sh harness compiles each
// *_test.cpp as a single translation unit, so include the implementation here.
#include "dsml_parser.cpp"
#include <cstdio>
#include <string>
#include <type_traits>
#include <vector>
namespace {
struct ParsedText {
std::string content;
std::string reasoning;
};
int failures = 0;
void check_equal(const std::string &got, const std::string &want,
const char *name) {
if (got == want) return;
std::fprintf(stderr, "FAIL %s: got \"%s\", want \"%s\"\n",
name, got.c_str(), want.c_str());
failures++;
}
void collect_text(const std::vector<ds4cpp::ParserEvent> &events,
ParsedText *parsed) {
for (const auto &event : events) {
if (event.type == ds4cpp::ParserEvent::CONTENT) {
parsed->content += event.text;
} else if (event.type == ds4cpp::ParserEvent::REASONING) {
parsed->reasoning += event.text;
}
}
}
ParsedText parse_chunks(ds4cpp::DsmlParser *parser,
const std::vector<std::string> &chunks) {
ParsedText parsed;
for (const auto &chunk : chunks) {
std::vector<ds4cpp::ParserEvent> events;
parser->Feed(chunk, events);
collect_text(events, &parsed);
}
std::vector<ds4cpp::ParserEvent> events;
parser->Flush(events);
collect_text(events, &parsed);
return parsed;
}
template <typename Parser>
void test_reasoning_opened_by_prompt() {
if constexpr (!std::is_constructible_v<Parser, bool>) {
std::fprintf(stderr,
"FAIL reasoning_opened_by_prompt: parser cannot start in thinking state\n");
failures++;
} else {
Parser parser(true);
ParsedText parsed = parse_chunks(
&parser,
{"We need to calculate factorial recursively.</think>Here is the answer."});
check_equal(parsed.reasoning,
"We need to calculate factorial recursively.",
"reasoning_opened_by_prompt:reasoning");
check_equal(parsed.content, "Here is the answer.",
"reasoning_opened_by_prompt:content");
}
}
template <typename Parser>
Parser text_parser() {
if constexpr (std::is_constructible_v<Parser, bool>) {
return Parser(false);
} else {
return Parser();
}
}
void test_reasoning_disabled() {
auto parser = text_parser<ds4cpp::DsmlParser>();
ParsedText parsed = parse_chunks(&parser, {"Here is the answer."});
check_equal(parsed.reasoning, "", "reasoning_disabled:reasoning");
check_equal(parsed.content, "Here is the answer.",
"reasoning_disabled:content");
}
void test_explicit_think_tag() {
auto parser = text_parser<ds4cpp::DsmlParser>();
ParsedText parsed = parse_chunks(
&parser, {"<think>reasoning</think>answer"});
check_equal(parsed.reasoning, "reasoning", "explicit_think_tag:reasoning");
check_equal(parsed.content, "answer", "explicit_think_tag:content");
}
template <typename Parser>
void test_split_think_close_marker() {
if constexpr (!std::is_constructible_v<Parser, bool>) {
std::fprintf(stderr,
"FAIL split_think_close_marker: parser cannot start in thinking state\n");
failures++;
} else {
Parser parser(true);
ParsedText parsed = parse_chunks(
&parser,
{"We need ", "to calculate ", "factorial", "</thi", "nk>",
"Here is ", "the answer."});
check_equal(parsed.reasoning, "We need to calculate factorial",
"split_think_close_marker:reasoning");
check_equal(parsed.content, "Here is the answer.",
"split_think_close_marker:content");
}
}
} // namespace
int main() {
test_reasoning_opened_by_prompt<ds4cpp::DsmlParser>();
test_reasoning_disabled();
test_explicit_think_tag();
test_split_think_close_marker<ds4cpp::DsmlParser>();
if (failures == 0) {
std::fprintf(stderr, "all dsml_parser checks passed\n");
return 0;
}
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <algorithm>
namespace ds4cpp {
inline int EffectiveGenerationLimit(int requested, int context_size,
int session_position) {
const int limit = requested > 0 ? requested : 256;
const int room = context_size - session_position;
if (room <= 1) return 0;
return std::min(limit, room - 1);
}
inline int RemainingGenerationBudget(int effective_limit, int produced) {
if (effective_limit <= produced) return 0;
return effective_limit - produced;
}
inline int SpeculativeAcceptedCapacity(int remaining, int draft_allowance,
int buffer_capacity) {
if (remaining <= 0 || draft_allowance < 0 || buffer_capacity <= 0) return 0;
return std::min({remaining, draft_allowance + 1, buffer_capacity});
}
} // namespace ds4cpp
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: MIT
#include "generation_limits.h"
#include <cstdio>
namespace {
int failures = 0;
void check_equal(int got, int want, const char *name) {
if (got == want) return;
std::fprintf(stderr, "FAIL %s: got %d, want %d\n", name, got, want);
failures++;
}
// Mutation caught: treating omitted or negative max_tokens as unlimited instead
// of preserving DS4's legacy 256-token default.
void test_nonpositive_uses_legacy_default_when_space_permits() {
check_equal(ds4cpp::EffectiveGenerationLimit(0, 4096, 100), 256,
"zero max_tokens uses legacy default");
check_equal(ds4cpp::EffectiveGenerationLimit(-1, 4096, 100), 256,
"negative max_tokens uses legacy default");
}
// Mutation caught: applying the legacy default without clamping it to the
// post-prefill context room and reserved slot.
void test_legacy_default_is_clamped_by_context() {
check_equal(ds4cpp::EffectiveGenerationLimit(0, 300, 100), 199,
"legacy default is context-clamped");
}
// Mutation caught: allowing an explicitly large request to overrun the
// post-prefill context boundary.
void test_large_positive_limit_is_clamped_to_context() {
check_equal(ds4cpp::EffectiveGenerationLimit(32768, 32768, 100), 32667,
"large positive is context-clamped");
}
// Mutation caught: replacing every positive request with the legacy default
// rather than preserving a smaller configured limit.
void test_smaller_positive_limit_is_preserved() {
check_equal(ds4cpp::EffectiveGenerationLimit(64, 4096, 100), 64,
"smaller positive is preserved");
}
// Mutation caught: consuming the final context slot instead of reserving it as
// required by DS4's generation loop.
void test_no_usable_room_returns_zero() {
check_equal(ds4cpp::EffectiveGenerationLimit(32, 100, 99), 0,
"one remaining context slot is not usable");
}
// Mutation caught: sending the original generation limit to a later
// speculative cycle instead of subtracting tokens already produced.
void test_remaining_budget_accounts_for_produced_tokens() {
check_equal(ds4cpp::RemainingGenerationBudget(10, 4), 6,
"remaining budget subtracts produced tokens");
check_equal(ds4cpp::RemainingGenerationBudget(10, 12), 0,
"remaining budget never becomes negative");
}
// Mutation caught: giving speculative evaluation capacity beyond either the
// output budget, the draft allowance plus its first target token, or the fixed
// accepted-token buffer.
void test_speculative_capacity_obeys_all_bounds() {
check_equal(ds4cpp::SpeculativeAcceptedCapacity(3, 8, 8), 3,
"capacity respects remaining output budget");
check_equal(ds4cpp::SpeculativeAcceptedCapacity(20, 4, 8), 5,
"capacity includes one target token beyond draft allowance");
check_equal(ds4cpp::SpeculativeAcceptedCapacity(20, 8, 6), 6,
"capacity respects fixed buffer");
}
} // namespace
int main() {
test_nonpositive_uses_legacy_default_when_space_permits();
test_legacy_default_is_clamped_by_context();
test_large_positive_limit_is_clamped_to_context();
test_smaller_positive_limit_is_preserved();
test_no_usable_room_returns_zero();
test_remaining_budget_accounts_for_produced_tokens();
test_speculative_capacity_obeys_all_bounds();
if (failures == 0) {
std::fprintf(stderr, "all generation limit checks passed\n");
return 0;
}
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
+184 -55
View File
@@ -10,7 +10,9 @@
#include "dsml_parser.h" // populated in Task 12
#include "dsml_renderer.h" // populated in Task 16
#include "generation_limits.h"
#include "kv_cache.h" // populated in Task 17
#include "request_lifecycle.h"
extern "C" {
#include "ds4.h"
@@ -35,6 +37,7 @@ extern "C" {
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
using grpc::Server;
@@ -69,6 +72,21 @@ int g_route_timeout_sec = 60;
std::atomic<Server *> g_server{nullptr};
static bool server_context_cancelled(void *ud) {
return static_cast<ServerContext *>(ud)->IsCancelled();
}
static void set_session_cancel(void *target, ds4cpp::CancelCallback callback,
void *userdata) noexcept {
ds4_session_set_cancel(static_cast<ds4_session *>(target), callback, userdata);
}
static bool request_should_continue(ds4cpp::RequestLifecycle *request,
ServerContext *context) {
request->ObserveContextCancellation(context->IsCancelled());
return request->ShouldContinue();
}
// Parse a "key:value" option string. Returns empty when no colon.
static std::pair<std::string, std::string> split_option(const std::string &opt) {
auto colon = opt.find(':');
@@ -238,37 +256,58 @@ static bool apply_engine_option(ds4_engine_options *opt, const std::string &key,
// When acting as a distributed coordinator, block until the worker route
// covers all layers (ds4_session_distributed_route_ready == 1) or the timeout
// elapses. Returns an empty string on success, or an error message to return
// to the client. No-op when not distributed.
// elapses. No-op when not distributed.
//
// Takes the g_engine_mu lock by reference and RELEASES it during each poll
// sleep. The wait can span up to g_route_timeout_sec seconds while workers
// connect; holding g_engine_mu the whole time would block the Status/Health
// readiness probes (they also lock g_engine_mu), making LocalAI's loader treat
// a still-starting worker as hung.
static std::string wait_route_ready(std::unique_lock<std::mutex> &lock) {
if (!g_distributed) return "";
struct RouteWaitResult {
ds4cpp::RouteWaitDecision decision;
std::string error;
};
static RouteWaitResult wait_route_ready(std::unique_lock<std::mutex> &lock,
ServerContext *context) {
if (!g_distributed) return {ds4cpp::RouteWaitDecision::Ready, ""};
char err[256] = {0};
const int deadline_polls = g_route_timeout_sec * 10; // 100ms per poll
for (int i = 0; i <= deadline_polls; ++i) {
int ready = ds4_session_distributed_route_ready(g_session, err, sizeof(err));
if (ready == 1) return "";
if (ready < 0) {
return std::string("ds4 distributed route error: ") +
(err[0] ? err : "unknown");
switch (ds4cpp::DecideRouteWait(ready, context->IsCancelled())) {
case ds4cpp::RouteWaitDecision::Ready:
return {ds4cpp::RouteWaitDecision::Ready, ""};
case ds4cpp::RouteWaitDecision::Error:
return {ds4cpp::RouteWaitDecision::Error,
std::string("ds4 distributed route error: ") +
(err[0] ? err : "unknown")};
case ds4cpp::RouteWaitDecision::Cancelled:
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
case ds4cpp::RouteWaitDecision::Pending:
break;
}
if (i == deadline_polls) break;
// Release the lock while sleeping so Status/Health and other RPCs can
// interleave during worker startup.
lock.unlock();
struct timespec ts = {0, 100L * 1000L * 1000L}; // 100ms
nanosleep(&ts, nullptr);
lock.lock();
if (context->IsCancelled()) {
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
}
// A concurrent Free() may have torn down the engine while we slept.
if (!g_engine || !g_session) {
return "ds4: model unloaded while waiting for distributed route";
return {ds4cpp::RouteWaitDecision::Error,
"ds4: model unloaded while waiting for distributed route"};
}
}
return "ds4 distributed route incomplete: workers not connected (layers uncovered)";
if (context->IsCancelled()) {
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
}
return {ds4cpp::RouteWaitDecision::Error,
"ds4 distributed route incomplete: workers not connected (layers uncovered)"};
}
static void append_token_text(ds4_engine *engine, int token, std::string &out) {
@@ -341,9 +380,9 @@ static void collect_done(void *) {}
struct StreamCtx {
ds4_engine *engine;
ServerWriter<backend::Reply> *writer;
ds4cpp::RequestLifecycle *request;
ds4cpp::DsmlParser parser;
int tokens;
bool aborted;
// Track which tool indices we've seen TOOL_START for, so subsequent
// ARGS deltas can elide the redundant id/name fields.
std::vector<bool> tool_started;
@@ -351,7 +390,7 @@ struct StreamCtx {
static void stream_emit(void *ud, int token) {
auto *s = static_cast<StreamCtx *>(ud);
if (s->aborted) return;
if (!s->request->ShouldContinue()) return;
if (token == ds4_token_eos(s->engine)) return;
size_t len = 0;
const char *text = ds4_token_text(s->engine, token, &len);
@@ -401,7 +440,7 @@ static void stream_emit(void *ud, int token) {
reply.set_message(chunk);
reply.set_tokens(1);
if (any_field) {
if (!s->writer->Write(reply)) s->aborted = true;
s->request->ObserveStreamWrite(s->writer->Write(reply));
}
s->tokens++;
}
@@ -757,21 +796,30 @@ public:
return GStatus::OK;
}
GStatus Predict(ServerContext *, const backend::PredictOptions *request,
GStatus Predict(ServerContext *context, const backend::PredictOptions *request,
backend::Reply *reply) override {
std::unique_lock<std::mutex> lock(g_engine_mu);
if (!g_engine || !g_session) {
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
}
if (GStatus id = check_model_identity(request); !id.ok()) return id;
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
return GStatus(StatusCode::UNAVAILABLE, route_err);
RouteWaitResult route = wait_route_ready(lock, context);
if (route.decision == ds4cpp::RouteWaitDecision::Cancelled) {
return GStatus(StatusCode::CANCELLED, "ds4 request cancelled");
}
if (route.decision == ds4cpp::RouteWaitDecision::Error) {
return GStatus(StatusCode::UNAVAILABLE, route.error);
}
ds4_tokens prompt = {};
build_prompt(g_engine, request, &prompt);
int n_predict = request->tokens() > 0 ? request->tokens() : 256;
CollectCtx collect = {g_engine, "", {}, reply, 0, {}, "", ""};
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
const bool starts_in_thinking = think_enabled &&
request->usetokenizertemplate() && request->messages_size() > 0;
CollectCtx collect = {
g_engine, "", ds4cpp::DsmlParser(starts_in_thinking),
reply, 0, {}, "", ""};
ds4cpp::RequestLifecycle lifecycle;
std::string cache_key = render_prompt_text(request);
size_t cache_hit = maybe_load_cache(cache_key);
(void)cache_hit; // future: skip prompt prefix if hit covers full prompt
@@ -783,15 +831,27 @@ public:
// Either way g_session advances so the disk KV cache picks up a
// real checkpoint after the call (see maybe_save_cache below).
char err[256] = {0};
int rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
int rc;
{
ds4cpp::CancelCallbackScope cancel_scope(
g_session, set_session_cancel, server_context_cancelled, context);
rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
}
int prompt_len = prompt.len;
ds4_tokens_free(&prompt);
if (rc == 0) {
if (rc == DS4_SESSION_SYNC_INTERRUPTED) {
lifecycle.ObserveContextCancellation(true);
}
const bool generation_started = rc == 0;
if (generation_started) {
const int n_predict = ds4cpp::EffectiveGenerationLimit(
request->tokens(), ds4_session_ctx(g_session),
ds4_session_pos(g_session));
const int eos = ds4_token_eos(g_engine);
const int draft_max = ds4_engine_mtp_draft_tokens(g_engine);
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
int produced = 0;
while (produced < n_predict) {
if (!request_should_continue(&lifecycle, context)) break;
SampleParams sp = compute_sample_params(request, collect.parser, think_enabled);
int first;
if (sp.temperature <= 0.0f) {
@@ -806,13 +866,20 @@ public:
if (draft_max > 0 && sp.temperature <= 0.0f) {
constexpr int kAcceptedMax = 8;
int accepted[kAcceptedMax];
int cap = std::min(kAcceptedMax, draft_max + 1);
const int remaining = ds4cpp::RemainingGenerationBudget(
n_predict, produced);
const int cap = ds4cpp::SpeculativeAcceptedCapacity(
remaining, draft_max, kAcceptedMax);
int n = ds4_session_eval_speculative_argmax(
g_session, first, draft_max, eos,
g_session, first, remaining, eos,
accepted, cap, err, sizeof(err));
if (n < 0) { rc = -1; break; }
bool stop = false;
for (int j = 0; j < n; ++j) {
if (!request_should_continue(&lifecycle, context)) {
stop = true;
break;
}
if (accepted[j] == eos) { stop = true; break; }
collect_emit(&collect, accepted[j]);
if (++produced >= n_predict) { stop = true; break; }
@@ -821,12 +888,26 @@ public:
} else {
collect_emit(&collect, first);
if (++produced >= n_predict) break;
if (!request_should_continue(&lifecycle, context)) break;
rc = ds4_session_eval(g_session, first, err, sizeof(err));
if (rc != 0) break;
}
}
collect_done(&collect);
}
request_should_continue(&lifecycle, context);
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
rc == DS4_SESSION_SYNC_INTERRUPTED, rc != 0,
!lifecycle.ShouldFinalize());
if (!terminal.should_finalize) {
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
return GStatus(StatusCode::INTERNAL,
std::string("ds4 generation failed: ") + err);
}
return GStatus(StatusCode::CANCELLED,
"ds4 request cancelled");
}
if (generation_started) collect_done(&collect);
maybe_save_cache(cache_key);
// Flush any buffered parser state.
@@ -834,7 +915,7 @@ public:
collect.parser.Flush(events);
apply_events(&collect, events);
if (rc != 0) {
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
return GStatus(StatusCode::INTERNAL,
std::string("ds4 generation failed: ") + err);
}
@@ -857,21 +938,30 @@ public:
return GStatus::OK;
}
GStatus PredictStream(ServerContext *, const backend::PredictOptions *request,
GStatus PredictStream(ServerContext *context, const backend::PredictOptions *request,
ServerWriter<backend::Reply> *writer) override {
std::unique_lock<std::mutex> lock(g_engine_mu);
if (!g_engine || !g_session) {
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
}
if (GStatus id = check_model_identity(request); !id.ok()) return id;
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
return GStatus(StatusCode::UNAVAILABLE, route_err);
RouteWaitResult route = wait_route_ready(lock, context);
if (route.decision == ds4cpp::RouteWaitDecision::Cancelled) {
return GStatus(StatusCode::CANCELLED, "ds4 request cancelled");
}
if (route.decision == ds4cpp::RouteWaitDecision::Error) {
return GStatus(StatusCode::UNAVAILABLE, route.error);
}
ds4_tokens prompt = {};
build_prompt(g_engine, request, &prompt);
int n_predict = request->tokens() > 0 ? request->tokens() : 256;
StreamCtx s = {g_engine, writer, {}, 0, false, {}};
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
const bool starts_in_thinking = think_enabled &&
request->usetokenizertemplate() && request->messages_size() > 0;
ds4cpp::RequestLifecycle lifecycle;
StreamCtx s = {
g_engine, writer, &lifecycle,
ds4cpp::DsmlParser(starts_in_thinking), 0, {}};
std::string cache_key = render_prompt_text(request);
size_t cache_hit = maybe_load_cache(cache_key);
(void)cache_hit;
@@ -879,14 +969,26 @@ public:
// Manual loop on g_session - see Predict() above for the rationale.
// MTP speculative path used when ds4_engine_mtp_draft_tokens > 0.
char err[256] = {0};
int rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
int rc;
{
ds4cpp::CancelCallbackScope cancel_scope(
g_session, set_session_cancel, server_context_cancelled, context);
rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
}
ds4_tokens_free(&prompt);
if (rc == 0) {
if (rc == DS4_SESSION_SYNC_INTERRUPTED) {
lifecycle.ObserveContextCancellation(true);
}
const bool generation_started = rc == 0;
if (generation_started) {
const int n_predict = ds4cpp::EffectiveGenerationLimit(
request->tokens(), ds4_session_ctx(g_session),
ds4_session_pos(g_session));
const int eos = ds4_token_eos(g_engine);
const int draft_max = ds4_engine_mtp_draft_tokens(g_engine);
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
int produced = 0;
while (produced < n_predict && !s.aborted) {
while (produced < n_predict) {
if (!request_should_continue(&lifecycle, context)) break;
SampleParams sp = compute_sample_params(request, s.parser, think_enabled);
int first;
if (sp.temperature <= 0.0f) {
@@ -900,50 +1002,77 @@ public:
if (draft_max > 0 && sp.temperature <= 0.0f) {
constexpr int kAcceptedMax = 8;
int accepted[kAcceptedMax];
int cap = std::min(kAcceptedMax, draft_max + 1);
const int remaining = ds4cpp::RemainingGenerationBudget(
n_predict, produced);
const int cap = ds4cpp::SpeculativeAcceptedCapacity(
remaining, draft_max, kAcceptedMax);
int n = ds4_session_eval_speculative_argmax(
g_session, first, draft_max, eos,
g_session, first, remaining, eos,
accepted, cap, err, sizeof(err));
if (n < 0) { rc = -1; break; }
bool stop = false;
for (int j = 0; j < n; ++j) {
if (!request_should_continue(&lifecycle, context)) {
stop = true;
break;
}
if (accepted[j] == eos) { stop = true; break; }
stream_emit(&s, accepted[j]);
if (s.aborted) { stop = true; break; }
if (!lifecycle.ShouldContinue()) { stop = true; break; }
if (++produced >= n_predict) { stop = true; break; }
}
if (stop) break;
} else {
stream_emit(&s, first);
if (s.aborted || ++produced >= n_predict) break;
if (!lifecycle.ShouldContinue() || ++produced >= n_predict) break;
if (!request_should_continue(&lifecycle, context)) break;
rc = ds4_session_eval(g_session, first, err, sizeof(err));
if (rc != 0) break;
}
}
stream_done(&s);
}
maybe_save_cache(cache_key);
// Flush parser state.
std::vector<ds4cpp::ParserEvent> events;
s.parser.Flush(events);
if (!events.empty() && !s.aborted) {
backend::Reply reply;
auto *delta = reply.add_chat_deltas();
for (const auto &e : events) {
if (e.type == ds4cpp::ParserEvent::CONTENT) {
delta->set_content(delta->content() + e.text);
} else if (e.type == ds4cpp::ParserEvent::REASONING) {
delta->set_reasoning_content(delta->reasoning_content() + e.text);
request_should_continue(&lifecycle, context);
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
rc == DS4_SESSION_SYNC_INTERRUPTED, rc != 0,
!lifecycle.ShouldFinalize());
terminal = ds4cpp::RunPostlude(
terminal,
[&]() {
ds4cpp::DsmlParser staged_parser = s.parser;
std::vector<ds4cpp::ParserEvent> events;
staged_parser.Flush(events);
bool write_succeeded = true;
if (!events.empty()) {
backend::Reply reply;
auto *delta = reply.add_chat_deltas();
for (const auto &e : events) {
if (e.type == ds4cpp::ParserEvent::CONTENT) {
delta->set_content(delta->content() + e.text);
} else if (e.type == ds4cpp::ParserEvent::REASONING) {
delta->set_reasoning_content(
delta->reasoning_content() + e.text);
}
}
write_succeeded = s.writer->Write(reply);
}
}
s.writer->Write(reply);
}
lifecycle.ObserveStreamWrite(write_succeeded);
request_should_continue(&lifecycle, context);
if (!lifecycle.ShouldFinalize()) return false;
s.parser = std::move(staged_parser);
if (generation_started) stream_done(&s);
return true;
},
[&]() { maybe_save_cache(cache_key); });
if (rc != 0 && !s.aborted) {
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
return GStatus(StatusCode::INTERNAL,
std::string("ds4 generation failed: ") + err);
}
if (terminal.cause == ds4cpp::TerminalCause::Cancelled) {
return GStatus(StatusCode::CANCELLED,
"ds4 request cancelled");
}
return GStatus::OK;
}
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: MIT
#pragma once
namespace ds4cpp {
using CancelCallback = bool (*)(void *);
using CancelSetter = void (*)(void *, CancelCallback, void *) noexcept;
class CancelCallbackScope {
public:
CancelCallbackScope(void *target, CancelSetter setter,
CancelCallback callback, void *userdata) noexcept
: target_(target), setter_(setter) {
setter_(target_, callback, userdata);
}
~CancelCallbackScope() noexcept {
setter_(target_, nullptr, nullptr);
}
CancelCallbackScope(const CancelCallbackScope &) = delete;
CancelCallbackScope &operator=(const CancelCallbackScope &) = delete;
private:
void *target_;
CancelSetter setter_;
};
enum class RouteWaitDecision {
Pending,
Ready,
Error,
Cancelled,
};
inline RouteWaitDecision DecideRouteWait(int route_status, bool cancelled) {
if (cancelled) return RouteWaitDecision::Cancelled;
if (route_status > 0) return RouteWaitDecision::Ready;
if (route_status < 0) return RouteWaitDecision::Error;
return RouteWaitDecision::Pending;
}
enum class TerminalCause {
Success,
Cancelled,
EngineError,
};
inline TerminalCause DecideTerminalCause(bool sync_interrupted,
bool engine_error,
bool abandoned) {
if (sync_interrupted) return TerminalCause::Cancelled;
if (engine_error) return TerminalCause::EngineError;
if (abandoned) return TerminalCause::Cancelled;
return TerminalCause::Success;
}
struct TerminalDecision {
TerminalCause cause;
bool should_finalize;
};
inline TerminalDecision ResolveTerminalDecision(bool sync_interrupted,
bool engine_error,
bool abandoned) {
return {
DecideTerminalCause(sync_interrupted, engine_error, abandoned),
!sync_interrupted && !abandoned,
};
}
template <typename Finalize, typename Persist>
TerminalDecision RunPostlude(TerminalDecision terminal,
Finalize transactional_finalize,
Persist persist) {
if (!terminal.should_finalize) return terminal;
if (!transactional_finalize()) {
terminal.should_finalize = false;
if (terminal.cause != TerminalCause::EngineError) {
terminal.cause = TerminalCause::Cancelled;
}
return terminal;
}
persist();
return terminal;
}
class RequestLifecycle {
public:
void ObserveContextCancellation(bool cancelled) {
context_cancelled_ = context_cancelled_ || cancelled;
}
void ObserveStreamWrite(bool succeeded) {
stream_write_aborted_ = stream_write_aborted_ || !succeeded;
}
bool ShouldContinue() const {
return !context_cancelled_ && !stream_write_aborted_;
}
bool ShouldFinalize() const {
return ShouldContinue();
}
private:
bool context_cancelled_ = false;
bool stream_write_aborted_ = false;
};
} // namespace ds4cpp
+414
View File
@@ -0,0 +1,414 @@
// SPDX-License-Identifier: MIT
// Standalone regression tests for DS4 request cancellation policy.
#include "request_lifecycle.h"
#include <cstdio>
namespace {
int failures = 0;
struct FakeCancelTarget {
ds4cpp::CancelCallback callback = nullptr;
void *userdata = nullptr;
int installs = 0;
int clears = 0;
};
struct PostludeCounts {
int finalize_attempts = 0;
int finalize_commits = 0;
int cache_persists = 0;
bool cache_followed_commit = true;
};
ds4cpp::TerminalDecision run_fake_postlude(
ds4cpp::TerminalDecision terminal, bool finalize_succeeds,
PostludeCounts *counts) {
return ds4cpp::RunPostlude(
terminal,
[=]() {
counts->finalize_attempts++;
if (!finalize_succeeds) return false;
counts->finalize_commits++;
return true;
},
[=]() {
counts->cache_followed_commit = counts->finalize_commits == 1;
counts->cache_persists++;
});
}
bool fake_cancel(void *) {
return false;
}
void fake_set_cancel(void *target, ds4cpp::CancelCallback callback,
void *userdata) noexcept {
auto *fake = static_cast<FakeCancelTarget *>(target);
fake->callback = callback;
fake->userdata = userdata;
if (callback) {
fake->installs++;
} else {
fake->clears++;
}
}
void check(bool condition, const char *name) {
if (condition) return;
std::fprintf(stderr, "FAIL %s\n", name);
failures++;
}
// Production mutation caught: treating an active request as abandoned would
// skip its parser finalization and cache save.
void test_active_request_continues_and_finalizes() {
ds4cpp::RequestLifecycle request;
check(request.ShouldContinue(), "active:continue");
check(request.ShouldFinalize(), "active:finalize");
}
// Production mutation caught: omitting the ServerContext cancellation branch
// would continue decoding and finalize a partial response.
void test_context_cancellation_stops_without_finalizing() {
ds4cpp::RequestLifecycle request;
request.ObserveContextCancellation(true);
check(!request.ShouldContinue(), "context_cancelled:stop");
check(!request.ShouldFinalize(), "context_cancelled:no_finalize");
}
// Production mutation caught: ignoring ServerWriter::Write failure would keep
// streaming and finalize a response whose client has gone away.
void test_stream_write_abort_stops_without_finalizing() {
ds4cpp::RequestLifecycle request;
request.ObserveStreamWrite(false);
check(!request.ShouldContinue(), "write_abort:stop");
check(!request.ShouldFinalize(), "write_abort:no_finalize");
}
// Production mutation caught: combining cancellation and write failure with
// AND would fail to stop when either signal occurs on its own.
void test_cancellation_and_write_abort_are_independent_or_conditions() {
ds4cpp::RequestLifecycle cancelled;
cancelled.ObserveContextCancellation(true);
cancelled.ObserveStreamWrite(true);
ds4cpp::RequestLifecycle write_aborted;
write_aborted.ObserveContextCancellation(false);
write_aborted.ObserveStreamWrite(false);
check(!cancelled.ShouldContinue(), "or:context_only");
check(!write_aborted.ShouldContinue(), "or:write_only");
}
// Production mutation caught: treating an incomplete distributed route as an
// error would return before workers have time to connect.
void test_route_wait_pending() {
check(ds4cpp::DecideRouteWait(0, false) ==
ds4cpp::RouteWaitDecision::Pending,
"route_wait:pending");
}
// Production mutation caught: failing to recognize a complete route would
// keep a ready inference request in the polling loop.
void test_route_wait_ready() {
check(ds4cpp::DecideRouteWait(1, false) ==
ds4cpp::RouteWaitDecision::Ready,
"route_wait:ready");
}
// Production mutation caught: ignoring a route probe error would poll until a
// misleading timeout instead of returning UNAVAILABLE promptly.
void test_route_wait_error() {
check(ds4cpp::DecideRouteWait(-1, false) ==
ds4cpp::RouteWaitDecision::Error,
"route_wait:error");
}
// Production mutation caught: omitting cancellation from route waiting would
// leave an abandoned request blocked until the distributed timeout.
void test_route_wait_cancellation() {
check(ds4cpp::DecideRouteWait(0, true) ==
ds4cpp::RouteWaitDecision::Cancelled,
"route_wait:cancelled");
}
// Production mutation caught: checking route errors before cancellation would
// report UNAVAILABLE for a request the client already abandoned.
void test_route_wait_cancellation_precedes_error() {
check(ds4cpp::DecideRouteWait(-1, true) ==
ds4cpp::RouteWaitDecision::Cancelled,
"route_wait:cancellation_precedence");
}
// Production mutation caught: classifying a successful active request as a
// terminal failure would suppress its normal response finalization.
void test_terminal_success() {
check(ds4cpp::DecideTerminalCause(false, false, false) ==
ds4cpp::TerminalCause::Success,
"terminal:success");
}
// Production mutation caught: treating DS4's cooperative sync interruption
// as an ordinary engine error would return INTERNAL instead of CANCELLED.
void test_terminal_sync_interruption_is_cancelled() {
check(ds4cpp::DecideTerminalCause(true, true, true) ==
ds4cpp::TerminalCause::Cancelled,
"terminal:sync_interrupted");
}
// Production mutation caught: treating every nonzero engine result as client
// abandonment would hide genuine DS4 failures behind CANCELLED.
void test_terminal_engine_error() {
check(ds4cpp::DecideTerminalCause(false, true, false) ==
ds4cpp::TerminalCause::EngineError,
"terminal:engine_error");
}
// Production mutation caught: ignoring an rc==0 context cancellation would
// finalize and cache an abandoned request.
void test_terminal_context_abandonment() {
ds4cpp::RequestLifecycle request;
request.ObserveContextCancellation(true);
check(ds4cpp::DecideTerminalCause(
false, false, !request.ShouldFinalize()) ==
ds4cpp::TerminalCause::Cancelled,
"terminal:context_abandonment");
}
// Production mutation caught: ignoring an rc==0 stream write failure would
// finalize and cache an abandoned streaming request.
void test_terminal_write_abandonment() {
ds4cpp::RequestLifecycle request;
request.ObserveStreamWrite(false);
check(ds4cpp::DecideTerminalCause(
false, false, !request.ShouldFinalize()) ==
ds4cpp::TerminalCause::Cancelled,
"terminal:write_abandonment");
}
// Production mutation caught: checking late cancellation or write failure
// before a determined ordinary DS4 error would replace INTERNAL with CANCELLED.
void test_terminal_engine_error_precedes_late_abandonment() {
ds4cpp::RequestLifecycle cancelled;
cancelled.ObserveContextCancellation(true);
ds4cpp::RequestLifecycle write_aborted;
write_aborted.ObserveStreamWrite(false);
check(ds4cpp::DecideTerminalCause(
false, true, !cancelled.ShouldFinalize()) ==
ds4cpp::TerminalCause::EngineError,
"terminal:engine_error_precedes_cancellation");
check(ds4cpp::DecideTerminalCause(
false, true, !write_aborted.ShouldFinalize()) ==
ds4cpp::TerminalCause::EngineError,
"terminal:engine_error_precedes_write_abort");
}
// Production mutation caught: using status precedence alone to gate side
// effects would finalize and persist an engine-error request abandoned later.
void test_abandoned_engine_error_keeps_internal_without_finalizing() {
ds4cpp::RequestLifecycle request;
request.ObserveContextCancellation(true);
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
false, true, !request.ShouldFinalize());
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"terminal_decision:abandoned_engine_error_status");
check(!terminal.should_finalize,
"terminal_decision:abandoned_engine_error_no_finalize");
}
// Production mutation caught: suppressing side effects for every engine error
// would change the existing finalization and cache behavior of active failures.
void test_active_engine_error_still_finalizes() {
ds4cpp::RequestLifecycle request;
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
false, true, !request.ShouldFinalize());
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"terminal_decision:active_engine_error_status");
check(terminal.should_finalize,
"terminal_decision:active_engine_error_finalize");
}
// Production mutation caught: persisting before committed finalization would
// cache a state whose final buffered stream reply was never completed.
void test_postlude_active_success_commits_then_persists() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::Success, true}, true, &counts);
check(terminal.cause == ds4cpp::TerminalCause::Success,
"postlude:success_outcome");
check(terminal.should_finalize, "postlude:success_committed");
check(counts.finalize_attempts == 1, "postlude:success_attempts");
check(counts.finalize_commits == 1, "postlude:success_commits");
check(counts.cache_persists == 1, "postlude:success_cache");
check(counts.cache_followed_commit, "postlude:success_cache_order");
}
// Production mutation caught: starting the postlude for an already-cancelled
// request would flush buffered parser state or persist an abandoned session.
void test_postlude_cancellation_skips_all_side_effects() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::Cancelled, false}, true, &counts);
check(terminal.cause == ds4cpp::TerminalCause::Cancelled,
"postlude:cancelled_outcome");
check(counts.finalize_attempts == 0, "postlude:cancelled_attempts");
check(counts.finalize_commits == 0, "postlude:cancelled_commits");
check(counts.cache_persists == 0, "postlude:cancelled_cache");
}
// Production mutation caught: committing the live parser or cache after a
// failed final Write would publish an abandoned streaming postlude.
void test_postlude_finalize_failure_cancels_without_commit_or_cache() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::Success, true}, false, &counts);
check(terminal.cause == ds4cpp::TerminalCause::Cancelled,
"postlude:write_failure_outcome");
check(!terminal.should_finalize, "postlude:write_failure_not_committed");
check(counts.finalize_attempts == 1, "postlude:write_failure_attempts");
check(counts.finalize_commits == 0, "postlude:write_failure_commits");
check(counts.cache_persists == 0, "postlude:write_failure_cache");
}
// Production mutation caught: skipping the postlude for every engine error
// would change active internal-error finalization and cache behavior.
void test_postlude_active_engine_error_finalizes_and_persists() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::EngineError, true}, true, &counts);
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"postlude:engine_error_outcome");
check(counts.finalize_attempts == 1, "postlude:engine_error_attempts");
check(counts.finalize_commits == 1, "postlude:engine_error_commits");
check(counts.cache_persists == 1, "postlude:engine_error_cache");
check(counts.cache_followed_commit, "postlude:engine_error_cache_order");
}
// Production mutation caught: replacing every failed transactional finalize
// with cancellation would hide an already-determined engine error.
void test_postlude_engine_error_finalize_failure_preserves_internal() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::EngineError, true}, false, &counts);
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"postlude:engine_error_write_failure_outcome");
check(!terminal.should_finalize,
"postlude:engine_error_write_failure_not_committed");
check(counts.finalize_attempts == 1,
"postlude:engine_error_write_failure_attempts");
check(counts.finalize_commits == 0,
"postlude:engine_error_write_failure_commits");
check(counts.cache_persists == 0,
"postlude:engine_error_write_failure_cache");
}
// Production mutation caught: status precedence must not grant side-effect
// permission to an engine-error request that was also abandoned.
void test_postlude_abandoned_engine_error_skips_all_side_effects() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::EngineError, false}, true, &counts);
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"postlude:abandoned_engine_error_outcome");
check(counts.finalize_attempts == 0,
"postlude:abandoned_engine_error_attempts");
check(counts.finalize_commits == 0,
"postlude:abandoned_engine_error_commits");
check(counts.cache_persists == 0,
"postlude:abandoned_engine_error_cache");
}
// Production mutation caught: failing to install the request callback would
// make DS4 prompt synchronization unable to observe client cancellation.
void test_cancel_callback_scope_installs_callback() {
FakeCancelTarget target;
int request_context = 42;
{
ds4cpp::CancelCallbackScope scope(
&target, fake_set_cancel, fake_cancel, &request_context);
check(target.callback == fake_cancel, "cancel_scope:callback_installed");
check(target.userdata == &request_context, "cancel_scope:userdata_installed");
check(target.installs == 1, "cancel_scope:installed_once");
}
}
// Production mutation caught: failing to clear the callback at every scope
// exit would leave DS4 pointing at a destroyed stack-owned ServerContext.
void test_cancel_callback_scope_clears_callback() {
FakeCancelTarget target;
int request_context = 42;
{
ds4cpp::CancelCallbackScope scope(
&target, fake_set_cancel, fake_cancel, &request_context);
}
check(target.callback == nullptr, "cancel_scope:callback_cleared");
check(target.userdata == nullptr, "cancel_scope:userdata_cleared");
check(target.clears == 1, "cancel_scope:cleared_once");
}
} // namespace
int main() {
test_active_request_continues_and_finalizes();
test_context_cancellation_stops_without_finalizing();
test_stream_write_abort_stops_without_finalizing();
test_cancellation_and_write_abort_are_independent_or_conditions();
test_route_wait_pending();
test_route_wait_ready();
test_route_wait_error();
test_route_wait_cancellation();
test_route_wait_cancellation_precedes_error();
test_terminal_success();
test_terminal_sync_interruption_is_cancelled();
test_terminal_engine_error();
test_terminal_context_abandonment();
test_terminal_write_abandonment();
test_terminal_engine_error_precedes_late_abandonment();
test_abandoned_engine_error_keeps_internal_without_finalizing();
test_active_engine_error_still_finalizes();
test_postlude_active_success_commits_then_persists();
test_postlude_cancellation_skips_all_side_effects();
test_postlude_finalize_failure_cancels_without_commit_or_cache();
test_postlude_active_engine_error_finalizes_and_persists();
test_postlude_engine_error_finalize_failure_preserves_internal();
test_postlude_abandoned_engine_error_skips_all_side_effects();
test_cancel_callback_scope_installs_callback();
test_cancel_callback_scope_clears_callback();
if (failures == 0) {
std::fprintf(stderr, "all request_lifecycle checks passed\n");
return 0;
}
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
+1 -1
View File
@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=cf1aa57e1a0fabfd015831718fc99d1aec01ada5
IK_LLAMA_VERSION?=fe215a8ccdce6b844d2a3a3bbde08ae76a6284bf
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=
+1
View File
@@ -2565,6 +2565,7 @@ public:
grpc::Status Embedding(ServerContext* context, const backend::PredictOptions* request, backend::EmbeddingResult* embeddingResult) {
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
embeddingResult->set_layout(backend::EMBEDDING_LAYOUT_FINAL);
json data = parse_options(false, request, llama);
const int task_id = llama.queue_tasks.get_new_id();
llama.queue_results.add_waiting_task_id(task_id);
+10
View File
@@ -115,4 +115,14 @@ if(LLAMA_GRPC_BUILD_TESTS)
target_include_directories(passthrough_options_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(passthrough_options_test PRIVATE cxx_std_17)
add_test(NAME passthrough_options_test COMMAND passthrough_options_test)
add_executable(tts_request_options_test tts_request_options_test.cpp tts_request_options.h)
target_include_directories(tts_request_options_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(tts_request_options_test PRIVATE cxx_std_17)
add_test(NAME tts_request_options_test COMMAND tts_request_options_test)
add_executable(thread_params_test thread_params_test.cpp thread_params.h)
target_include_directories(thread_params_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(thread_params_test PRIVATE cxx_std_17)
add_test(NAME thread_params_test COMMAND thread_params_test)
endif()
+1 -1
View File
@@ -1,5 +1,5 @@
LLAMA_VERSION?=221f0f6356efe2260023208365705ec5d5a7c8f5
LLAMA_VERSION?=465e49b9cea78a68b9c244ffb48d0ee24a82873d
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# Mark a copied gRPC server as targeting a llama.cpp fork that does not carry
# LocalAI's SERVER_TASK_TYPE_TTS patch. The RPCs remain present in the shared
# protobuf service, but respond with UNIMPLEMENTED instead of referencing
# server task types and mtmd gen-audio APIs absent from those forks.
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 <grpc-server.cpp>" >&2
exit 2
fi
SRC=$1
if [[ ! -f "$SRC" ]]; then
echo "grpc-server.cpp not found at $SRC" >&2
exit 2
fi
if grep -q '^#define LOCALAI_LLAMA_CPP_NO_TTS_TASK' "$SRC"; then
echo "==> $SRC already disables the LocalAI TTS task, skipping"
exit 0
fi
awk '
!done && /^#include/ {
print "#define LOCALAI_LLAMA_CPP_NO_TTS_TASK 1"
print "// ^ injected by disable-tts-task.sh for an unpatched llama.cpp fork"
print ""
done = 1
}
{ print }
END {
if (!done) {
print "disable-tts-task.sh: no #include anchor found" > "/dev/stderr"
exit 1
}
}
' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> LocalAI TTS task disabled in $SRC"
+359 -72
View File
@@ -53,8 +53,10 @@
#include "arg.h"
#include "chat-auto-parser.h"
#include "llama_compat.h" // fork-skew switches, generated by prepare.sh
#include "thread_params.h"
#include "message_content.h"
#include "passthrough_options.h"
#include "tts_request_options.h"
#include <getopt.h>
#include <grpcpp/ext/proto_server_reflection_plugin.h>
#include <grpcpp/grpcpp.h>
@@ -65,6 +67,7 @@
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iterator>
#include <list>
@@ -85,6 +88,12 @@ using grpc::ServerBuilder;
using grpc::ServerContext;
using grpc::Status;
#if LOCALAI_HAS_MTMD_INIT_OPT
#define LOCALAI_MTMD_INIT_OPT_ARG(value) , value
#else
#define LOCALAI_MTMD_INIT_OPT_ARG(value)
#endif
// gRPC bearer token auth for distributed mode.
// Reads LOCALAI_GRPC_AUTH_TOKEN from the environment. When set, rejects
// requests without a matching "authorization: Bearer <token>" metadata header.
@@ -233,7 +242,15 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
data["typical_p"] = predict->typicalp();
data["temperature"] = predict->temperature();
data["repeat_last_n"] = predict->repeat();
data["repeat_penalty"] = predict->penalty();
// PredictOptions.Penalty is a bare proto float, so a caller that names no
// repetition penalty sends 0 rather than omitting the field. Since
// llama.cpp 9de0fcf2b, common_sampler_init() rejects a non-positive
// penalty_repeat outright (it would divide logits by zero), which turned
// every such request into "Failed to initialize samplers". Treat 0 as
// "unset" and leave llama.cpp's own neutral default in place.
if (predict->penalty() > 0.0f) {
data["repeat_penalty"] = predict->penalty();
}
data["frequency_penalty"] = predict->frequencypenalty();
data["presence_penalty"] = predict->presencepenalty();
data["mirostat"] = predict->mirostat();
@@ -283,7 +300,7 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
} else {
SRV_WRN("[TOOLS DEBUG] parse_options: Parsed tools JSON is not an array: %s\n", tools_json.dump().c_str());
}
} catch (const json::parse_error& e) {
} catch (const common_json_error& e) {
SRV_WRN("Failed to parse tools JSON from proto: %s\n", e.what());
SRV_WRN("[TOOLS DEBUG] parse_options: Tools string that failed to parse: %s\n", predict->tools().c_str());
}
@@ -313,7 +330,7 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
SRV_DBG("[TOOLS DEBUG] Received tool_choice object from Go layer: %s\n", tool_choice_json.dump().c_str());
}
SRV_INF("Extracted tool_choice from proto: %s\n", predict->toolchoice().c_str());
} catch (const json::parse_error& e) {
} catch (const common_json_error& e) {
// If parsing fails, treat as string
data["tool_choice"] = predict->toolchoice();
SRV_INF("Extracted tool_choice as string: %s\n", predict->toolchoice().c_str());
@@ -342,7 +359,7 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
// Add to data - llama.cpp server expects it as an object (map)
data["logit_bias"] = logit_bias_json;
SRV_INF("Using logit_bias: %s\n", predict->logitbias().c_str());
} catch (const json::parse_error& e) {
} catch (const common_json_error& e) {
SRV_ERR("Failed to parse logit_bias JSON from proto: %s\n", e.what());
}
}
@@ -387,7 +404,10 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
});
}
data["stop"] = predict->stopprompts();
data["stop"] = json::array();
for (const auto & stop : predict->stopprompts()) {
data["stop"].push_back(stop);
}
// data["n_probs"] = predict->nprobs();
//TODO: images,
@@ -1105,14 +1125,16 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
try {
int n = std::stoi(optval_str);
if (n < 0) n = 0;
// Keep override-name storage alive for the lifetime of the params struct
// (mirrors upstream arg.cpp behavior with a function-local static).
#if LOCALAI_HAS_N_CPU_FFN_HELPER
llm_add_n_cpu_ffn_overrides(n, LLM_FFN_EXPS_REGEX, params.speculative.draft.tensor_buft_overrides);
#else
static std::list<std::string> buft_overrides_draft;
for (int i = 0; i < n; ++i) {
buft_overrides_draft.push_back(llm_ffn_exps_block_regex(i));
params.speculative.draft.tensor_buft_overrides.push_back(
{buft_overrides_draft.back().c_str(), ggml_backend_cpu_buffer_type()});
}
#endif
} catch (...) {}
}
@@ -1130,14 +1152,16 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
try {
int n = std::stoi(optval_str);
if (n < 0) n = 0;
// Keep override-name storage alive for the lifetime of the
// params struct (mirrors upstream arg.cpp's function-local static).
#if LOCALAI_HAS_N_CPU_FFN_HELPER
llm_add_n_cpu_ffn_overrides(n, LLM_FFN_EXPS_REGEX, params.tensor_buft_overrides);
#else
static std::list<std::string> buft_overrides_main;
for (int i = 0; i < n; ++i) {
buft_overrides_main.push_back(llm_ffn_exps_block_regex(i));
params.tensor_buft_overrides.push_back(
{buft_overrides_main.back().c_str(), ggml_backend_cpu_buffer_type()});
}
#endif
} catch (...) {}
}
@@ -1402,6 +1426,12 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
passthrough_draft_gpu_layers);
}
// The library initializer now creates both threadpools before the server
// can apply llama_context's fallback for the -1 batch-thread sentinel.
params.cpuparams_batch.n_threads = llama_grpc::resolve_batch_threads(
params.cpuparams_batch.n_threads,
params.cpuparams.n_threads);
#ifndef LOCALAI_LLAMA_CPP_NO_SCORE_TASK
// Score-task suffix forking: reserve seq ids (and recurrent-state cells)
// beyond the slots so one scoring call decodes all candidate tails in a
@@ -1445,6 +1475,26 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
}
#ifndef LOCALAI_LLAMA_CPP_NO_TTS_TASK
// MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM hands back raw float32 samples, but the
// WAV header core/backend/tts.go builds around the streamed chunks announces
// 16-bit samples, so the wire has to carry s16 or the client decodes floats as
// integers and hears noise. The scaling matches write_wav16() in
// tools/mtmd/mtmd-helper-gen.cpp, which is what the non-streaming path writes.
static std::string tts_pcm_f32_to_s16(const std::string & samples) {
const size_t n = samples.size() / sizeof(float);
std::string out;
out.resize(n * sizeof(int16_t));
for (size_t i = 0; i < n; i++) {
float v = 0.0f;
std::memcpy(&v, samples.data() + i * sizeof(float), sizeof(float));
const int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, v)) * 32767.0f);
std::memcpy(&out[i * sizeof(int16_t)], &s, sizeof(int16_t));
}
return out;
}
#endif
// GRPC Server start
class BackendServiceImpl final : public backend::Backend::Service {
private:
@@ -1758,7 +1808,7 @@ public:
for (int j = 0; j < request->audios_size(); j++) rin.audios.push_back(request->audios(j));
for (int j = 0; j < request->videos_size(); j++) rin.videos.push_back(request->videos(j));
}
messages_json.push_back(llama_grpc::build_reconstructed_message(rin));
messages_json.push_back(json::parse(llama_grpc::build_reconstructed_message(rin).dump()));
}
// Final safety check: Ensure no message has null content (Jinja templates require strings)
@@ -1951,7 +2001,7 @@ public:
if (!body_json.contains("chat_template_kwargs")) {
body_json["chat_template_kwargs"] = json::object();
}
for (auto& el : ctk.items()) {
for (auto el : ctk.items()) {
body_json["chat_template_kwargs"][el.key()] = el.value();
}
}
@@ -2037,30 +2087,27 @@ public:
// If not using chat templates, extract files from image_data/audio_data fields
// (If using chat templates, files were already extracted by oaicompat_chat_params_parse)
if (!request->usetokenizertemplate() || request->messages_size() == 0 || ctx_server.impl->chat_params.tmpls == nullptr) {
const auto &images_data = data.find("image_data");
if (images_data != data.end() && images_data->is_array())
if (data.contains("image_data") && data.at("image_data").is_array())
{
for (const auto &img : *images_data)
for (const auto &img : data.at("image_data"))
{
auto decoded_data = base64_decode(img["data"].get<std::string>());
files.push_back(decoded_data);
}
}
const auto &audio_data = data.find("audio_data");
if (audio_data != data.end() && audio_data->is_array())
if (data.contains("audio_data") && data.at("audio_data").is_array())
{
for (const auto &audio : *audio_data)
for (const auto &audio : data.at("audio_data"))
{
auto decoded_data = base64_decode(audio["data"].get<std::string>());
files.push_back(decoded_data);
}
}
const auto &video_data = data.find("video_data");
if (video_data != data.end() && video_data->is_array())
if (data.contains("video_data") && data.at("video_data").is_array())
{
for (const auto &video : *video_data)
for (const auto &video : data.at("video_data"))
{
auto decoded_data = base64_decode(video["data"].get<std::string>());
files.push_back(decoded_data);
@@ -2074,10 +2121,10 @@ public:
std::vector<server_tokens> inputs;
if (has_mtmd) {
// multimodal
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files));
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt)));
} else {
// Everything else, including multimodal completions.
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true);
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
}
tasks.reserve(inputs.size());
@@ -2089,15 +2136,23 @@ public:
task.tokens = std::move(inputs[i]);
#ifdef LOCALAI_HAS_SERVER_SCHEMA
// The schema evaluator no longer takes the per-slot n_ctx: upstream
// dropped the parameter and server-schema stopped consulting n_ctx at
// all, leaving the context bound to the slot. Forks that predate the
// server-schema split still expect it, so only this branch loses it.
task.params = server_schema::eval_llama_cmpl_schema(
ctx_server.impl->vocab,
params_base,
ctx_server.get_meta().logit_bias_eog,
data);
#else
task.params = server_task::params_from_json_cmpl(
#endif
ctx_server.impl->vocab,
params_base,
ctx_server.get_meta().slot_n_ctx,
ctx_server.get_meta().logit_bias_eog,
data);
#endif
task.id_slot = json_value(data, "id_slot", -1);
// OAI-compat: enable autoparser (PEG-based chat parsing) so that
@@ -2325,7 +2380,7 @@ public:
for (int j = 0; j < request->audios_size(); j++) rin.audios.push_back(request->audios(j));
for (int j = 0; j < request->videos_size(); j++) rin.videos.push_back(request->videos(j));
}
messages_json.push_back(llama_grpc::build_reconstructed_message(rin));
messages_json.push_back(json::parse(llama_grpc::build_reconstructed_message(rin).dump()));
}
// Final safety check: Ensure no message has null content (Jinja templates require strings)
@@ -2518,7 +2573,7 @@ public:
if (!body_json.contains("chat_template_kwargs")) {
body_json["chat_template_kwargs"] = json::object();
}
for (auto& el : ctk.items()) {
for (auto el : ctk.items()) {
body_json["chat_template_kwargs"][el.key()] = el.value();
}
}
@@ -2604,11 +2659,10 @@ public:
// If not using chat templates, extract files from image_data/audio_data fields
// (If using chat templates, files were already extracted by oaicompat_chat_params_parse)
if (!request->usetokenizertemplate() || request->messages_size() == 0 || ctx_server.impl->chat_params.tmpls == nullptr) {
const auto &images_data = data.find("image_data");
if (images_data != data.end() && images_data->is_array())
if (data.contains("image_data") && data.at("image_data").is_array())
{
std::cout << "[PREDICT] Processing " << images_data->size() << " images" << std::endl;
for (const auto &img : *images_data)
std::cout << "[PREDICT] Processing " << data.at("image_data").size() << " images" << std::endl;
for (const auto &img : data.at("image_data"))
{
std::cout << "[PREDICT] Processing image" << std::endl;
auto decoded_data = base64_decode(img["data"].get<std::string>());
@@ -2616,20 +2670,18 @@ public:
}
}
const auto &audio_data = data.find("audio_data");
if (audio_data != data.end() && audio_data->is_array())
if (data.contains("audio_data") && data.at("audio_data").is_array())
{
for (const auto &audio : *audio_data)
for (const auto &audio : data.at("audio_data"))
{
auto decoded_data = base64_decode(audio["data"].get<std::string>());
files.push_back(decoded_data);
}
}
const auto &video_data = data.find("video_data");
if (video_data != data.end() && video_data->is_array())
if (data.contains("video_data") && data.at("video_data").is_array())
{
for (const auto &video : *video_data)
for (const auto &video : data.at("video_data"))
{
auto decoded_data = base64_decode(video["data"].get<std::string>());
files.push_back(decoded_data);
@@ -2644,10 +2696,10 @@ public:
std::vector<server_tokens> inputs;
if (has_mtmd) {
// multimodal
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files));
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt)));
} else {
// Everything else, including multimodal completions.
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true);
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
}
tasks.reserve(inputs.size());
@@ -2659,15 +2711,23 @@ public:
task.tokens = std::move(inputs[i]);
#ifdef LOCALAI_HAS_SERVER_SCHEMA
// The schema evaluator no longer takes the per-slot n_ctx: upstream
// dropped the parameter and server-schema stopped consulting n_ctx at
// all, leaving the context bound to the slot. Forks that predate the
// server-schema split still expect it, so only this branch loses it.
task.params = server_schema::eval_llama_cmpl_schema(
ctx_server.impl->vocab,
params_base,
ctx_server.get_meta().logit_bias_eog,
data);
#else
task.params = server_task::params_from_json_cmpl(
#endif
ctx_server.impl->vocab,
params_base,
ctx_server.get_meta().slot_n_ctx,
ctx_server.get_meta().logit_bias_eog,
data);
#endif
task.id_slot = json_value(data, "id_slot", -1);
// OAI-compat: enable autoparser (PEG-based chat parsing) so that
@@ -2826,7 +2886,7 @@ public:
json prompt = body.at("embeddings");
auto tokenized_prompts = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt, true, true);
auto tokenized_prompts = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
for (const auto & tokens : tokenized_prompts) {
// this check is necessary for models that do not add BOS token to the input
if (tokens.empty()) {
@@ -2865,42 +2925,40 @@ public:
return grpc::Status(grpc::StatusCode::INTERNAL, all_results.error->to_json().value("message", "Error in receiving results"));
}
// Collect responses
json responses = json::array();
// Extract the embeddings typed, straight from the task results (no
// JSON round-trip), and report the payload shape alongside the same
// flat float array as before: dim is the embedding width, tokens the
// number of vectors packed into `embeddings` (1 per prompt when the
// server pooled, one per token with pooling:none; summed across
// prompts if the request carried several), prompt_tokens the prompt
// tokens evaluated, for usage accounting. Consumers seeing 0/0 know
// the backend predates shape reporting.
int32_t n_vectors = 0;
int32_t dim = 0;
int32_t prompt_tokens = 0;
for (auto & res : all_results.results) {
GGML_ASSERT(dynamic_cast<server_task_result_embd*>(res.get()) != nullptr);
responses.push_back(res->to_json());
}
std::cout << "[DEBUG] Responses size: " << responses.size() << std::endl;
// Process the responses and extract embeddings
for (const auto & response_elem : responses) {
// Check if the response has an "embedding" field
if (response_elem.contains("embedding")) {
json embedding_data = json_value(response_elem, "embedding", json::array());
if (embedding_data.is_array() && !embedding_data.empty()) {
for (const auto & embedding_vector : embedding_data) {
if (embedding_vector.is_array()) {
for (const auto & embedding_value : embedding_vector) {
embeddingResult->add_embeddings(embedding_value.get<float>());
}
}
}
auto * embd_res = dynamic_cast<server_task_result_embd*>(res.get());
GGML_ASSERT(embd_res != nullptr);
prompt_tokens += embd_res->n_tokens;
for (const auto & vec : embd_res->embedding) {
for (const float value : vec) {
embeddingResult->add_embeddings(value);
}
} else {
// Check if the response itself contains the embedding data directly
if (response_elem.is_array()) {
for (const auto & embedding_value : response_elem) {
embeddingResult->add_embeddings(embedding_value.get<float>());
}
if (!vec.empty()) {
n_vectors++;
dim = (int32_t) vec.size();
}
}
}
embeddingResult->set_tokens(n_vectors);
embeddingResult->set_dim(dim);
embeddingResult->set_prompt_tokens(prompt_tokens);
embeddingResult->set_layout(
llama_pooling_type(ctx_server.get_llama_context()) == LLAMA_POOLING_TYPE_NONE
? backend::EMBEDDING_LAYOUT_PER_TOKEN
: backend::EMBEDDING_LAYOUT_FINAL);
std::cout << "[DEBUG] Embedding vectors: " << n_vectors << " x " << dim << std::endl;
return grpc::Status::OK;
}
@@ -2933,7 +2991,7 @@ public:
tasks.reserve(documents.size());
for (size_t i = 0; i < documents.size(); i++) {
auto tmp = format_prompt_rerank(ctx_server.impl->model_tgt, ctx_server.impl->vocab, ctx_server.impl->mctx, request->query(), documents[i]);
auto tmp = format_prompt_rerank(ctx_server.impl->model_tgt, ctx_server.impl->vocab, ctx_server.impl->mctx, request->query(), documents[i] LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
server_task task = server_task(SERVER_TASK_TYPE_RERANK);
task.id = rd.queue_tasks.get_new_id();
task.index = i;
@@ -2954,7 +3012,7 @@ public:
}
// Collect responses
json responses = json::array();
std::vector<json> responses;
for (auto & res : all_results.results) {
GGML_ASSERT(dynamic_cast<server_task_result_rerank*>(res.get()) != nullptr);
responses.push_back(res->to_json());
@@ -2967,7 +3025,7 @@ public:
// Crop results by request.top_n if specified
int top_n = request->top_n();
if (top_n > 0 && top_n < static_cast<int>(responses.size())) {
responses = json(responses.begin(), responses.begin() + top_n);
responses.resize(top_n);
}
// Set usage information
backend::Usage* usage = rerankResult->mutable_usage();
@@ -2994,6 +3052,229 @@ public:
return grpc::Status::OK;
}
#ifndef LOCALAI_LLAMA_CPP_NO_TTS_TASK
// Builds the shared TTS task from a request. Returns a non-OK status and
// leaves `task` untouched when the request is malformed or the loaded model
// cannot synthesise audio.
grpc::Status prepareTTSTask(const backend::TTSRequest* request, bool stream, server_task & task) {
if (!ctx_server.get_meta().has_cap_tts) {
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
"the loaded model does not support audio generation (no gen-audio mmproj)");
}
std::map<std::string, std::string> params(request->params().begin(), request->params().end());
const auto opts = llama_grpc::parse_tts_request_options(
request->text(),
request->voice(),
request->has_language() ? request->language() : std::string(),
params);
if (!opts.ok) {
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, opts.error);
}
auto wrapper = mtmd_helper_bitmap_init_from_file(ctx_server.impl->mctx, opts.voice_path.c_str(), false LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
if (!wrapper.bitmap) {
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
"failed to read speaker reference audio: " + opts.voice_path);
}
task.tts_inp.set_prompt(opts.text);
// core/backend/tts.go always sets TTSRequest.language, so has_language()
// is true even when the caller named no language and the string is empty.
// gen_audio::inp::get() already maps a stored blank to nullptr, so this
// guard is behavior-preserving rather than behavior-fixing. It is kept
// so the "unset" intent is visible at the call site instead of resting
// on a detail of the helper.
if (!opts.language.empty()) {
task.tts_inp.set_lang(opts.language);
}
task.tts_inp.set_speaker_ref(mtmd::bitmap_ptr(wrapper.bitmap));
task.tts_inp.data.top_k = opts.top_k;
task.tts_inp.data.top_p = opts.top_p;
task.tts_inp.data.stream = stream;
task.tts_inp.data.out_type = stream
? MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM // Go prepends its own WAV header, see core/backend/tts.go
: MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
task.params.stream = stream;
// -1 keeps upstream's 512-frame default. The model does not always emit
// its codec EOS, so a short input can otherwise generate the full cap.
task.params.n_predict = opts.max_frames > 0 ? opts.max_frames : -1;
task.params.sampling = params_base.sampling;
// Both values mirror upstream's draft POST /tts handler. Note that the
// pair is INERT at this pin: llama_sampler_init_penalties() clamps
// penalty_last_n with std::max(penalty_last_n, 0), so -1 means "off",
// not "the whole generation", and the penalty sampler is then built
// disabled. No repetition penalty is actually applied.
//
// That is deliberate. Dropping the second line lets the sampling
// default of 64 apply and genuinely engages the 1.05 penalty, which was
// measured here against the model's habit of never emitting its codec
// EOS and running to the frame cap: 0 of 15 short requests ran away
// with the penalty inert, 1 of 15 with it active over the last 64
// tokens. It does not fix the runaway, so the line stays for parity
// with the draft. Use max_frames to bound the output instead.
task.params.sampling.penalty_repeat = 1.05f;
task.params.sampling.penalty_last_n = -1;
if (opts.top_k > 0) {
task.params.sampling.top_k = opts.top_k;
}
if (opts.top_p > 0) {
task.params.sampling.top_p = opts.top_p;
}
return grpc::Status::OK;
}
grpc::Status TTS(ServerContext* context, const backend::TTSRequest* request, backend::Result* result) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
if (request->dst().empty()) {
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "dst must name an output file path");
}
server_task task(SERVER_TASK_TYPE_TTS);
auto prepared = prepareTTSTask(request, /* stream= */ false, task);
if (!prepared.ok()) return prepared;
auto rd = ctx_server.get_response_reader();
task.id = rd.get_new_id();
rd.post_task(std::move(task));
auto should_stop = [context]() { return context->IsCancelled(); };
std::string audio;
while (true) {
auto res = rd.next(should_stop);
if (!res) {
return grpc::Status(grpc::StatusCode::CANCELLED, "TTS request cancelled");
}
if (res->is_error()) {
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
}
auto * tts_res = dynamic_cast<server_task_result_tts *>(res.get());
if (tts_res == nullptr) {
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a TTS task");
}
audio.append(tts_res->audio);
if (tts_res->final) {
break;
}
}
std::ofstream out(request->dst(), std::ios::binary | std::ios::trunc);
if (!out) {
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to open output file: " + request->dst());
}
out.write(audio.data(), (std::streamsize) audio.size());
if (!out) {
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to write output file: " + request->dst());
}
// Buffered data is flushed here, so a full disk or a failing device can
// surface for the first time on close. Reporting success then would
// leave a truncated file behind under the name the caller will read.
out.close();
if (!out) {
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to close output file: " + request->dst());
}
result->set_success(true);
result->set_message("TTS audio generated");
return grpc::Status::OK;
}
grpc::Status TTSStream(ServerContext* context, const backend::TTSRequest* request, grpc::ServerWriter<backend::Reply>* writer) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
server_task task(SERVER_TASK_TYPE_TTS);
auto prepared = prepareTTSTask(request, /* stream= */ true, task);
if (!prepared.ok()) return prepared;
auto rd = ctx_server.get_response_reader();
task.id = rd.get_new_id();
rd.post_task(std::move(task));
auto should_stop = [context]() { return context->IsCancelled(); };
// core/backend/tts.go:ModelTTSStream builds the WAV header itself from
// the sample rate in the first reply's Message, then concatenates every
// Reply.Audio verbatim. So the rate goes out once, up front, and the
// chunks stay raw PCM.
//
// Send it before draining rather than off the first audio result: a
// chunk needs a whole 72-frame window, about 5.8 s of audio and far
// longer in wall time on CPU, and the Go side cannot emit the WAV
// header until this reply lands. Waiting would hold the client at zero
// bytes for that entire stretch. The rate is a property of the loaded
// model, available synchronously, so there is nothing to wait for.
{
backend::Reply header;
const json info = { {"sample_rate", mtmd_gen_audio_get_info(ctx_server.impl->mctx).sample_rate} };
header.set_message(info.dump());
if (!writer->Write(header)) {
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the TTS stream");
}
}
while (true) {
auto res = rd.next(should_stop);
if (!res) {
return grpc::Status(grpc::StatusCode::CANCELLED, "TTS request cancelled");
}
if (res->is_error()) {
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
}
auto * tts_res = dynamic_cast<server_task_result_tts *>(res.get());
if (tts_res == nullptr) {
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a TTS task");
}
if (!tts_res->audio.empty()) {
backend::Reply chunk;
chunk.set_audio(tts_pcm_f32_to_s16(tts_res->audio));
if (!writer->Write(chunk)) {
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the TTS stream");
}
}
if (tts_res->final) {
break;
}
}
return grpc::Status::OK;
}
#else
grpc::Status TTS(ServerContext* context, const backend::TTSRequest* request, backend::Result* result) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
(void) request;
(void) result;
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
"TTS is unavailable in this llama.cpp fork backend");
}
grpc::Status TTSStream(ServerContext* context, const backend::TTSRequest* request, grpc::ServerWriter<backend::Reply>* writer) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
(void) request;
(void) writer;
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
"TTSStream is unavailable in this llama.cpp fork backend");
}
#endif
// Score returns the model's joint log-probability of each candidate
// continuation given a shared prompt.
//
@@ -3328,9 +3609,15 @@ public:
// Populate the response with metrics
response->set_slot_id(0);
response->set_prompt_json_for_slot("");
#if LOCALAI_HAS_SERVER_METRICS
response->set_tokens_per_second(res_metrics->metrics.prompt_bucket.n_per_second());
response->set_tokens_generated(res_metrics->metrics.predict.count);
response->set_prompt_tokens_processed(res_metrics->metrics.prompt.count);
#else
response->set_tokens_per_second(res_metrics->n_prompt_tokens_processed ? 1.e3 / res_metrics->t_prompt_processing * res_metrics->n_prompt_tokens_processed : 0.);
response->set_tokens_generated(res_metrics->n_tokens_predicted_total);
response->set_prompt_tokens_processed(res_metrics->n_prompt_tokens_processed_total);
#endif
return grpc::Status::OK;
+4 -3
View File
@@ -52,14 +52,15 @@ inline nlohmann::ordered_json normalize_message_content(const std::string& role,
// (#7528). A multimodal user message legitimately carries a typed-part array
// ({type:text}, {type:image_url}, ...), which must be left intact. Shared by the
// streaming and non-streaming paths so this invariant cannot drift between them.
inline void normalize_template_message(nlohmann::ordered_json& msg) {
template <typename Json>
inline void normalize_template_message(Json& msg) {
if (!msg.contains("content")) {
msg["content"] = ""; // templates expect the field to exist
return;
}
nlohmann::ordered_json& content = msg["content"];
auto& content = msg["content"];
const std::string role = (msg.contains("role") && msg["role"].is_string())
? msg["role"].get<std::string>()
? msg["role"].template get<std::string>()
: std::string();
if (content.is_null()) {
content = ""; // #7324: null would crash content[:N] slicing
@@ -1,8 +1,20 @@
From 75220a0d74892e3315f4042274b1efa6195868d8 Mon Sep 17 00:00:00 2001
From: Codex <codex@local>
Date: Mon, 10 Aug 2026 23:05:52 +0000
Subject: [PATCH 1/2] score-patch
---
common/common.cpp | 6 +-
common/common.h | 3 +
tools/server/server-context.cpp | 358 +++++++++++++++++++++++++++++++-
tools/server/server-task.h | 47 +++++
4 files changed, 405 insertions(+), 9 deletions(-)
diff --git a/common/common.cpp b/common/common.cpp
index 8f13217..fc584e1 100644
index 2e3f14c..0cec0dc 100644
--- a/common/common.cpp
+++ b/common/common.cpp
@@ -1591,8 +1591,10 @@ struct llama_context_params common_context_params_to_llama(const common_params &
@@ -1636,8 +1636,10 @@ struct llama_context_params common_context_params_to_llama(const common_params &
auto cparams = llama_context_default_params();
cparams.n_ctx = params.n_ctx;
@@ -13,13 +25,13 @@ index 8f13217..fc584e1 100644
+ cparams.n_seq_max = params.n_parallel + params.n_seq_score_forks;
+ cparams.n_rs_seq = std::max(params.speculative.need_n_rs_seq(), (uint32_t) std::max(0, params.n_rs_seq));
cparams.n_outputs_max = std::max(params.n_outputs_max, 0);
cparams.n_outputs_max_per_seq = std::max(params.n_outputs_max_per_seq, 0);
cparams.n_batch = params.n_batch;
cparams.n_ubatch = params.n_ubatch;
diff --git a/common/common.h b/common/common.h
index bffc176..e313bd6 100644
index 878534d..4001df2 100644
--- a/common/common.h
+++ b/common/common.h
@@ -455,6 +455,9 @@ struct common_params {
@@ -445,6 +445,9 @@ struct common_params {
int32_t n_keep = 0; // number of tokens to keep from initial prompt
int32_t n_chunks = -1; // max number of chunks to process (-1 = unlimited)
int32_t n_parallel = 1; // number of parallel sequences to decode
@@ -28,39 +40,26 @@ index bffc176..e313bd6 100644
+ bool score_enabled = false; // reserve server resources for the Score task type
int32_t n_sequences = 1; // number of sequences to decode
int32_t n_outputs_max = 0; // max outputs in a batch (0 = n_batch)
int32_t grp_attn_n = 1; // group-attention factor
diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt
index 780df32..1d2fe8f 100644
--- a/tools/CMakeLists.txt
+++ b/tools/CMakeLists.txt
@@ -41,3 +41,4 @@ else()
add_subdirectory(fit-params)
add_subdirectory(results)
endif()
+add_subdirectory(grpc-server)
int32_t n_outputs_max_per_seq = 1; // max outputs per sequence
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
index 715477e..de5bed8 100644
index 3b5f6a1..d0e18e6 100644
--- a/tools/server/server-context.cpp
+++ b/tools/server/server-context.cpp
@@ -49,7 +49,16 @@ static uint32_t server_n_outputs_max(const common_params & params) {
@@ -48,6 +48,13 @@ static common_speculative_output_limits server_output_limits(const common_params
auto result = common_speculative_get_output_limits(
params.n_batch, params.n_parallel, common_speculative_n_max(&params.speculative));
const uint32_t n_outputs_per_seq = 1 + common_speculative_n_max(&params.speculative);
- const uint64_t n_outputs = (uint64_t) params.n_parallel * n_outputs_per_seq;
+ // score tasks (SERVER_TASK_TYPE_SCORE) output logits for every candidate
+ // token, so reserve room for a bounded candidate tail per parallel slot
+ if (!params.score_enabled) {
+ return std::max<uint32_t>(1, std::min<uint64_t>(n_batch,
+ (uint64_t) params.n_parallel * n_outputs_per_seq));
+ // Score tasks output logits for every candidate token, so reserve room
+ // for a bounded candidate tail per parallel slot.
+ if (params.score_enabled) {
+ result.per_seq = std::max<int32_t>(result.per_seq, 1 + SERVER_SCORE_MAX_CAND_TOKENS);
+ result.total = std::min<int32_t>(params.n_batch, params.n_parallel * result.per_seq);
+ }
+
+ const uint32_t n_outputs_score_seq = 1 + SERVER_SCORE_MAX_CAND_TOKENS;
+
+ const uint64_t n_outputs = (uint64_t) params.n_parallel * std::max(n_outputs_per_seq, n_outputs_score_seq);
return std::max<uint32_t>(1, std::min<uint64_t>(n_batch, n_outputs));
}
@@ -202,6 +211,26 @@ struct server_slot {
result.total = std::max<int32_t>(1, result.total);
result.per_seq = std::max<int32_t>(1, result.per_seq);
return result;
@@ -239,6 +246,26 @@ struct server_slot {
std::vector<completion_token_output> generated_token_probs;
@@ -87,7 +86,7 @@ index 715477e..de5bed8 100644
bool has_next_token = true;
bool has_new_line = false;
bool truncated = false;
@@ -311,6 +340,10 @@ struct server_slot {
@@ -341,6 +368,10 @@ struct server_slot {
}
generated_tokens.clear();
generated_token_probs.clear();
@@ -97,8 +96,8 @@ index 715477e..de5bed8 100644
+ score_divergence = -1;
json_schema = json();
// clear speculative decoding stats
@@ -2205,6 +2238,229 @@ private:
task_prev = std::move(task);
@@ -2271,6 +2302,229 @@ private:
queue_results.send(std::move(res));
}
@@ -328,7 +327,7 @@ index 715477e..de5bed8 100644
//
// Functions to process the task
//
@@ -2341,6 +2597,7 @@ private:
@@ -2407,6 +2661,7 @@ private:
case SERVER_TASK_TYPE_INFILL:
case SERVER_TASK_TYPE_EMBEDDING:
case SERVER_TASK_TYPE_RERANK:
@@ -336,7 +335,7 @@ index 715477e..de5bed8 100644
{
// special case: if input is provided via CLI, tokenize it first
// otherwise, no need to tokenize as it's already done inside the HTTP thread
@@ -2832,6 +3089,13 @@ private:
@@ -2903,6 +3158,13 @@ private:
break; // stop any further processing
}
}
@@ -350,7 +349,7 @@ index 715477e..de5bed8 100644
}
void pre_decode() {
@@ -3154,6 +3418,16 @@ private:
@@ -3222,6 +3484,16 @@ private:
n_past = std::min(n_past, slot.alora_invocation_start - 1);
}
@@ -367,7 +366,7 @@ index 715477e..de5bed8 100644
const auto n_cache_reuse = slot.task->params.n_cache_reuse;
const bool can_cache_reuse =
@@ -3395,8 +3669,12 @@ private:
@@ -3455,8 +3727,12 @@ private:
bool do_checkpoint = params_base.n_ctx_checkpoints > 0;
@@ -382,7 +381,7 @@ index 715477e..de5bed8 100644
// make a checkpoint of the parts of the memory that cannot be rolled back.
// checkpoints are created only if:
@@ -3463,10 +3741,17 @@ private:
@@ -3444,9 +3720,16 @@ private:
// embedding requires all tokens in the batch to be output;
// MTP also wants logits at every prompt position so the
// streaming hook can mirror t_h_nextn into ctx_dft.
@@ -395,16 +394,12 @@ index 715477e..de5bed8 100644
+ slot.prompt.n_tokens() + 1 < slot.task->n_tokens();
add_ok &= batch.add(slot.id,
cur_tok,
slot.prompt.tokens.pos_next(),
- slot.need_embd());
+ slot.need_embd() || need_score_logit);
/* pos = */ slot.prompt.tokens.pos_next(),
- /* output = */ slot.need_embd(),
+ /* output = */ slot.need_embd() || need_score_logit,
/* is_prompt = */ true);
slot.prompt.tokens.push_back(cur_tok);
slot.n_prompt_tokens_processed++;
@@ -3481,6 +3766,32 @@ private:
}
}
@@ -3454,2 +3737,28 @@ private:
+ // score tasks: break at the shared-prompt boundary so the checkpoint
+ // below lands exactly there — the other candidates of the same
+ // scoring call re-process only their own tokens. Also break at the
@@ -431,10 +426,9 @@ index 715477e..de5bed8 100644
+ }
+ }
+
// process the last few tokens of the prompt separately in order to allow for a checkpoint to be created.
// create checkpoints that many tokens before the end of the prompt:
// - 4 + n_ubatch
@@ -3513,6 +3824,15 @@ private:
// break at the last user message, or at user messages at least min step past the last checkpoint
if (do_checkpoint && spans.is_user_start(slot.prompt.n_tokens())) {
@@ -3573,6 +3882,15 @@ private:
const bool is_user_start = spans.is_user_start(n_tokens_start);
const bool is_last_user_message = n_tokens_start == last_user_pos;
@@ -450,7 +444,7 @@ index 715477e..de5bed8 100644
// entire prompt has been processed
if (slot.prompt.n_tokens() == slot.task->n_tokens()) {
slot.state = SLOT_STATE_DONE_PROMPT;
@@ -3528,8 +3848,8 @@ private:
@@ -3588,8 +3906,8 @@ private:
slot.init_sampler();
} else {
// skip ordinary mid-prompt checkpoints, unless the batch starts a user
@@ -461,7 +455,7 @@ index 715477e..de5bed8 100644
do_checkpoint = false;
}
}
@@ -3546,10 +3866,10 @@ private:
@@ -3606,10 +3924,10 @@ private:
// do not checkpoint after mtmd chunks
do_checkpoint = do_checkpoint && !has_mtmd;
@@ -474,7 +468,7 @@ index 715477e..de5bed8 100644
n_tokens_start > slot.prompt.checkpoints.back().n_tokens + params_base.checkpoint_min_step);
SLT_DBG(slot, "main/do_checkpoint = %s, pos_min = %d, pos_max = %d\n", do_checkpoint ? "yes" : "no", pos_min, pos_max);
@@ -3703,6 +4023,13 @@ private:
@@ -3772,6 +4090,13 @@ private:
}
}
@@ -488,7 +482,7 @@ index 715477e..de5bed8 100644
if (!is_inside_view(slot.i_batch)) {
// the required token not in this sub-batch, skip
return;
@@ -3724,6 +4051,25 @@ private:
@@ -3793,6 +4118,25 @@ private:
return;
}
@@ -515,7 +509,7 @@ index 715477e..de5bed8 100644
// prompt evaluated for next-token prediction
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
index c3eea2e..fb3c178 100644
index 6275ec7..5bedf19 100644
--- a/tools/server/server-task.h
+++ b/tools/server/server-task.h
@@ -13,10 +13,25 @@
@@ -597,3 +591,5 @@ index c3eea2e..fb3c178 100644
struct server_task_result_error : server_task_result {
error_type err_type = ERROR_TYPE_SERVER;
std::string err_msg;
--
2.39.5
@@ -0,0 +1,845 @@
diff --git a/tools/mtmd/mtmd-helper-gen.cpp b/tools/mtmd/mtmd-helper-gen.cpp
index 1c58d3ae1..196cbd433 100644
--- a/tools/mtmd/mtmd-helper-gen.cpp
+++ b/tools/mtmd/mtmd-helper-gen.cpp
@@ -50,29 +50,38 @@ static llama_token find_special_token(const llama_vocab * vocab, const std::stri
return LLAMA_TOKEN_NULL;
}
+static void put_bytes(std::vector<char> & buf, const void * p, size_t n) {
+ const char * c = (const char *) p;
+ buf.insert(buf.end(), c, c + n);
+}
+
+// data_sz == UINT32_MAX writes the "unknown length" sentinel (streaming), same as ffmpeg does on a pipe
+static void write_wav16_header(std::vector<char> & buf, uint32_t data_sz, int32_t rate) {
+ const uint32_t riff_sz = data_sz == UINT32_MAX ? UINT32_MAX : 36 + data_sz;
+ const uint32_t fmt_sz = 16, byte_rate = (uint32_t) rate * 2;
+ const uint16_t fmt = 1, ch = 1, align = 2, bits = 16;
+ const uint32_t rate32 = (uint32_t) rate;
+ put_bytes(buf, "RIFF", 4); put_bytes(buf, &riff_sz, 4); put_bytes(buf, "WAVE", 4);
+ put_bytes(buf, "fmt ", 4); put_bytes(buf, &fmt_sz, 4);
+ put_bytes(buf, &fmt, 2); put_bytes(buf, &ch, 2); put_bytes(buf, &rate32, 4);
+ put_bytes(buf, &byte_rate, 4); put_bytes(buf, &align, 2); put_bytes(buf, &bits, 2);
+ put_bytes(buf, "data", 4); put_bytes(buf, &data_sz, 4);
+}
+
+static void append_wav16_pcm(std::vector<char> & buf, const float * pcm, size_t n) {
+ for (size_t i = 0; i < n; i++) {
+ int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, pcm[i])) * 32767.0f);
+ put_bytes(buf, &s, 2);
+ }
+}
+
static bool write_wav16(std::vector<char> & buf, const std::vector<float> & pcm, int32_t rate) {
// RIFF chunk sizes are 32-bit; refuse to emit a file with a truncated header
if (pcm.size() > ((size_t) UINT32_MAX - 36) / 2) {
return false;
}
- const uint32_t data_sz = (uint32_t) (pcm.size() * 2);
- const uint32_t riff_sz = 36 + data_sz;
- const uint32_t fmt_sz = 16, byte_rate = (uint32_t) rate * 2;
- const uint16_t fmt = 1, ch = 1, align = 2, bits = 16;
- const uint32_t rate32 = (uint32_t) rate;
- auto put = [&](const void * p, size_t n) {
- const char * c = (const char *) p;
- buf.insert(buf.end(), c, c + n);
- };
- put("RIFF", 4); put(&riff_sz, 4); put("WAVE", 4);
- put("fmt ", 4); put(&fmt_sz, 4);
- put(&fmt, 2); put(&ch, 2); put(&rate32, 4);
- put(&byte_rate, 4); put(&align, 2); put(&bits, 2);
- put("data", 4); put(&data_sz, 4);
- for (float v : pcm) {
- int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, v)) * 32767.0f);
- put(&s, 2);
- }
+ write_wav16_header(buf, (uint32_t) (pcm.size() * 2), rate);
+ append_wav16_pcm(buf, pcm.data(), pcm.size());
return true;
}
@@ -92,6 +101,8 @@ public:
// set out_stop on end-of-speech, h_state_out must be null if no frame is generated
virtual int32_t step_gen(llama_token sampled, const float * h_state_in, const float ** h_state_out, bool * out_stop) = 0;
virtual int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) = 0;
+ // forces any buffered codes through code2wav now, regardless of window_frames
+ virtual int32_t flush() { return 0; }
protected:
llama_context * lctx;
@@ -121,6 +132,9 @@ public:
prompt_batch.reset();
n_prompt = 0;
prompt_pos = 0;
+ stream = false;
+ pcm_sent = 0;
+ wav_header_sent = false;
}
int32_t set_input(const mtmd_helper_gen_audio_inp * inp) override {
@@ -208,6 +222,7 @@ public:
top_p = inp->top_p > 0 ? inp->top_p : def.top_p;
seed = inp->seed;
out_type = inp->out_type;
+ stream = inp->stream;
// the prompt above holds the whole text stream up to tts_eos, so every generated
// frame adds tts_pad on top of the codes embedding
@@ -302,31 +317,60 @@ public:
}
int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) override {
- if (!flush_gen_wav()) {
- return 1;
+ *out_sample_rate = info.sample_rate;
+
+ if (!stream) {
+ // one-shot call: force out whatever's left, regardless of window_frames
+ if (!flush_gen_wav()) {
+ return 1;
+ }
+ if (out_n_samples) {
+ *out_n_samples = (int64_t) audio_pcm.size();
+ }
+ if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
+ *out_data = (const char *) audio_pcm.data();
+ *out_data_len = audio_pcm.size() * sizeof(float);
+ return 0;
+ }
+ out_buf.clear();
+ if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
+ LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
+ return 1;
+ }
+ *out_data = out_buf.data();
+ *out_data_len = out_buf.size();
+ return 0;
}
- *out_sample_rate = info.sample_rate;
+ // streaming: only return audio produced since the previous call
+ const size_t n_new = audio_pcm.size() - pcm_sent;
if (out_n_samples) {
- *out_n_samples = (int64_t) audio_pcm.size();
+ *out_n_samples = (int64_t) n_new;
}
if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
- *out_data = (const char *) audio_pcm.data();
- *out_data_len = audio_pcm.size() * sizeof(float);
+ *out_data = (const char *) (audio_pcm.data() + pcm_sent);
+ *out_data_len = n_new * sizeof(float);
+ pcm_sent = audio_pcm.size();
return 0;
}
out_buf.clear();
- if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
- LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
- return 1;
+ if (!wav_header_sent) {
+ write_wav16_header(out_buf, UINT32_MAX, info.sample_rate);
+ wav_header_sent = true;
}
+ append_wav16_pcm(out_buf, audio_pcm.data() + pcm_sent, n_new);
+ pcm_sent = audio_pcm.size();
*out_data = out_buf.data();
*out_data_len = out_buf.size();
return 0;
}
+ int32_t flush() override {
+ return flush_gen_wav() ? 0 : 1;
+ }
+
private:
bool ensure_cache() {
if (specials_ok) {
@@ -370,7 +414,7 @@ private:
LOG_ERR("mtmd_helper_gen_audio: mmproj has no speaker/audio encoder\n");
return false;
}
- const std::string marker = mtmd_default_marker();
+ const std::string marker = mtmd_get_marker(mctx);
mtmd_input_text text{ marker.c_str(), marker.size(), false, true };
mtmd_input_chunks * chunks = mtmd_input_chunks_init();
const mtmd_bitmap * bptr = bitmap;
@@ -456,6 +500,9 @@ private:
std::vector<float> h_state_buf;
mtmd_helper_gen_audio_outtype out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
std::vector<char> out_buf;
+ bool stream = false;
+ size_t pcm_sent = 0; // samples already returned by get_output()
+ bool wav_header_sent = false;
};
// settings that only live in the reference's per-pack yaml, not in the checkpoint
@@ -1024,6 +1071,14 @@ void mtmd_helper_gen_audio_reset(mtmd_helper_gen_audio * ctx) {
}
}
+struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void) {
+ mtmd_helper_gen_audio_inp inp{};
+ inp.top_k = 50;
+ inp.top_p = 1.0f;
+ inp.out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
+ return inp;
+}
+
int32_t mtmd_helper_gen_audio_set_input(mtmd_helper_gen_audio * ctx, const mtmd_helper_gen_audio_inp * inp) {
if (!ctx->pipeline) {
LOG_ERR("mtmd_helper_gen_audio: unsupported or missing gen-audio pipeline\n");
@@ -1060,3 +1115,10 @@ int32_t mtmd_helper_gen_audio_get_output(mtmd_helper_gen_audio * ctx, int32_t *
}
return ctx->pipeline->get_output(out_sample_rate, out_data, out_data_len, out_n_samples);
}
+
+int32_t mtmd_helper_gen_audio_flush(mtmd_helper_gen_audio * ctx) {
+ if (!ctx->pipeline) {
+ return 1;
+ }
+ return ctx->pipeline->flush();
+}
diff --git a/tools/mtmd/mtmd-helper.h b/tools/mtmd/mtmd-helper.h
index 832f7171a..3eaa01aab 100644
--- a/tools/mtmd/mtmd-helper.h
+++ b/tools/mtmd/mtmd-helper.h
@@ -175,6 +175,7 @@ enum mtmd_helper_gen_audio_outtype {
MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV, // WAV PCM 16-bit LE, mono
};
struct mtmd_helper_gen_audio_inp {
+ bool stream; // if true, output() must be called after each step_gen()
llama_seq_id seq_id;
const char * prompt;
@@ -190,6 +191,8 @@ struct mtmd_helper_gen_audio_inp {
enum mtmd_helper_gen_audio_outtype out_type;
};
+MTMD_API struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void);
+
MTMD_API mtmd_helper_gen_audio * mtmd_helper_gen_audio_init(
struct llama_context * lctx,
struct mtmd_context * mctx);
@@ -221,6 +224,8 @@ MTMD_API int32_t mtmd_helper_gen_audio_step_gen(
// out_data valid until next get_output() or reset() call
// out_n_samples (optional, can be NULL) receives the number of generated PCM samples
+// if inp->stream is true: returns only audio produced since the previous call, and
+// *out_data_len == 0 whenever a full window_frames batch hasn't accumulated yet
MTMD_API int32_t mtmd_helper_gen_audio_get_output(
mtmd_helper_gen_audio * ctx,
int32_t * out_sample_rate,
@@ -228,6 +233,10 @@ MTMD_API int32_t mtmd_helper_gen_audio_get_output(
size_t * out_data_len,
int64_t * out_n_samples);
+// forces any buffered codes through code2wav now, regardless of window_frames;
+// call once when generation has ended, before the last get_output() in stream mode
+MTMD_API int32_t mtmd_helper_gen_audio_flush(mtmd_helper_gen_audio * ctx);
+
#ifdef __cplusplus
} // extern "C"
#endif
@@ -254,8 +263,41 @@ struct mtmd_helper_gen_audio_deleter {
};
using gen_audio_ptr = std::unique_ptr<mtmd_helper_gen_audio, mtmd_helper_gen_audio_deleter>;
struct gen_audio {
+
+ // sub-struct, RAII wrapper for mtmd_helper_gen_audio_inp
+ struct inp {
+ mtmd_helper_gen_audio_inp data = mtmd_helper_gen_audio_inp_default();
+ std::string prompt_str;
+ std::string lang_str;
+ mtmd::bitmap_ptr speaker_ref_ptr;
+
+ inp() = default;
+ inp(inp &&) = default;
+ inp & operator=(inp &&) = default;
+ inp(const inp &) = delete;
+ inp & operator=(const inp &) = delete;
+
+ void set_prompt (std::string p) { prompt_str = std::move(p); }
+ void set_lang (std::string l) { lang_str = std::move(l); }
+ void set_speaker_ref(mtmd::bitmap_ptr bmp) { speaker_ref_ptr = std::move(bmp); }
+
+ // pointers are only valid as long as *this is alive
+ const mtmd_helper_gen_audio_inp * get() {
+ data.prompt = prompt_str.c_str();
+ data.prompt_len = prompt_str.size();
+ data.lang = lang_str.empty() ? nullptr : lang_str.c_str();
+ data.speaker_ref = speaker_ref_ptr.get();
+ return &data;
+ }
+ };
+
gen_audio_ptr ctx;
- gen_audio(struct llama_context * lctx, struct mtmd_context * mctx) : ctx(mtmd_helper_gen_audio_init(lctx, mctx)) {}
+ void init(struct llama_context * lctx, struct mtmd_context * mctx) {
+ ctx.reset(mtmd_helper_gen_audio_init(lctx, mctx));
+ }
+ bool valid() const {
+ return ctx.get() != nullptr;
+ }
void reset() {
mtmd_helper_gen_audio_reset(ctx.get());
}
@@ -271,6 +313,9 @@ struct gen_audio {
int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples = nullptr) {
return mtmd_helper_gen_audio_get_output(ctx.get(), out_sample_rate, out_data, out_data_len, out_n_samples);
}
+ int32_t flush() {
+ return mtmd_helper_gen_audio_flush(ctx.get());
+ }
};
} // namespace mtmd_helper
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
index 9069463fe..b7fa1e534 100644
--- a/tools/server/server-context.cpp
+++ b/tools/server/server-context.cpp
@@ -16,6 +16,7 @@
#include "speculative.h"
#include "mtmd.h"
#include "mtmd-helper.h"
+#include "base64.hpp"
#include <algorithm>
#include <cstddef>
@@ -41,8 +42,9 @@ constexpr int HTTP_POLLING_SECONDS = 1;
static common_speculative_output_limits server_output_limits(const common_params & params) {
if (params.embedding ||
- (params.pooling_type != LLAMA_POOLING_TYPE_UNSPECIFIED && params.pooling_type != LLAMA_POOLING_TYPE_NONE)) {
- return { params.n_batch, 1 };
+ (params.pooling_type != LLAMA_POOLING_TYPE_UNSPECIFIED && params.pooling_type != LLAMA_POOLING_TYPE_NONE) ||
+ !params.mmproj.path.empty()) { // gen-audio (TTS) capability isn't known until the mmproj loads, size generously
+ return { params.n_batch, params.n_batch };
}
auto result = common_speculative_get_output_limits(
@@ -212,6 +214,30 @@ struct server_slot {
mtmd_context * mctx = nullptr;
mtmd::batch_ptr mbatch = nullptr;
+ struct tts_ctx {
+ mtmd_helper::gen_audio ctx;
+ const float * h_state;
+ llama_token sampled;
+ int32_t n_decoded;
+ bool is_supported() const {
+ return ctx.valid();
+ }
+ void reset() {
+ // mtmd_helper_gen_audio_reset() dereferences its argument before it
+ // null-checks the pipeline, and the pipeline is only allocated for
+ // models that actually carry a gen-audio mmproj. server_slot::reset()
+ // runs for every slot of every model, so without this guard any
+ // non-TTS model segfaults during slot initialization.
+ if (is_supported()) {
+ ctx.reset();
+ }
+ h_state = nullptr;
+ sampled = LLAMA_TOKEN_NULL;
+ n_decoded = 0;
+ }
+ };
+ tts_ctx tts;
+
// speculative decoding
common_speculative * spec;
@@ -391,6 +417,8 @@ struct server_slot {
// clear multimodal state
mbatch.reset();
+
+ tts.reset();
}
void init_sampler() const {
@@ -829,6 +857,14 @@ public:
mtmd_context * mctx = nullptr;
const llama_vocab * vocab = nullptr;
+ bool has_cap_tts() const {
+ return mctx != nullptr && mtmd_gen_audio_get_info(mctx).type != MTMD_GEN_AUDIO_TYPE_NONE;
+ }
+
+ bool has_cap_chat() const {
+ return mctx == nullptr || mtmd_helper_model_can_chat(ctx_tgt, mctx);
+ }
+
server_queue queue_tasks;
server_response queue_results;
@@ -1288,6 +1324,10 @@ private:
slot.mctx = mctx;
slot.prompt.tokens.has_mtmd = mctx != nullptr;
+ if (has_cap_tts()) {
+ slot.tts.ctx.init(ctx_tgt, mctx);
+ }
+
SLT_TRC(slot, "new slot, n_ctx = %d\n", slot.n_ctx);
slot.callback_on_release = [this](int id_slot) {
@@ -1748,6 +1788,28 @@ private:
SLT_DBG(slot, "launching slot : %s\n", safe_json_to_str(slot.to_json()).c_str());
+ if (task.type == SERVER_TASK_TYPE_TTS) {
+ GGML_ASSERT(has_cap_tts()); // should already checked in route handler
+ if (!slot.tts.is_supported()) {
+ slot.tts.ctx.init(ctx_tgt, slot.mctx);
+ }
+
+ // TTS slots never enter the shared batch: pre_decode() returns early for
+ // them and process_tts_slots() drives them instead, so they skip the
+ // prompt-cache bookkeeping that clears this sequence between requests.
+ // The gen-audio pipeline always decodes from position 0, and its own
+ // reset() only clears host-side buffers, so without this the second and
+ // later tasks on a slot decode over the previous request's tokens and
+ // step_prompt() fails immediately.
+ slot.prompt_clear();
+
+ task.tts_inp.data.seq_id = slot.id;
+ if (slot.tts.ctx.set_input(task.tts_inp.get()) != 0) {
+ send_error(task, "failed to process TTS prompt", ERROR_TYPE_SERVER);
+ return false;
+ }
+ }
+
// initialize samplers
if (task.need_sampling()) {
try {
@@ -1765,6 +1827,9 @@ private:
// TODO: getting pre sampling logits is not yet supported with backend sampling
use_backend_sampling &= !need_pre_sample_logits;
+ // TODO: check verify if this actually works with TTS
+ use_backend_sampling &= task.type != SERVER_TASK_TYPE_TTS;
+
// TODO: tmp until backend sampling is fully implemented
if (use_backend_sampling) {
llama_set_sampler(ctx_tgt, slot.id, common_sampler_get(slot.smpl.get()));
@@ -1783,9 +1848,13 @@ private:
slot.task = std::make_unique<const server_task>(std::move(task));
- slot.state = slot.task->is_child()
- ? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
- : SLOT_STATE_STARTED;
+ if (slot.task->type == SERVER_TASK_TYPE_TTS) {
+ slot.state = SLOT_STATE_PROCESSING_PROMPT;
+ } else {
+ slot.state = slot.task->is_child()
+ ? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
+ : SLOT_STATE_STARTED;
+ }
// reset server kill-switch counter
n_empty_consecutive = 0;
@@ -2050,6 +2119,18 @@ private:
queue_results.send(std::move(res));
}
+ void send_tts_result(server_slot & slot, int32_t sample_rate, const char * data, size_t data_len, bool final) {
+ auto res = std::make_unique<server_task_result_tts>();
+
+ res->id = slot.task->id;
+ res->index = slot.task->index;
+ res->sample_rate = sample_rate;
+ res->audio.assign(data, data_len);
+ res->final = final;
+
+ queue_results.send(std::move(res));
+ }
+
void send_final_response(server_slot & slot) {
auto res = std::make_unique<server_task_result_cmpl_final>();
@@ -2556,6 +2637,7 @@ private:
case SERVER_TASK_TYPE_EMBEDDING:
case SERVER_TASK_TYPE_RERANK:
case SERVER_TASK_TYPE_SCORE:
+ case SERVER_TASK_TYPE_TTS:
{
// special case: if input is provided via CLI, tokenize it first
// otherwise, no need to tokenize as it's already done inside the HTTP thread
@@ -3007,1 +3089,9 @@ private:
+ // note: TTS slots bypass the shared batch entirely
+ try {
+ process_tts_slots();
+ } catch (const std::exception & e) {
+ SRV_ERR("process_tts_slots() failed: %s\n", e.what());
+ abort_all_slots("process_tts_slots() failed: " + std::string(e.what()));
+ }
+
GGML_ASSERT(batch.slot_batched || batch.size() == 0);
@@ -3074,10 +3164,77 @@ private:
}
}
+ void process_tts_slots() {
+ iterate(slots, [&](server_slot & slot) {
+ if (!slot.is_processing() || slot.task->type != SERVER_TASK_TYPE_TTS) {
+ return;
+ }
+
+ llama_set_embeddings(ctx_tgt, true);
+
+ if (slot.state == SLOT_STATE_PROCESSING_PROMPT) {
+ const int32_t ret = slot.tts.ctx.step_prompt(llama_n_batch(ctx_tgt));
+ if (ret < 0) {
+ send_error(slot, "TTS prompt processing failed", ERROR_TYPE_SERVER);
+ slot.release();
+ } else if (ret == 0) {
+ slot.tts.sampled = common_sampler_sample(slot.smpl.get(), ctx_tgt, -1);
+ common_sampler_accept(slot.smpl.get(), slot.tts.sampled, true);
+ slot.tts.h_state = llama_get_embeddings_ith(ctx_tgt, -1);
+ slot.state = SLOT_STATE_GENERATING;
+ }
+ return;
+ }
+
+ const int32_t n_predict = slot.task->params.n_predict > 0 ? slot.task->params.n_predict : 512;
+ if (slot.tts.n_decoded >= n_predict || llama_vocab_is_eog(vocab, slot.tts.sampled)) {
+ int32_t sample_rate = 0;
+ const char * data = nullptr;
+ size_t data_len = 0;
+ // generation truly ends here: force out any sub-window remainder still buffered
+ if (slot.tts.ctx.flush() != 0 || slot.tts.ctx.get_output(&sample_rate, &data, &data_len) != 0) {
+ send_error(slot, "failed to finalize TTS output", ERROR_TYPE_SERVER);
+ } else {
+ send_tts_result(slot, sample_rate, data, data_len, true);
+ }
+ slot.release();
+ return;
+ }
+
+ const float * h_state_next = nullptr;
+ if (slot.tts.ctx.step_gen(slot.tts.sampled, slot.tts.h_state, &h_state_next) != 0) {
+ send_error(slot, "TTS generation failed", ERROR_TYPE_SERVER);
+ slot.release();
+ return;
+ }
+ slot.tts.h_state = h_state_next;
+ slot.tts.n_decoded++;
+
+ slot.tts.sampled = common_sampler_sample(slot.smpl.get(), ctx_tgt, -1);
+ common_sampler_accept(slot.smpl.get(), slot.tts.sampled, true);
+
+ if (slot.task->params.stream) {
+ int32_t sample_rate = 0;
+ const char * data = nullptr;
+ size_t data_len = 0;
+ if (slot.tts.ctx.get_output(&sample_rate, &data, &data_len) != 0) {
+ send_error(slot, "TTS streaming output failed", ERROR_TYPE_SERVER);
+ slot.release();
+ } else if (data_len > 0) {
+ send_tts_result(slot, sample_rate, data, data_len, false);
+ }
+ }
+ });
+ }
+
void pre_decode() {
// apply context-shift if needed
// TODO: simplify and improve
iterate(slots, [&](server_slot & slot) {
+ if (slot.task && slot.task->type == SERVER_TASK_TYPE_TTS) {
+ // TTS slots drive their own decode loop in process_tts_slots(), never enter the shared batch
+ return;
+ }
if (slot.state == SLOT_STATE_GENERATING && slot.prompt.n_tokens() + 1 >= slot.n_ctx) {
if (!params_base.ctx_shift) {
// this check is redundant (for good)
@@ -3150,7 +3307,7 @@ private:
// determine which slots are generating and drafting
iterate(slots, [&](server_slot & slot) {
- if (slot.state != SLOT_STATE_GENERATING) {
+ if (slot.state != SLOT_STATE_GENERATING || slot.task->type == SERVER_TASK_TYPE_TTS) {
return;
}
@@ -3284,7 +3441,7 @@ private:
return; // batch is full, skip remaining slots
}
- if (!slot.is_processing()) {
+ if (!slot.is_processing() || slot.task->type == SERVER_TASK_TYPE_TTS) {
return;
}
@@ -4433,6 +4590,8 @@ server_context_meta server_context::get_meta() const {
/* has_inp_image */ impl->chat_params.allow_image,
/* has_inp_audio */ impl->chat_params.allow_audio,
/* has_inp_video */ impl->chat_params.allow_video,
+ /* has_cap_chat */ impl->has_cap_chat(),
+ /* has_cap_tts */ impl->has_cap_tts(),
/* json_ui_settings */ impl->json_ui_settings,
/* slot_n_ctx */ impl->get_slot_n_ctx(),
/* pooling_type */ llama_pooling_type(impl->ctx_tgt),
@@ -4512,6 +4671,11 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
res->set_req(&req); // will also set spipe if needed
+ if (!ctx_server.has_cap_chat()) {
+ res->error(format_error_response("this server does not support chat/completions", ERROR_TYPE_NOT_SUPPORTED));
+ return res;
+ }
+
int32_t sse_ping_interval = params.sse_ping_interval;
try {
@@ -5399,6 +5563,150 @@ void server_routes::init_routes() {
return res;
};
+ this->post_tts = [this](const server_http_req & req) {
+ auto res = create_response();
+ res->set_req(&req); // will also set spipe if needed
+
+ if (!ctx_server.has_cap_tts()) {
+ res->error(format_error_response("this server does not support audio generation", ERROR_TYPE_NOT_SUPPORTED));
+ return res;
+ }
+
+ const json body = json::parse(req.body);
+
+ std::string prompt = json_value(body, "input", json_value(body, "prompt", std::string()));
+ if (prompt.empty()) {
+ res->error(format_error_response("\"input\" must be a non-empty string", ERROR_TYPE_INVALID_REQUEST));
+ return res;
+ }
+
+ const std::string response_format = json_value(body, "response_format", std::string("wav"));
+ const bool stream = json_value(body, "stream", false);
+
+ server_task task(SERVER_TASK_TYPE_TTS);
+ task.tts_inp.set_prompt(prompt);
+ task.tts_inp.set_lang(json_value(body, "lang", std::string()));
+ task.tts_inp.data.top_k = json_value(body, "top_k", 0);
+ task.tts_inp.data.top_p = json_value(body, "top_p", 0.0f);
+ task.tts_inp.data.stream = stream;
+ task.tts_inp.data.out_type = response_format == "pcm"
+ ? MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM
+ : MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
+ task.params.stream = stream;
+ task.params.n_predict = json_value(body, "n_predict", -1);
+ task.params.sampling = params.sampling; // baseline defaults, then apply overrides below
+ task.params.sampling.penalty_repeat = json_value(body, "repeat_penalty", 1.05f);
+ task.params.sampling.penalty_last_n = -1;
+ if (task.tts_inp.data.top_k > 0) {
+ task.params.sampling.top_k = task.tts_inp.data.top_k;
+ }
+ if (task.tts_inp.data.top_p > 0) {
+ task.params.sampling.top_p = task.tts_inp.data.top_p;
+ }
+
+ // speaker reference: either an uploaded form file ("speaker_ref") or a base64 JSON field ("speaker_ref_b64")
+ const unsigned char * speaker_ref_data = nullptr;
+ size_t speaker_ref_len = 0;
+ std::string speaker_ref_b64_decoded;
+
+ auto speaker_ref_file = req.files.find("speaker_ref");
+ if (speaker_ref_file != req.files.end()) {
+ speaker_ref_data = speaker_ref_file->second.data.data();
+ speaker_ref_len = speaker_ref_file->second.data.size();
+ } else {
+ std::string speaker_ref_b64 = json_value(body, "speaker_ref_b64", std::string());
+ if (!speaker_ref_b64.empty()) {
+ speaker_ref_b64_decoded = base64::decode(speaker_ref_b64);
+ speaker_ref_data = (const unsigned char *) speaker_ref_b64_decoded.data();
+ speaker_ref_len = speaker_ref_b64_decoded.size();
+ }
+ }
+
+ if (speaker_ref_len > 0) {
+ auto wrapper = mtmd_helper_bitmap_init_from_buf(ctx_server.mctx, speaker_ref_data, speaker_ref_len, false, ctx_server.init_opt);
+ if (!wrapper.bitmap) {
+ res->error(format_error_response("failed to decode \"speaker_ref\"", ERROR_TYPE_INVALID_REQUEST));
+ return res;
+ }
+ task.tts_inp.set_speaker_ref(mtmd::bitmap_ptr(wrapper.bitmap));
+ } else {
+ // SRV_WRN expands __VA_ARGS__ without the GNU comma-elision extension,
+ // so a bare format string leaves a trailing comma and will not compile
+ SRV_WRN("%s", "no speaker reference provided, the model may behave randomly\n");
+ }
+
+ auto & rd = res->rd;
+ task.id = rd.get_new_id();
+ rd.post_task(std::move(task));
+
+ const std::string content_type = response_format == "pcm" ? "audio/L16" : "audio/wav";
+
+ if (!stream) {
+ auto result = rd.next(req.should_stop);
+ if (!result) {
+ GGML_ASSERT(req.should_stop());
+ return res; // connection is closed
+ }
+ if (result->is_error()) {
+ res->error(result->to_json());
+ return res;
+ }
+ auto * tts_res = dynamic_cast<server_task_result_tts *>(result.get());
+ GGML_ASSERT(tts_res != nullptr);
+ res->status = 200;
+ res->content_type = content_type;
+ res->data = std::move(tts_res->audio);
+ return res;
+ } else {
+ auto first_result = rd.next(req.should_stop);
+ if (!first_result) {
+ GGML_ASSERT(req.should_stop());
+ return res; // connection is closed
+ }
+ if (first_result->is_error()) {
+ res->error(first_result->to_json());
+ return res;
+ }
+ auto * first_tts_res = dynamic_cast<server_task_result_tts *>(first_result.get());
+ GGML_ASSERT(first_tts_res != nullptr);
+
+ res->status = 200;
+ res->content_type = content_type;
+ res->data = std::move(first_tts_res->audio);
+ bool is_done = first_tts_res->final;
+
+ res->set_next([res_this = res.get(), is_done](std::string & output) mutable -> bool {
+ if (is_done) {
+ return false;
+ }
+ if (res_this->should_stop()) {
+ return false;
+ }
+ if (!res_this->data.empty()) {
+ output = std::move(res_this->data);
+ res_this->data.clear();
+ return true;
+ }
+
+ server_response_reader & rd = res_this->rd;
+ if (!rd.has_next()) {
+ return false;
+ }
+ auto result = rd.next([&res_this]() { return res_this->should_stop(); });
+ if (!result || result->is_error()) {
+ return false;
+ }
+ auto * tts_res = dynamic_cast<server_task_result_tts *>(result.get());
+ GGML_ASSERT(tts_res != nullptr);
+ output = std::move(tts_res->audio);
+ is_done = tts_res->final;
+ return true;
+ });
+ }
+
+ return res;
+ };
+
this->get_lora_adapters = [this](const server_http_req & req) {
auto res = create_response();
diff --git a/tools/server/server-context.h b/tools/server/server-context.h
index f9ab1132b..610512678 100644
--- a/tools/server/server-context.h
+++ b/tools/server/server-context.h
@@ -22,6 +22,8 @@ struct server_context_meta {
bool has_inp_image;
bool has_inp_audio;
bool has_inp_video;
+ bool has_cap_chat;
+ bool has_cap_tts;
json json_ui_settings;
int slot_n_ctx;
enum llama_pooling_type pooling_type;
@@ -151,6 +153,7 @@ struct server_routes {
server_http_context::handler_t post_embeddings;
server_http_context::handler_t post_embeddings_oai;
server_http_context::handler_t post_rerank;
+ server_http_context::handler_t post_tts;
server_http_context::handler_t get_lora_adapters;
server_http_context::handler_t post_lora_adapters;
diff --git a/tools/server/server-task.cpp b/tools/server/server-task.cpp
index 1ee677553..939630b8b 100644
--- a/tools/server/server-task.cpp
+++ b/tools/server/server-task.cpp
@@ -1497,6 +1497,17 @@ json server_task_result_rerank::to_json() {
};
}
+//
+// server_task_result_tts
+//
+json server_task_result_tts::to_json() {
+ return json {
+ {"sample_rate", sample_rate},
+ {"n_bytes", audio.size()},
+ {"final", final},
+ };
+}
+
//
// server_task_result_error
//
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
index 5bedf1987..e6ca67a65 100644
--- a/tools/server/server-task.h
+++ b/tools/server/server-task.h
@@ -10,6 +10,7 @@
// TODO: prevent including the whole server-common.h as we only use server_tokens
#include "server-common.h"
+#include "mtmd-helper.h"
using json = nlohmann::ordered_json;
@@ -42,6 +43,7 @@ enum server_task_type {
SERVER_TASK_TYPE_SLOT_ERASE,
SERVER_TASK_TYPE_GET_LORA,
SERVER_TASK_TYPE_SET_LORA,
+ SERVER_TASK_TYPE_TTS,
};
// TODO: change this to more generic "response_format" to replace the "format_response_*" in server-common
@@ -202,6 +204,9 @@ struct server_task {
// used by SERVER_TASK_TYPE_SET_LORA
std::map<int, float> set_lora; // mapping adapter ID -> scale
+ // used by SERVER_TASK_TYPE_TTS
+ mtmd_helper::gen_audio::inp tts_inp;
+
server_task() = default;
server_task(server_task_type type) : type(type) {}
@@ -235,6 +240,7 @@ struct server_task {
switch (type) {
case SERVER_TASK_TYPE_COMPLETION:
case SERVER_TASK_TYPE_INFILL:
+ case SERVER_TASK_TYPE_TTS:
return true;
default:
return false;
@@ -494,5 +500,15 @@ struct server_task_result_embd : server_task_result {
json to_json_oaicompat();
};
+struct server_task_result_tts : server_task_result {
+ std::string audio; // raw bytes for this chunk (WAV or PCM, per request's out_type)
+ int32_t sample_rate = 0;
+ bool final = false; // true for the last chunk of a request
+
+ virtual bool is_stop() override { return final; }
+
+ virtual json to_json() override;
+};
+
struct server_task_result_rerank : server_task_result {
float score = -1e6;
+49
View File
@@ -15,6 +15,30 @@ if [ -d "patches" ]; then
done
fi
## Apple RDMA link fixup.
## ggml-rpc hands Apple's librdma to the linker with
## target_link_options(ggml-rpc PRIVATE "LINKER:-weak_library,..."). Link options are not
## a usage requirement of a static library, so in our BUILD_SHARED_LIBS=OFF build the flag
## dies with libggml-rpc.a and every ibv_* symbol transport-apple.cpp reaches for comes out
## undefined when grpc-server and ggml-rpc-server link. Re-declare the same weak link as
## INTERFACE so it travels to whoever links the static library.
##
## Guarded on the marker so a second prepare.sh over the same checkout is a no-op, and on
## GGML_RPC_RDMA_APPLE so forks that branched before the Apple RDMA transport (turboquant,
## bonsai) are left alone.
RPC_CMAKE=llama.cpp/ggml/src/ggml-rpc/CMakeLists.txt
if [ -f "$RPC_CMAKE" ] && grep -q "GGML_RPC_RDMA_APPLE" "$RPC_CMAKE" && ! grep -q "LOCALAI_RDMA_IFACE" "$RPC_CMAKE"; then
echo "==> ggml-rpc carries the Apple RDMA transport, re-declaring its weak librdma link as INTERFACE"
cat >> "$RPC_CMAKE" <<'EOF'
# LOCALAI_RDMA_IFACE: added by backend/cpp/llama-cpp/prepare.sh
if (GGML_RPC_RDMA AND APPLE AND NOT BUILD_SHARED_LIBS)
target_link_options(ggml-rpc INTERFACE "LINKER:-weak_library,${RDMA_LIB}")
endif()
EOF
fi
for file in $(ls llama.cpp/tools/server/); do
cp -rfv llama.cpp/tools/server/$file llama.cpp/tools/grpc-server/
done
@@ -28,6 +52,13 @@ cp -r message_content_test.cpp llama.cpp/tools/grpc-server/
# Generic passthrough parser staging and its standalone regression test.
cp -r passthrough_options.h llama.cpp/tools/grpc-server/
cp -r passthrough_options_test.cpp llama.cpp/tools/grpc-server/
# TTS request validation (included by grpc-server.cpp) and its standalone
# regression test.
cp -r tts_request_options.h llama.cpp/tools/grpc-server/
cp -r tts_request_options_test.cpp llama.cpp/tools/grpc-server/
# Thread-count default normalization and its standalone regression test.
cp -r thread_params.h llama.cpp/tools/grpc-server/
cp -r thread_params_test.cpp llama.cpp/tools/grpc-server/
# Parent-death watcher (included by grpc-server.cpp) and its standalone unit
# test (run via backend/cpp/run-unit-tests.sh; also buildable under ctest).
cp -r parent_watch.h llama.cpp/tools/grpc-server/
@@ -49,10 +80,28 @@ else
echo "==> llama.cpp predates the load-mode enum, using the legacy mmap/mlock/direct-io booleans"
LEGACY_LOAD_MODE=1
fi
if grep -q "server_metrics metrics;" llama.cpp/tools/server/server-task.h; then
HAS_SERVER_METRICS=1
else
HAS_SERVER_METRICS=0
fi
if grep -q "mtmd_helper_init_opt" llama.cpp/tools/mtmd/mtmd-helper.h; then
HAS_MTMD_INIT_OPT=1
else
HAS_MTMD_INIT_OPT=0
fi
if grep -q "llm_add_n_cpu_ffn_overrides" llama.cpp/common/common.h; then
HAS_N_CPU_FFN_HELPER=1
else
HAS_N_CPU_FFN_HELPER=0
fi
cat > llama.cpp/tools/grpc-server/llama_compat.h <<EOF
// Generated by backend/cpp/llama-cpp/prepare.sh. Do not edit.
#pragma once
#define LOCALAI_LEGACY_LOAD_MODE ${LEGACY_LOAD_MODE}
#define LOCALAI_HAS_SERVER_METRICS ${HAS_SERVER_METRICS}
#define LOCALAI_HAS_MTMD_INIT_OPT ${HAS_MTMD_INIT_OPT}
#define LOCALAI_HAS_N_CPU_FFN_HELPER ${HAS_N_CPU_FFN_HELPER}
EOF
set +e
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <cstdint>
namespace llama_grpc {
inline int32_t resolve_batch_threads(int32_t batch_threads, int32_t inference_threads) {
return batch_threads < 0 ? inference_threads : batch_threads;
}
} // namespace llama_grpc
@@ -0,0 +1,15 @@
#include "thread_params.h"
#include <cstdio>
int main() {
if (llama_grpc::resolve_batch_threads(-1, 4) != 4) {
std::fprintf(stderr, "default batch threads did not inherit inference threads\n");
return 1;
}
if (llama_grpc::resolve_batch_threads(2, 4) != 2) {
std::fprintf(stderr, "explicit batch threads were overwritten\n");
return 1;
}
return 0;
}
+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
#include <exception>
#include <map>
#include <string>
namespace llama_grpc {
// Validated, parsed form of a backend::TTSRequest, kept free of llama.cpp,
// mtmd and gRPC headers so backend/cpp/run-unit-tests.sh can compile it as a
// standalone translation unit. grpc-server.cpp turns this into a
// mtmd_helper::gen_audio::inp.
struct tts_request_options {
bool ok = false;
std::string error;
std::string text;
std::string voice_path;
std::string language;
// 0 / 0.0f mean "unset": upstream only overrides the sampler defaults when
// the value is strictly positive.
int32_t top_k = 0;
float top_p = 0.0f;
// Upper bound on generated audio frames, exposed because the model does not
// always emit its codec EOS and will otherwise run to the 512-frame default,
// which is roughly 41 s at the 12.5 Hz frame rate. 0 means unset, leaving
// that default in place.
int32_t max_frames = 0;
};
namespace detail {
// Strict whole-string numeric parsing. std::stoi/stof accept trailing garbage
// ("40abc" -> 40), which would silently honour a typo'd request.
inline bool parse_whole_int32(const std::string & value, int32_t & out) {
if (value.empty()) {
return false;
}
try {
size_t consumed = 0;
const long parsed = std::stol(value, &consumed);
if (consumed != value.size()) {
return false;
}
if (parsed < INT32_MIN || parsed > INT32_MAX) {
return false;
}
out = static_cast<int32_t>(parsed);
return true;
} catch (const std::exception &) {
return false;
}
}
inline bool parse_whole_float(const std::string & value, float & out) {
if (value.empty()) {
return false;
}
try {
size_t consumed = 0;
const float parsed = std::stof(value, &consumed);
if (consumed != value.size()) {
return false;
}
out = parsed;
return true;
} catch (const std::exception &) {
return false;
}
}
inline tts_request_options reject(const std::string & message) {
tts_request_options opts;
opts.ok = false;
opts.error = message;
return opts;
}
} // namespace detail
inline tts_request_options parse_tts_request_options(
const std::string & text,
const std::string & voice,
const std::string & language,
const std::map<std::string, std::string> & params) {
if (text.empty()) {
return detail::reject("text must be a non-empty string");
}
// The Qwen3-TTS Base checkpoints have no built-in speaker. Without a
// reference clip the model picks an arbitrary voice, so an unset voice is
// a request error rather than a defaulted one.
if (voice.empty()) {
return detail::reject("voice must name a speaker reference audio file");
}
tts_request_options opts;
opts.text = text;
opts.voice_path = voice;
opts.language = language;
// Both values are range-checked here rather than left to the caller: the
// consumer copies them straight into mtmd_helper::gen_audio::inp, and only
// its separate sampler assignment is guarded by "> 0". An out-of-range or
// non-finite value would slip past that guard and reach llama.cpp.
const auto top_k_it = params.find("top_k");
if (top_k_it != params.end()) {
if (!detail::parse_whole_int32(top_k_it->second, opts.top_k)) {
return detail::reject("top_k must be an integer, got \"" + top_k_it->second + "\"");
}
if (opts.top_k < 0) {
return detail::reject("top_k must be >= 0, got \"" + top_k_it->second + "\"");
}
}
const auto top_p_it = params.find("top_p");
if (top_p_it != params.end()) {
if (!detail::parse_whole_float(top_p_it->second, opts.top_p)) {
return detail::reject("top_p must be a number, got \"" + top_p_it->second + "\"");
}
// Phrased as a negated in-range test, not "p < 0.0f || p > 1.0f",
// because every comparison against NaN is false: the obvious form
// would accept NaN, and NaN then defeats the consumer's "> 0" guard
// too, since that comparison is false as well.
if (!(opts.top_p >= 0.0f && opts.top_p <= 1.0f)) {
return detail::reject("top_p must be between 0.0 and 1.0, got \"" + top_p_it->second + "\"");
}
}
const auto max_frames_it = params.find("max_frames");
if (max_frames_it != params.end()) {
if (!detail::parse_whole_int32(max_frames_it->second, opts.max_frames)) {
return detail::reject("max_frames must be an integer, got \"" + max_frames_it->second + "\"");
}
if (opts.max_frames < 0) {
return detail::reject("max_frames must be >= 0, got \"" + max_frames_it->second + "\"");
}
}
opts.ok = true;
return opts;
}
} // namespace llama_grpc
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: MIT
#include <cstdio>
#include <map>
#include <string>
#include "tts_request_options.h"
static int failures = 0;
static void check(bool ok, const char * name) {
if (!ok) {
++failures;
std::fprintf(stderr, "FAIL: %s\n", name);
}
}
static void test_accepts_a_minimal_valid_request() {
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "en", {});
check(opts.ok, "minimal request is accepted");
check(opts.error.empty(), "minimal request has no error");
check(opts.text == "Hello world", "text passes through");
check(opts.voice_path == "/models/voices/ref.wav", "voice path passes through");
check(opts.language == "en", "language passes through");
check(opts.top_k == 0, "top_k defaults to the unset sentinel");
check(opts.top_p == 0.0f, "top_p defaults to the unset sentinel");
check(opts.max_frames == 0, "max_frames defaults to the unset sentinel");
}
static void test_rejects_empty_text() {
const auto opts = llama_grpc::parse_tts_request_options(
"", "/models/voices/ref.wav", "en", {});
check(!opts.ok, "empty text is rejected");
check(opts.error.find("text") != std::string::npos, "empty-text error names the field");
}
static void test_rejects_missing_speaker_reference() {
// Qwen3-TTS Base has no built-in speaker; without a reference it produces
// an arbitrary voice, so this must be a hard error rather than a surprise.
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "", "en", {});
check(!opts.ok, "missing voice is rejected");
check(opts.error.find("voice") != std::string::npos, "missing-voice error names the field");
}
static void test_parses_sampling_params() {
const std::map<std::string, std::string> params{
{"top_k", "40"},
{"top_p", "0.85"},
};
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", params);
check(opts.ok, "sampling params are accepted");
check(opts.top_k == 40, "top_k is parsed");
check(opts.top_p > 0.849f && opts.top_p < 0.851f, "top_p is parsed");
check(opts.language.empty(), "absent language stays empty");
}
static void test_rejects_malformed_sampling_params() {
const auto bad_top_k = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "forty"}});
check(!bad_top_k.ok, "non-numeric top_k is rejected");
check(bad_top_k.error.find("top_k") != std::string::npos, "top_k error names the field");
const auto bad_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", ""}});
check(!bad_top_p.ok, "empty top_p is rejected");
const auto trailing = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "40abc"}});
check(!trailing.ok, "top_k with trailing garbage is rejected");
const auto trailing_float = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "0.8abc"}});
check(!trailing_float.ok, "top_p with trailing garbage is rejected");
// std::stol returns a long, which is wider than int32_t on 64-bit hosts, so
// an in-range-for-long value still has to be caught before the narrowing.
const auto overflow_top_k = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "99999999999"}});
check(!overflow_top_k.ok, "top_k beyond int32 range is rejected");
check(overflow_top_k.error.find("top_k") != std::string::npos,
"top_k overflow error names the field");
}
static void test_rejects_out_of_range_sampling_params() {
// These reach mtmd_helper::gen_audio::inp unconditionally downstream, where
// the "> 0" sampler guard does not screen them, so they must die here.
const auto negative_top_k = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "-5"}});
check(!negative_top_k.ok, "negative top_k is rejected");
check(negative_top_k.error.find("top_k") != std::string::npos,
"negative top_k error names the field");
const auto negative_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "-0.1"}});
check(!negative_top_p.ok, "negative top_p is rejected");
check(negative_top_p.error.find("top_p") != std::string::npos,
"negative top_p error names the field");
const auto large_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "1.5"}});
check(!large_top_p.ok, "top_p above 1.0 is rejected");
// NaN survives a naive "p < 0.0f || p > 1.0f" range test because every
// comparison against NaN is false. This case pins the correct form.
const auto nan_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "nan"}});
check(!nan_top_p.ok, "NaN top_p is rejected");
const auto inf_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "inf"}});
check(!inf_top_p.ok, "infinite top_p is rejected");
}
static void test_accepts_sampling_param_boundaries() {
const auto zero_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "0.0"}});
check(zero_top_p.ok, "top_p of 0.0 is accepted");
check(zero_top_p.top_p == 0.0f, "top_p of 0.0 round-trips");
const auto one_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "1.0"}});
check(one_top_p.ok, "top_p of 1.0 is accepted");
check(one_top_p.top_p == 1.0f, "top_p of 1.0 round-trips");
const auto zero_top_k = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "0"}});
check(zero_top_k.ok, "top_k of 0 is accepted");
}
static void test_parses_max_frames() {
// The consumer maps a positive value onto n_predict and leaves upstream's
// 512-frame default in place when it is unset, so the sentinel matters as
// much as the parsed value.
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "120"}});
check(opts.ok, "max_frames is accepted");
check(opts.max_frames == 120, "max_frames is parsed");
const auto absent = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "40"}});
check(absent.ok, "a request without max_frames is accepted");
check(absent.max_frames == 0, "absent max_frames leaves the unset sentinel");
const auto zero = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "0"}});
check(zero.ok, "max_frames of 0 is accepted");
check(zero.max_frames == 0, "max_frames of 0 means unset");
}
static void test_rejects_malformed_max_frames() {
const auto negative = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "-1"}});
check(!negative.ok, "negative max_frames is rejected");
check(negative.error.find("max_frames") != std::string::npos,
"negative max_frames error names the field");
const auto non_numeric = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "many"}});
check(!non_numeric.ok, "non-numeric max_frames is rejected");
check(non_numeric.error.find("max_frames") != std::string::npos,
"non-numeric max_frames error names the field");
const auto trailing = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "120abc"}});
check(!trailing.ok, "max_frames with trailing garbage is rejected");
const auto empty = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", ""}});
check(!empty.ok, "empty max_frames is rejected");
const auto overflow = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "99999999999"}});
check(!overflow.ok, "max_frames beyond int32 range is rejected");
}
static void test_ignores_unknown_params() {
// Unknown keys are backend-specific knobs meant for other TTS engines. A
// request routed here must not fail just because it carries them.
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"exaggeration", "0.7"}});
check(opts.ok, "unknown params are ignored, not rejected");
}
int main() {
test_accepts_a_minimal_valid_request();
test_rejects_empty_text();
test_rejects_missing_speaker_reference();
test_parses_sampling_params();
test_rejects_malformed_sampling_params();
test_rejects_out_of_range_sampling_params();
test_accepts_sampling_param_boundaries();
test_parses_max_frames();
test_rejects_malformed_max_frames();
test_ignores_unknown_params();
if (failures == 0) {
std::printf("tts_request_options_test: all checks passed\n");
}
return failures;
}
+2
View File
@@ -48,6 +48,7 @@ define turboquant-build
# stays compiling against vanilla upstream.
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
$(info $(GREEN)I turboquant build info:$(1)$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(TURBOQUANT_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build llama.cpp
@@ -86,6 +87,7 @@ turboquant-cpu-all:
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build purge
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
$(info $(GREEN)I turboquant build info:cpu-all-variants$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(TURBOQUANT_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build llama.cpp
@@ -8,6 +8,8 @@
# so the grpc-server option parser skips the two references to
# common_params::checkpoint_min_step (the default and the option handler).
# That field does not exist in the fork yet; drop this once it does.
# 3. Use nlohmann's parse_error type in JSON catch clauses because the fork
# predates upstream's common_json_error wrapper.
#
# The fork used to lag upstream on the whole common_params_speculative refactor
# (ggml-org/llama.cpp#22397/#22838/#22964), the model_tgt rename (#22838) and
@@ -100,4 +102,16 @@ else
echo "==> LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP define OK"
fi
# 3. The shared source follows current upstream and catches common_json_error.
# TurboQuant still exposes nlohmann::json directly, so its equivalent parse
# failures use json::parse_error instead.
if grep -q 'common_json_error' "$SRC"; then
echo "==> patching $SRC to use the TurboQuant JSON exception type"
awk '{ gsub(/common_json_error/, "json::parse_error"); print }' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> TurboQuant JSON exception patch OK"
else
echo "==> $SRC already uses a TurboQuant-compatible JSON exception type, skipping"
fi
echo "==> all patches applied"
+1 -1
View File
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
CRISPASR_VERSION?=21901d3f7c23554f072964828363e49ddbc2dc68
CRISPASR_VERSION?=c6d4265ae2ee2b8931b09d7d25d5c65c75c36a41
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
+1 -1
View File
@@ -14,7 +14,7 @@ JOBS?=$(shell nproc --ignore=1)
# It is kept alive by the upstream tag da2-support (survives a squash-merge);
# repoint to the master merge commit once mudler/depth-anything.cpp PR #1 lands.
DEPTHANYTHING_REPO?=https://github.com/mudler/depth-anything.cpp.git
DEPTHANYTHING_VERSION?=2028b47ac75a8659c6a9aa617baf09be193eb55f
DEPTHANYTHING_VERSION?=02ba082274e001a63e50de5a1eb0ccc50c6af4b1
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
+7 -2
View File
@@ -38,8 +38,9 @@ type Store struct {
// keysAreNormalized stays true until any non-unit-magnitude key
// is added; once false, the magnitude-aware fallback path is
// used by Find. Re-evaluated only at Set time, never again on
// its own — a deletion of the offending key does NOT flip it
// back to true (the bookkeeping cost would dominate the gain).
// its own — a partial deletion of the offending key does NOT flip
// it back to true (the bookkeeping cost would dominate the gain).
// An empty store returns to its initial state.
keysAreNormalized bool
// keyLen is the dimension of every stored key. -1 means "no
@@ -142,6 +143,10 @@ func (s *Store) StoresDelete(opts *pb.StoresDeleteOptions) error {
mergedV = append(mergedV, tailV...)
s.keys = mergedK
s.values = mergedV
if len(s.keys) == 0 {
s.keyLen = -1
s.keysAreNormalized = true
}
assert(slices.IsSortedFunc(s.keys, slices.Compare[[]float32]), "Delete: s.keys not sorted post-merge")
assert(len(s.keys) == len(s.values), "Delete: keys/values length skew")
return nil
+40
View File
@@ -105,6 +105,46 @@ var _ = Describe("StoresDelete", func() {
})).To(Succeed(), "delete of missing key should succeed")
Expect(s.keys).To(HaveLen(1))
})
It("reopens the dimension after deleting every key", func() {
s := NewStore()
oldKey := []float32{2, 0, 0}
mustSet(s, [][]float32{oldKey}, [][]byte{[]byte("3d")})
Expect(s.keysAreNormalized).To(BeFalse())
Expect(s.StoresDelete(&pb.StoresDeleteOptions{
Keys: wrapKeys([][]float32{oldKey}),
})).To(Succeed())
Expect(s.keys).To(BeEmpty())
Expect(s.keyLen).To(Equal(-1))
Expect(s.keysAreNormalized).To(BeTrue())
newKey := normalizeVec([]float32{1, 1})
mustSet(s, [][]float32{newKey}, [][]byte{[]byte("2d")})
res, err := s.StoresFind(&pb.StoresFindOptions{
Key: &pb.StoresKey{Floats: newKey},
TopK: 1,
})
Expect(err).NotTo(HaveOccurred())
Expect(res.Values).To(HaveLen(1))
Expect(string(res.Values[0].Bytes)).To(Equal("2d"))
})
It("retains the dimension after a partial delete", func() {
s := NewStore()
mustSet(s,
[][]float32{{1, 0, 0}, {0, 1, 0}},
[][]byte{[]byte("x"), []byte("y")},
)
Expect(s.StoresDelete(&pb.StoresDeleteOptions{
Keys: wrapKeys([][]float32{{1, 0, 0}}),
})).To(Succeed())
Expect(s.keyLen).To(Equal(3))
Expect(s.StoresSet(&pb.StoresSetOptions{
Keys: wrapKeys([][]float32{{1, 0}}),
Values: wrapValues([][]byte{[]byte("2d")}),
})).NotTo(Succeed())
})
})
var _ = Describe("StoresFind", func() {
+22
View File
@@ -0,0 +1,22 @@
# Fetched upstream sources
sources/
# CMake build directories
build*/
# Packaging output
package/
# Compiled backend binary. The second name is what a bare `go build ./...` from
# this directory produces (it names the binary after the directory), as opposed
# to the -o name the Makefile asks for.
nemo-speech-cpp-grpc
/nemo-speech-cpp
# Shared libraries staged in-tree by the Makefile (cp from sources/). The
# SOVERSION suffix means the payload is libnemo_speech_*.so.1, hence both globs.
*.so
*.so.*
*.dylib
compile_commands.json
+391
View File
@@ -0,0 +1,391 @@
# nemo-speech-cpp backend Makefile.
#
# Upstream pin lives below as NEMO_SPEECH_VERSION so .github/bump_deps.sh can
# find and update it, matching the parakeet-cpp / vibevoice-cpp convention.
#
# Bumping NEMO_SPEECH_VERSION is a no-op on an existing checkout: sources/ is a
# directory target, so make only clones when it is missing and never re-checks
# out an already-cloned tree. After a bump run 'make purge && make', the same
# rule the parakeet-cpp Makefile documents.
#
# 'build' is the entry point the backend image calls (backend/Dockerfile.golang
# runs 'make -C backend/go/$(BACKEND) build' and then copies package/), so it
# has to produce the binary and the package, not just the shared libraries.
NEMO_SPEECH_VERSION?=ffa38cb2408f1e832a36d46fef5e3e1e80d07e6c
NEMO_SPEECH_REPO?=https://github.com/NVIDIA/NeMo-Speech.cpp
GOCMD?=go
GO_TAGS?=
JOBS?=$(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
BUILD_TYPE?=
NATIVE?=false
# NEMO_SPEECH_CUBLAS_SHIM defaults ON upstream and builds a drop-in
# libcublas.so.13. LocalAI's CUDA images ship the real cuBLAS, so the shim would
# shadow it with a slower native GEMM. Always OFF here.
CMAKE_ARGS?=-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DNEMO_SPEECH_CUBLAS_SHIM=OFF \
-DNEMO_SPEECH_BUILD_ASR=ON \
-DNEMO_SPEECH_BUILD_DIAR=ON \
-DNEMO_SPEECH_BUILD_TTS=ON \
-DNEMO_SPEECH_BUILD_NMT=ON \
-DNEMO_SPEECH_BUILD_CLI=OFF \
-DNEMO_SPEECH_BUILD_HTTP=OFF \
-DNEMO_SPEECH_BUILD_GRPC=OFF \
-DNEMO_SPEECH_WITH_FLASHLIGHT=OFF \
-DNEMO_SPEECH_TTS_WITH_ZH=ON \
-DNEMO_SPEECH_TTS_WITH_JA=ON
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
endif
# NEMO_SPEECH_TTS_WITH_JA=ON compiles Open JTalk's bundled MeCab, and
# mecab/src/dictionary.cpp derives a comparator from std::binary_function, which
# C++17 removed. libstdc++ still ships it as deprecated-but-present under
# -std=gnu++17, so Linux never notices; libc++ compiles it out and the build dies
# with "no template named 'binary_function' in namespace 'std'". Upstream's own
# CMakeLists already carries the equivalent workaround for MSVC's STL
# (_HAS_AUTO_PTR_ETC plus /FIfunctional) but has no libc++ branch, because
# NEMO_SPEECH_TTS_WITH_JA defaults OFF upstream and only LocalAI turns it on.
#
# libc++ gates the two templates on _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION,
# and has done since LLVM 16, which is older than any clang Xcode still ships.
# The name matters: the older _LIBCPP_ENABLE_CXX17_REMOVED_BINDERS covers
# bind1st/bind2nd/ptr_fun/mem_fun and NOT unary_function/binary_function, and the
# umbrella _LIBCPP_ENABLE_CXX17_REMOVED_FEATURES no longer exists at all. A wrong
# name is silently accepted by the preprocessor and fixes nothing.
#
# Applied through CMAKE_CXX_FLAGS rather than to the one target because the
# tokenizer CMakeLists is upstream's and this tree is a pinned checkout, not a
# patched one. Project-wide is also the safer scope: the macro decides whether
# libc++'s internal __binary_function alias resolves to std::binary_function or
# to __binary_function_keep_layout_base, which is a base class of std::less and
# friends, so defining it for a subset of translation units would give those
# class templates two spellings in one binary. Both bases are empty and, at
# C++17, carry identical members, so the project-wide define changes no layout
# and no ABI. On Linux the macro is not a name libstdc++ knows, so the branch is
# unreachable there and would be inert even if it were taken.
ifeq ($(shell uname -s),Darwin)
CXX_COMPAT_FLAGS?=-D_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION
else
CXX_COMPAT_FLAGS?=
endif
ifneq ($(strip $(CXX_COMPAT_FLAGS)),)
CMAKE_ARGS+=-DCMAKE_CXX_FLAGS=$(CXX_COMPAT_FLAGS)
endif
# scripts/build_itn_deps.sh installs the Sparrowhawk/OpenFST runtime here.
# NEMO_SPEECH_DEPENDENCY_PREFIX defaults to <src>/.deps upstream, and the ITN
# stack goes under its itn/ subdirectory. ITN_MARKER is a real output of that
# script (it prints exactly this file on success), so it can drive a make rule.
ITN_PREFIX=sources/NeMo-Speech.cpp/.deps/itn
ITN_LIB_DIR=$(ITN_PREFIX)/lib
ITN_MARKER=$(ITN_LIB_DIR)/libsparrowhawk.so
ITN_FST_HEADER=$(ITN_PREFIX)/include/fst/fst.h
# SentencePiece became a core ASR dependency in 5be7bfb: RNNT context biasing
# uses it even when Flashlight and text normalization are disabled. Build the
# pinned static archive provided by upstream so every platform gets the same
# dependency instead of relying on an undeclared system package.
SENTENCEPIECE_PREFIX=sources/NeMo-Speech.cpp/.deps/sentencepiece
SENTENCEPIECE_MARKER=$(SENTENCEPIECE_PREFIX)/lib/libsentencepiece.a
# Linux's ASR CMake block looks in NEMO_SPEECH_DEPENDENCY_PREFIX directly, but
# the Apple branch uses generic find_library()/find_path(). Put the same private
# prefix on CMake's search path so Darwin consumes the archive built above too.
CMAKE_ARGS+=-DCMAKE_PREFIX_PATH=$(abspath $(SENTENCEPIECE_PREFIX))
ITN_CC?=gcc-12
ITN_CXX?=g++-12
# Pin protoc to the apt one. backend/Dockerfile.golang drops protoc 27.1 into
# /usr/local/bin, which precedes /usr/bin on PATH, while libprotobuf-dev is the
# distro's (3.21 on noble, 3.12 on jammy). Sparrowhawk resolves protoc from PATH
# at make time (configure.ac uses AC_CHECK_PROG, so PROTOC substitutes to the
# bare word, and src/proto/Makefile.am invokes $(PROTOC)), and it commits no
# pregenerated stubs, so this always runs. Code generated by 27.1 includes
# google/protobuf/runtime_version.h and a PROTOBUF_VERSION #error guard that the
# older headers do not have, so the mismatch breaks the build. configure honours
# a pre-set PROTOC ("Let the user override the test"), which is what this is.
ITN_PROTOC?=/usr/bin/protoc
# Text normalization is Linux-only: Sparrowhawk/OpenFST assume a GNU toolchain
# and the gcc-12 pin has no macOS analogue. Documented gap, see the spec.
#
# An already-configured build tree wins over the platform default. Without that,
# a tree configured WITH_NORM=OFF would silently try to reconfigure itself to ON
# on the next bare `make test`, which means demanding gcc-12 from a developer who
# deliberately built without it. An explicit WITH_NORM= on the command line still
# overrides both, since command-line variables beat ?= assignments.
CMAKE_CACHE=sources/NeMo-Speech.cpp/build/CMakeCache.txt
CACHED_WITH_NORM=$(shell sed -n 's/^NEMO_SPEECH_WITH_NORM:BOOL=//p' $(CMAKE_CACHE) 2>/dev/null)
ifeq ($(shell uname -s),Darwin)
WITH_NORM?=OFF
else ifneq ($(CACHED_WITH_NORM),)
WITH_NORM?=$(CACHED_WITH_NORM)
else
WITH_NORM?=ON
endif
CMAKE_ARGS+=-DNEMO_SPEECH_WITH_NORM=$(WITH_NORM)
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS+=-DGGML_CUDA=ON
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(BUILD_TYPE),metal)
CMAKE_ARGS+=-DGGML_METAL=ON
endif
# ggml-patches/ is a CUDA series. Every kernel it adds lives under
# src/ggml-cuda/; the only files it touches outside that directory are enum and
# name-table entries in include/ggml.h and src/ggml.c plus, in ggml-cpu, a
# supports_op returning false and an abort case for the CUDA-only op. Upstream
# agrees: its metal-* and vulkan-* CMake presets inherit the cpu-* ones, which
# set NEMO_SPEECH_GGML_PATCHED=OFF, and every use of a patch-only symbol in the
# ASR sources sits behind NEMO_SPEECH_FUSED_RELPOS_ATTN /
# NEMO_SPEECH_FASTCONFORMER_CUDA_FUSIONS (both force-OFF without GGML_CUDA) or
# behind NEMO_SPEECH_GGML_PATCHED itself, which guards a Q8_PLANAR flag write
# that a non-CUDA buffer already throws before reaching.
#
# So on macOS the series buys nothing, and it cannot be applied there anyway:
# upstream's scripts/apply-ggml-patches.sh uses mapfile, a bash 4 builtin, and
# macOS ships bash 3.2 as the only bash on the runner's PATH. Skip the patch
# step and tell cmake the linked ggml is stock, which is exactly upstream's own
# Metal configuration. Linux keeps applying the series unchanged.
ifeq ($(shell uname -s),Darwin)
GGML_PATCHED?=OFF
else
GGML_PATCHED?=ON
endif
CMAKE_ARGS+=-DNEMO_SPEECH_GGML_PATCHED=$(GGML_PATCHED)
.PHONY: nemo-speech-cpp-grpc package build clean purge test all stage-libs patch-ggml engine itn sentencepiece patch-itn-headers
all: nemo-speech-cpp-grpc package
sources/NeMo-Speech.cpp:
mkdir -p sources
cd sources && git clone $(NEMO_SPEECH_REPO) NeMo-Speech.cpp
cd sources/NeMo-Speech.cpp && git checkout $(NEMO_SPEECH_VERSION)
# NMT links llama.cpp; ja needs open_jtalk; zh needs cppjieba. flashlight and
# kenlm are deliberately not initialized, they are out of scope.
cd sources/NeMo-Speech.cpp && git submodule update --init --recursive \
ggml llama.cpp third_party/open_jtalk third_party/cppjieba third_party/cpp-httplib
# NEMO_SPEECH_GGML_PATCHED defaults ON and silently assumes the ggml-patches
# series is applied. An unpatched checkout builds fine and produces wrong CUDA
# encoder output, so a failure here must stop the build rather than warn.
#
# Upstream's own script is the right tool: it applies the series in filename
# order, exits non-zero when a patch does not apply, and decides "already
# applied" by comparing the full-series tree hash rather than a timestamp. That
# makes it safe to run unconditionally, so there is no sentinel file to go stale
# or to wedge the build when deleted.
#
# Both branches keep the order-only clone prerequisite: it is the only thing
# that pulls sources/ in on a WITH_NORM=OFF tree, where the library rule has no
# other prerequisite left.
ifeq ($(GGML_PATCHED),ON)
patch-ggml: | sources/NeMo-Speech.cpp
cd sources/NeMo-Speech.cpp && bash scripts/apply-ggml-patches.sh
else
patch-ggml: | sources/NeMo-Speech.cpp
@echo "[ggml-patch] skipped: NEMO_SPEECH_GGML_PATCHED=$(GGML_PATCHED), the series is CUDA-only"
endif
# The Sparrowhawk/OpenFST text-normalization stack, as a target in its own right
# keyed on a file the build script actually produces.
#
# It used to be a side effect of the runtime library rule, which meant make had
# no idea whether it existed: once the library was up to date the script could
# never run again, so a tree built WITH_NORM=OFF could not be moved to ON, and
# anything that needed the ITN prefix was stuck demanding a full clean. As its
# own rule it is built on demand, rebuilt independently, and reachable directly
# with 'make itn'.
#
# OpenFST's templates ICE on gcc-13/14 at -O2, hence the gcc-12 pin for this one
# step; the runtime itself builds with the image default compiler.
$(ITN_MARKER): | sources/NeMo-Speech.cpp
@command -v $(ITN_CC) >/dev/null 2>&1 && command -v $(ITN_CXX) >/dev/null 2>&1 || { \
echo "ERROR: $(ITN_CC)/$(ITN_CXX) not found, and text normalization needs them:" >&2; \
echo " OpenFST's templates ICE on gcc-13 and gcc-14 at -O2." >&2; \
echo " Install them, or build this backend with WITH_NORM=OFF." >&2; \
exit 1; }
# configure's only gate on a preset PROTOC is test -n, so a path that does not
# exist is accepted here and surfaces much later as a bare "No such file or
# directory" from inside make -C src/proto. Check it up front instead.
@command -v $(ITN_PROTOC) >/dev/null 2>&1 || { \
echo "ERROR: protoc not found at $(ITN_PROTOC)." >&2; \
echo " Install the protobuf-compiler package, whose protoc matches" >&2; \
echo " the libprotobuf-dev headers Sparrowhawk compiles against, or" >&2; \
echo " point this at a matching one with ITN_PROTOC=/path/to/protoc." >&2; \
exit 1; }
cd sources/NeMo-Speech.cpp && CC=$(ITN_CC) CXX=$(ITN_CXX) PROTOC=$(ITN_PROTOC) \
JOBS=$(JOBS) scripts/build_itn_deps.sh
@$(MAKE) --no-print-directory patch-itn-headers
# OpenFST 1.8.3's FstImpl copy-assignment operator assigns a raw SymbolTable*
# (what SymbolTable::Copy() returns) straight to a std::unique_ptr member:
#
# isymbols_ = impl.isymbols_ ? impl.isymbols_->Copy() : nullptr;
#
# std::unique_ptr has no operator= taking a raw pointer in any C++ standard, so
# that line is ill-formed everywhere. It survived because nothing instantiates
# FstImpl::operator=, and gcc <= 13 only checks a template member's body when it
# is instantiated. gcc 14 resolves non-dependent operator expressions at template
# definition time, so it rejects the line in every translation unit that so much
# as includes <fst/fst.h>, with no instantiation involved. Verified: gcc 14.2
# fails on a file whose entire content is '#include <fst/fst.h>'.
#
# That is why this only shows up now. build_itn_deps.sh builds OpenFST with
# gcc-12 (its templates ICE on newer gcc at -O2) and upstream's own images build
# the runtime with gcc-13, so neither compiler ever sees it. LocalAI's
# backend/Dockerfile.golang installs gcc-14 and makes it the default via
# update-alternatives, and fst_normalizer.cpp is the one translation unit here
# that includes OpenFST, so it is the one that breaks.
#
# The fix is the same spelling FstImpl::SetInputSymbols already uses 80 lines
# further down, and matches the copy constructor's deep-copy intent exactly. It
# is applied to the installed prefix rather than to the OpenFST checkout because
# the prefix is the only copy the cmake build compiles against; libfst.so is
# already linked by this point and cannot contain the function, since no
# compiler could ever have emitted it. Only these two lines are affected: gcc 14
# reports exactly two errors over the whole OpenFST include closure, both here.
#
# Guarded on both sides so a pinned-version bump cannot silently no-op it: the
# first check fails if neither the broken nor the fixed spelling is present, the
# last fails if the broken one survives.
patch-itn-headers:
@test -f $(ITN_FST_HEADER) || { \
echo "ERROR: $(ITN_FST_HEADER) missing; the ITN prefix is not installed." >&2; \
exit 1; }
@grep -q 'isymbols_ = impl.isymbols_' $(ITN_FST_HEADER) || \
grep -q 'isymbols_.reset(impl.isymbols_' $(ITN_FST_HEADER) || { \
echo "ERROR: FstImpl::operator= in $(ITN_FST_HEADER) matches neither the" >&2; \
echo " known-broken nor the patched form. OpenFST changed upstream;" >&2; \
echo " re-check whether this patch is still needed before removing it." >&2; \
exit 1; }
sed -i -E 's|^([[:space:]]*)([io]symbols_) = (impl\.[io]symbols_ \? impl\.[io]symbols_->Copy\(\) : nullptr);$$|\1\2.reset(\3);|' $(ITN_FST_HEADER)
@if grep -q 'symbols_ = impl.[io]symbols_' $(ITN_FST_HEADER); then \
echo "ERROR: the FstImpl::operator= patch did not apply to $(ITN_FST_HEADER)." >&2; \
exit 1; \
fi
itn: $(ITN_MARKER)
$(SENTENCEPIECE_MARKER): | sources/NeMo-Speech.cpp
# Upstream's license copies use GNU install's -D flag, which BSD install
# does not support. Homebrew CMake 4 also rejects SentencePiece's old policy
# floor. Patch both incompatibilities before running the helper on Darwin.
@if [ "$(shell uname -s)" = Darwin ]; then \
cd sources/NeMo-Speech.cpp && \
mkdir -p .deps/sentencepiece/share/licenses/nemo-speech/third_party/sentencepiece && \
perl -pi \
-e 's/install -Dm0644/install -m 0644/g;' \
-e 's/-DCMAKE_BUILD_TYPE=Release /-DCMAKE_BUILD_TYPE=Release -DCMAKE_POLICY_VERSION_MINIMUM=3.5 /;' \
scripts/build_sentencepiece_static.sh; \
fi
cd sources/NeMo-Speech.cpp && JOBS=$(JOBS) scripts/build_sentencepiece_static.sh
sentencepiece: $(SENTENCEPIECE_MARKER)
# Only a WITH_NORM=ON build needs the ITN stack, and it must exist before cmake
# configures, since the WITH_NORM cmake block find_library()s into the prefix
# with REQUIRED.
NEMO_RUNTIME_PREREQS=$(SENTENCEPIECE_MARKER)
ifeq ($(WITH_NORM),ON)
NEMO_RUNTIME_PREREQS+=$(ITN_MARKER)
endif
# Upstream sets CMAKE_LIBRARY_OUTPUT_DIRECTORY to ${CMAKE_BINARY_DIR}/bin, so the
# shared objects land in build/bin rather than at the top of the build tree.
#
# patch-ggml is order-only: it is phony and therefore always runs, but an
# order-only prerequisite does not mark this target out of date, so an
# already-built tree is not relinked on every invocation.
sources/NeMo-Speech.cpp/build/bin/libnemo_speech_asr_c.so: $(NEMO_RUNTIME_PREREQS) | patch-ggml
cd sources/NeMo-Speech.cpp && cmake -B build -G Ninja $(CMAKE_ARGS)
cd sources/NeMo-Speech.cpp && cmake --build build -j$(JOBS)
# Stage the runtime next to the Go sources so purego.Dlopen finds it during
# local development and so package.sh has a single directory to bundle from.
#
# ASR and NMT build a dedicated _c shared object that links the C++ implementation
# in privately. TTS does not: upstream compiles its c_api.cpp straight into
# libnemo_speech_tts and only aliases the nemo_speech_tts_c CMake target, so the
# TTS C ABI ships without the _c suffix.
stage-libs: sources/NeMo-Speech.cpp/build/bin/libnemo_speech_asr_c.so
# -a keeps the SOVERSION symlink a symlink instead of duplicating the payload.
cp -af sources/NeMo-Speech.cpp/build/bin/libnemo_speech_asr_c.* .
cp -af sources/NeMo-Speech.cpp/build/bin/libnemo_speech_tts.* .
cp -af sources/NeMo-Speech.cpp/build/bin/libnemo_speech_nmt_c.* .
# The _c libraries are thin ABI shims with a DT_NEEDED on the C++
# implementation DSO, so dlopen fails without these next to them. TTS needs
# no counterpart, its implementation and ABI live in the same object.
cp -af sources/NeMo-Speech.cpp/build/bin/libnemo_speech_asr.* .
cp -af sources/NeMo-Speech.cpp/build/bin/libnemo_speech_nmt.* .
# nemo_speech_text_normalization is STATIC but links sparrowhawk, fstfar and
# fst PUBLIC, so those become DT_NEEDED on libnemo_speech_asr.so. They live in
# a project-local prefix that nothing else on the system provides, so without
# staging them here the packaged backend cannot dlopen at all.
#
# Keyed on the prefix existing rather than on WITH_NORM, so this stages what
# the tree actually built. A WITH_NORM=ON build cannot reach here without the
# prefix (the library rule takes ITN_MARKER as a prerequisite), and if a
# library that needs Sparrowhawk somehow arrives unstaged, package.sh's
# closure guard fails the build rather than shipping it.
@if [ -d "$(ITN_LIB_DIR)" ]; then \
echo "cp -af $(ITN_LIB_DIR)/*.so* ."; \
cp -af $(ITN_LIB_DIR)/*.so* .; \
fi
## Builds the native runtime and stops short of the Go binary. Everything it
## touches lives under sources/, a clone pinned by NEMO_SPEECH_VERSION, so
## nothing here can observe a change elsewhere in the LocalAI tree.
## Dockerfile.golang calls this from a layer that copies in this directory and
## nothing else, which keeps the multi-minute ggml/llama.cpp compile in the
## registry layer cache across builds whose only change is on the Go side.
## Without it that prebuild is skipped and a CUDA build recompiles all of
## upstream on every Go-side edit. See .agents/ci-caching.md.
engine: stage-libs
nemo-speech-cpp-grpc: stage-libs
# CGO_ENABLED=0 matches whisper / parakeet-cpp / omnivoice-cpp: the runtime is
# reached through purego.Dlopen, not cgo, and a static binary is what lets
# run.sh route execution through the packaged lib/ld.so.
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o nemo-speech-cpp-grpc .
# The dlopen tests need the staged shared objects on the loader path, the same
# way parakeet-cpp sets it up. Depends on stage-libs so that path is not an
# empty directory on a clean tree, which would fail the tests confusingly.
#
# NEMO_SPEECH_REQUIRE_LIBS turns a missing library from a skip into a failure.
# The ABI specs are the only thing standing between this backend and silent
# memory corruption, so a run that reaches them and quietly skips them is worse
# than one that fails: it reports green having checked nothing.
test: stage-libs
NEMO_SPEECH_REQUIRE_LIBS=1 LD_LIBRARY_PATH=$(CURDIR):$$LD_LIBRARY_PATH $(GOCMD) test ./... -count=1
package: nemo-speech-cpp-grpc
bash package.sh
# What backend/Dockerfile.golang invokes. It must leave both the binary and a
# populated package/ behind, because the final image stage copies package/.
build: package
clean:
# Every .so here is staged output (nemo runtime plus, on a WITH_NORM build,
# the ITN stack), and the SOVERSION suffix means the payload is *.so.1, so
# the globs have to reach past the .so.
rm -f nemo-speech-cpp-grpc
rm -f *.so *.so.* *.dylib
rm -rf package
rm -rf sources/NeMo-Speech.cpp/build
purge: clean
rm -rf sources
+423
View File
@@ -0,0 +1,423 @@
package main
// purego binds by name at runtime and the config structs cross the ABI by
// pointer, so neither a renamed symbol nor a mis-laid-out mirror struct is
// visible to the compiler or the linker. Everything here is transcribed from
// sources/NeMo-Speech.cpp/include/nemo_speech/{asr,diar,tts,nmt}.h, and
// abi_test.go asserts it against the real shared objects.
import (
"fmt"
"unsafe"
"github.com/ebitengine/purego"
)
var (
asrLib uintptr
ttsLib uintptr
nmtLib uintptr
)
// ---- ASR ----
var (
ASRCreate func(cfg unsafe.Pointer, out *uintptr) int32
ASRDestroy func(recognizer uintptr)
ASRRecognizeF32 func(recognizer uintptr, options unsafe.Pointer, samples *float32, nSamples uint64, sampleRate int32, out *uintptr) int32
ASRStreamingRecognize func(recognizer uintptr, options unsafe.Pointer, out *uintptr) int32
ASRStreamPushF32 func(stream uintptr, samples *float32, nSamples uint64, sampleRate int32) int32
ASRStreamForceEndpoint func(stream uintptr) int32
ASRStreamFinish func(stream uintptr) int32
ASRStreamNext func(stream uintptr, out *uintptr) int32
ASRStreamClose func(stream uintptr)
ASRRecognitionOptionsDef func() cASRRecognitionOptions
ASRResultIsFinal func(result uintptr) bool
ASRResultAudioProcessed func(result uintptr) float32
ASRResultAlternativeCount func(result uintptr) uint64
ASRResultTranscript func(result uintptr, alt uint64) string
ASRResultConfidence func(result uintptr, alt uint64) float32
ASRResultWordCount func(result uintptr, alt uint64) uint64
ASRResultWordText func(result uintptr, alt, i uint64) string
ASRResultWordStartTime func(result uintptr, alt, i uint64) int32
ASRResultWordEndTime func(result uintptr, alt, i uint64) int32
ASRResultWordConfidence func(result uintptr, alt, i uint64) float32
ASRResultWordSpeakerTag func(result uintptr, alt, i uint64) int32
ASRResultLanguageCount func(result uintptr, alt uint64) uint64
ASRResultLanguageCode func(result uintptr, alt, i uint64) string
ASRResultDestroy func(result uintptr)
ASRLastError func() string
ASRVersion func() string
)
// ---- Diarization (exported from the ASR library) ----
var (
DiarCreate func(cfg unsafe.Pointer, out *uintptr) int32
DiarDestroy func(model uintptr)
DiarNumSpeakers func(model uintptr) int32
DiarSecondsPerFrame func(model uintptr) float64
DiarStreamOpen func(model uintptr, out *uintptr) int32
DiarStreamPushF32 func(stream uintptr, samples *float32, nSamples uint64, sampleRate int32) int32
DiarStreamFinish func(stream uintptr) int32
DiarStreamClose func(stream uintptr)
// cfg is the optional nemo_speech_diar_segmentation_config (NULL = library
// defaults). The two-call count-then-fill pattern is documented on the C
// declaration in diar.h.
DiarSegments func(stream uintptr, cfg unsafe.Pointer, out unsafe.Pointer, capacity uint64, count *uint64) int32
)
// ---- TTS ----
var (
TTSCreate func(cfg unsafe.Pointer, out *uintptr) int32
TTSDestroy func(synthesizer uintptr)
TTSSampleRate func(synthesizer uintptr) int32
TTSSpeakerCount func(synthesizer uintptr) int32
TTSSpeakerName func(synthesizer uintptr, i uint64) string
TTSSynthesizeText func(synthesizer uintptr, options unsafe.Pointer, text string, callback uintptr, userData uintptr, statsOut unsafe.Pointer) int32
TTSRuntimeConfigDefault func() cTTSRuntimeConfig
TTSSynthesisOptionsDefault func() cTTSSynthesisOptions
TTSLastError func() string
TTSVersion func() string
)
// ---- NMT ----
var (
NMTCreate func(cfg unsafe.Pointer, out *uintptr) int32
NMTDestroy func(translator uintptr)
NMTTranslate func(translator uintptr, texts *uintptr, nTexts uint64, source, target string, out *uintptr) int32
NMTResultCount func(result uintptr) uint64
NMTResultText func(result uintptr, i uint64) string
NMTResultLanguage func(result uintptr, i uint64) string
NMTResultDestroy func(result uintptr)
NMTLastError func() string
NMTVersion func() string
)
// ---- C struct mirrors ----
//
// Each mirrors a struct in include/nemo_speech/*.h field for field. The leading
// Size field is the C `size_t size` the runtime validates against its own
// sizeof, which is what makes a layout mismatch detectable at runtime instead
// of silently corrupting memory. Blank fields are System V AMD64 / AAPCS64
// padding: C inserts it implicitly, Go does not, so it has to be written out.
// See abi_test.go, which pins both every total size and every field offset.
type cASRBackendConfig struct {
Size uintptr
GPU int32
_ [4]byte // trailing pad to the struct's 8-byte alignment
}
type cASRModelConfig struct {
Size uintptr
Path uintptr
Name uintptr
}
type cASRVADConfig struct {
Size uintptr
ModelPath uintptr
EnableMasking bool
_ [3]byte
Onset float32
Offset float32
_ [4]byte
}
type cASRPostprocConfig struct {
Size uintptr
ProfanityListPath uintptr
ITNModelDir uintptr
PNCModelPath uintptr
}
type cASRDiarConfig struct {
Size uintptr
ModelPath uintptr
ChunkFrames int32
RightContextFrames int32
LeftContextFrames int32
FIFOFrames int32
SpkcacheFrames int32
UpdatePeriodFrames int32
}
type cASRRecognizerConfig struct {
Size uintptr
Backend uintptr
Model uintptr
Streaming uintptr
Decoder uintptr
VAD uintptr
Endpointing uintptr
Postproc uintptr
Diar uintptr
Batching uintptr
}
type cASRRecognitionOptions struct {
Size uintptr
RequestID uintptr
LanguageCode uintptr
InterimResults bool
EnableWordTimeOffsets bool
EnableAutomaticPunctuation bool
VerbatimTranscripts bool
ProfanityFilter bool
_ [3]byte
StopHistoryEouMs int32
_ [4]byte
SpeechContexts uintptr
SpeechContextCount uintptr
MaxAlternatives int32
EnableSpeakerDiarization bool
_ [3]byte
MaxSpeakerCount int32
_ [4]byte
}
// cDiarModelConfig mirrors nemo_speech_diar_model_config (diar.h). This is the
// standalone Sortformer pipeline's own config and is NOT cASRDiarConfig, which
// is the diarizer attached to a recognizer: this one carries gpu and preset,
// that one does not.
//
// The six frame counts are sentinel-sensitive. src/asr/c_api.cpp applies each
// one only when it is > 0, EXCEPT left_context_frames, which it applies when it
// is >= 0. A zero-valued struct would therefore pin the left context to 0
// rather than leave the preset's value alone, so loadDiarizer writes -1 into
// all six.
type cDiarModelConfig struct {
Size uintptr
ModelPath uintptr
GPU int32
_ [4]byte // pad to the alignment of the pointer that follows
Preset uintptr
// Encoder-frame geometry overrides, applied on top of the preset.
ChunkFrames int32
RightContextFrames int32
LeftContextFrames int32
FIFOFrames int32
SpkcacheFrames int32
UpdatePeriodFrames int32
}
// cDiarSegmentationConfig mirrors nemo_speech_diar_segmentation_config
// (diar.h): the NeMo ts_vad postprocessing applied when turning per-frame
// speaker probabilities into segments.
//
// onset and offset are float, the four durations are double. That mixture is
// the whole reason this mirror needs its offsets pinned: writing all six as
// float32 or all six as float64 both produce a struct C would read shifted.
type cDiarSegmentationConfig struct {
Size uintptr
Onset float32
Offset float32
PadOnsetSec float64
PadOffsetSec float64
MinGapSec float64
MinDurationSec float64
}
// cDiarSegment mirrors nemo_speech_diar_segment (diar.h), the element type
// nemo_speech_diar_segments fills.
//
// It has no leading size field: unlike the config structs it travels from C to
// Go, so there is no caller-declared size for the runtime to validate against.
// The times are already SECONDS (double), not frame indices, so nothing here
// needs the model's seconds-per-frame to be interpreted. Speaker is 1-based,
// matching WordInfo.speaker_tag on the ASR surface.
type cDiarSegment struct {
StartTime float64
EndTime float64
Speaker int32
_ [4]byte // trailing pad to the struct's 8-byte alignment
}
type cTTSModelConfig struct {
Size uintptr
MagpieModel uintptr
CodecModel uintptr
TokenizerModelDir uintptr
TextNormalizerModelDir uintptr
}
// cTTSRuntimeConfig mirrors nemo_speech_tts_runtime_config. The four backend /
// mode fields are C enums, which this toolchain lays out as int32.
type cTTSRuntimeConfig struct {
Size uintptr
Speaker int32
Threads int32
CodecThreads int32
Seed int32
Steps int32
TopK int32
ChunkFrames int32
CodecQueueDepth int32
CodecHistoryFrames int32
CodecFutureFrames int32
WindowMs int32
Temperature float32
OverrideTemperature bool
_ [3]byte
CFGScale float32
OverrideCFGScale bool
UseCFG bool
UseLocalTransformer bool
UseKVCache bool
UseStatefulCodec bool
CodecCPU bool
FlushPartialChunk bool
Verbose bool
LTBackend int32
SamplingBackend int32
UMAMode int32
LongformMode int32
LTFP32 bool
_ [7]byte
}
type cTTSSynthesizerConfig struct {
Size uintptr
Model uintptr
Runtime uintptr
DefaultLanguageCode uintptr
DefaultVoiceName uintptr
}
type cTTSSynthesisOptions struct {
Size uintptr
RequestID uintptr
LanguageCode uintptr
Speaker int32
Seed int32
Steps int32
TopK int32
Temperature float32
OverrideTemperature bool
_ [3]byte
CFGScale float32
OverrideCFGScale bool
_ [3]byte
VoiceName uintptr
OutputSampleRate int32
_ [4]byte
}
type cNMTBackendConfig struct {
Size uintptr
GPU int32
_ [4]byte
}
type cNMTModelConfig struct {
Size uintptr
Path uintptr
NCtx int32
_ [4]byte
}
type cNMTTranslatorConfig struct {
Size uintptr
Backend uintptr
Model uintptr
Generation uintptr
Pool uintptr
}
// symbol pairs a Go function pointer with its exported C name. Keeping the
// name next to the var means `nm -D libnemo_speech_asr_c.so.1 | grep nemo_speech`
// is enough to spot drift after a pin bump.
type symbol struct {
fn any
name string
lib *uintptr
}
func symbols() []symbol {
return []symbol{
{&ASRCreate, "nemo_speech_asr_create", &asrLib},
{&ASRDestroy, "nemo_speech_asr_destroy", &asrLib},
{&ASRRecognizeF32, "nemo_speech_asr_recognize_f32", &asrLib},
{&ASRStreamingRecognize, "nemo_speech_asr_streaming_recognize", &asrLib},
{&ASRStreamPushF32, "nemo_speech_asr_stream_push_f32", &asrLib},
{&ASRStreamForceEndpoint, "nemo_speech_asr_stream_force_endpoint", &asrLib},
{&ASRStreamFinish, "nemo_speech_asr_stream_finish", &asrLib},
{&ASRStreamNext, "nemo_speech_asr_stream_next", &asrLib},
{&ASRStreamClose, "nemo_speech_asr_stream_close", &asrLib},
{&ASRRecognitionOptionsDef, "nemo_speech_asr_recognition_options_default", &asrLib},
{&ASRResultIsFinal, "nemo_speech_asr_result_is_final", &asrLib},
{&ASRResultAudioProcessed, "nemo_speech_asr_result_audio_processed", &asrLib},
{&ASRResultAlternativeCount, "nemo_speech_asr_result_alternative_count", &asrLib},
{&ASRResultTranscript, "nemo_speech_asr_result_transcript", &asrLib},
{&ASRResultConfidence, "nemo_speech_asr_result_confidence", &asrLib},
{&ASRResultWordCount, "nemo_speech_asr_result_word_count", &asrLib},
{&ASRResultWordText, "nemo_speech_asr_result_word_text", &asrLib},
{&ASRResultWordStartTime, "nemo_speech_asr_result_word_start_time", &asrLib},
{&ASRResultWordEndTime, "nemo_speech_asr_result_word_end_time", &asrLib},
{&ASRResultWordConfidence, "nemo_speech_asr_result_word_confidence", &asrLib},
{&ASRResultWordSpeakerTag, "nemo_speech_asr_result_word_speaker_tag", &asrLib},
{&ASRResultLanguageCount, "nemo_speech_asr_result_language_count", &asrLib},
{&ASRResultLanguageCode, "nemo_speech_asr_result_language_code", &asrLib},
{&ASRResultDestroy, "nemo_speech_asr_result_destroy", &asrLib},
{&ASRLastError, "nemo_speech_asr_last_error", &asrLib},
{&ASRVersion, "nemo_speech_asr_version", &asrLib},
{&DiarCreate, "nemo_speech_diar_create", &asrLib},
{&DiarDestroy, "nemo_speech_diar_destroy", &asrLib},
{&DiarNumSpeakers, "nemo_speech_diar_num_speakers", &asrLib},
{&DiarSecondsPerFrame, "nemo_speech_diar_seconds_per_frame", &asrLib},
{&DiarStreamOpen, "nemo_speech_diar_stream_open", &asrLib},
{&DiarStreamPushF32, "nemo_speech_diar_stream_push_f32", &asrLib},
{&DiarStreamFinish, "nemo_speech_diar_stream_finish", &asrLib},
{&DiarStreamClose, "nemo_speech_diar_stream_close", &asrLib},
{&DiarSegments, "nemo_speech_diar_segments", &asrLib},
{&TTSCreate, "nemo_speech_tts_create", &ttsLib},
{&TTSDestroy, "nemo_speech_tts_destroy", &ttsLib},
{&TTSSampleRate, "nemo_speech_tts_sample_rate", &ttsLib},
{&TTSSpeakerCount, "nemo_speech_tts_speaker_count", &ttsLib},
{&TTSSpeakerName, "nemo_speech_tts_speaker_name", &ttsLib},
{&TTSSynthesizeText, "nemo_speech_tts_synthesize_text", &ttsLib},
{&TTSRuntimeConfigDefault, "nemo_speech_tts_runtime_config_default", &ttsLib},
{&TTSSynthesisOptionsDefault, "nemo_speech_tts_synthesis_options_default", &ttsLib},
{&TTSLastError, "nemo_speech_tts_last_error", &ttsLib},
{&TTSVersion, "nemo_speech_tts_version", &ttsLib},
{&NMTCreate, "nemo_speech_nmt_create", &nmtLib},
{&NMTDestroy, "nemo_speech_nmt_destroy", &nmtLib},
{&NMTTranslate, "nemo_speech_nmt_translate", &nmtLib},
{&NMTResultCount, "nemo_speech_nmt_result_count", &nmtLib},
{&NMTResultText, "nemo_speech_nmt_result_text", &nmtLib},
{&NMTResultLanguage, "nemo_speech_nmt_result_language", &nmtLib},
{&NMTResultDestroy, "nemo_speech_nmt_result_destroy", &nmtLib},
{&NMTLastError, "nemo_speech_nmt_last_error", &nmtLib},
{&NMTVersion, "nemo_speech_nmt_version", &nmtLib},
}
}
// registerSymbols binds every entry point. purego panics on a missing symbol,
// so this recovers and returns the offending name: after an upstream pin bump a
// rename must fail loudly at startup, not at first inference.
func registerSymbols() error {
for _, s := range symbols() {
if err := registerOne(s); err != nil {
return err
}
}
return nil
}
func registerOne(s symbol) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("nemo-speech-cpp: binding %q: %v", s.name, r)
}
}()
purego.RegisterLibFunc(s.fn, *s.lib, s.name)
return nil
}
+317
View File
@@ -0,0 +1,317 @@
package main
import (
"os"
"unsafe"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// requireLibs reports whether a missing shared library must fail the specs
// instead of skipping them.
//
// librariesPresent stats bare filenames relative to the working directory,
// while openLibraries resolves them through the loader search path, so the two
// can legitimately disagree. Under `make test` that is harmless because the
// stage-libs prerequisite puts the .so files in the working directory, but any
// other invocation would skip every library-backed spec and still report a
// green run. The Makefile sets NEMO_SPEECH_REQUIRE_LIBS=1 so no CI path can
// pass on a silent skip; leaving it unset keeps the pure-Go specs runnable on a
// checkout with no build.
func requireLibs() bool {
return os.Getenv("NEMO_SPEECH_REQUIRE_LIBS") == "1"
}
// librariesPresent reports whether a local build is available to bind against.
func librariesPresent() bool {
for _, n := range []string{
libraryName("NEMO_SPEECH_ASR_LIBRARY", "libnemo_speech_asr_c"),
libraryName("NEMO_SPEECH_TTS_LIBRARY", "libnemo_speech_tts"),
libraryName("NEMO_SPEECH_NMT_LIBRARY", "libnemo_speech_nmt_c"),
} {
if _, err := os.Stat(n); err != nil {
return false
}
}
return true
}
// layout is one expected number transcribed from the C headers.
type layout struct {
what string
got uintptr
want uintptr
}
// The `want` column is what a C compiler reports for the structs in
// include/nemo_speech/{asr,diar,tts,nmt}.h under the System V AMD64 / AAPCS64 rules
// both supported targets follow. Regenerate after an upstream pin bump with a
// throwaway program over the installed headers:
//
// printf('SIZE %%zu\n', sizeof(nemo_speech_asr_recognition_options));
// printf('OFF %%zu\n', offsetof(nemo_speech_asr_recognition_options, max_speaker_count));
//
// Sizes alone are not enough: two padding mistakes can cancel out and leave the
// total unchanged while every field between them reads from the wrong offset,
// so each mirror pins its field offsets too.
func structSizes() []layout {
return []layout{
{"cASRBackendConfig", unsafe.Sizeof(cASRBackendConfig{}), 16},
{"cASRModelConfig", unsafe.Sizeof(cASRModelConfig{}), 24},
{"cASRVADConfig", unsafe.Sizeof(cASRVADConfig{}), 32},
{"cASRPostprocConfig", unsafe.Sizeof(cASRPostprocConfig{}), 32},
{"cASRDiarConfig", unsafe.Sizeof(cASRDiarConfig{}), 40},
{"cASRRecognizerConfig", unsafe.Sizeof(cASRRecognizerConfig{}), 80},
{"cASRRecognitionOptions", unsafe.Sizeof(cASRRecognitionOptions{}), 72},
{"cDiarModelConfig", unsafe.Sizeof(cDiarModelConfig{}), 56},
{"cDiarSegmentationConfig", unsafe.Sizeof(cDiarSegmentationConfig{}), 48},
{"cDiarSegment", unsafe.Sizeof(cDiarSegment{}), 24},
{"cTTSModelConfig", unsafe.Sizeof(cTTSModelConfig{}), 40},
{"cTTSRuntimeConfig", unsafe.Sizeof(cTTSRuntimeConfig{}), 96},
{"cTTSSynthesizerConfig", unsafe.Sizeof(cTTSSynthesizerConfig{}), 40},
{"cTTSSynthesisOptions", unsafe.Sizeof(cTTSSynthesisOptions{}), 72},
{"cNMTBackendConfig", unsafe.Sizeof(cNMTBackendConfig{}), 16},
{"cNMTModelConfig", unsafe.Sizeof(cNMTModelConfig{}), 24},
{"cNMTTranslatorConfig", unsafe.Sizeof(cNMTTranslatorConfig{}), 40},
}
}
func structOffsets() []layout {
return []layout{
{"cASRBackendConfig.GPU", unsafe.Offsetof(cASRBackendConfig{}.GPU), 8},
{"cASRModelConfig.Path", unsafe.Offsetof(cASRModelConfig{}.Path), 8},
{"cASRModelConfig.Name", unsafe.Offsetof(cASRModelConfig{}.Name), 16},
{"cASRVADConfig.ModelPath", unsafe.Offsetof(cASRVADConfig{}.ModelPath), 8},
{"cASRVADConfig.EnableMasking", unsafe.Offsetof(cASRVADConfig{}.EnableMasking), 16},
{"cASRVADConfig.Onset", unsafe.Offsetof(cASRVADConfig{}.Onset), 20},
{"cASRVADConfig.Offset", unsafe.Offsetof(cASRVADConfig{}.Offset), 24},
{"cASRPostprocConfig.ProfanityListPath", unsafe.Offsetof(cASRPostprocConfig{}.ProfanityListPath), 8},
{"cASRPostprocConfig.ITNModelDir", unsafe.Offsetof(cASRPostprocConfig{}.ITNModelDir), 16},
{"cASRPostprocConfig.PNCModelPath", unsafe.Offsetof(cASRPostprocConfig{}.PNCModelPath), 24},
{"cASRDiarConfig.ModelPath", unsafe.Offsetof(cASRDiarConfig{}.ModelPath), 8},
{"cASRDiarConfig.ChunkFrames", unsafe.Offsetof(cASRDiarConfig{}.ChunkFrames), 16},
{"cASRDiarConfig.RightContextFrames", unsafe.Offsetof(cASRDiarConfig{}.RightContextFrames), 20},
{"cASRDiarConfig.LeftContextFrames", unsafe.Offsetof(cASRDiarConfig{}.LeftContextFrames), 24},
{"cASRDiarConfig.FIFOFrames", unsafe.Offsetof(cASRDiarConfig{}.FIFOFrames), 28},
{"cASRDiarConfig.SpkcacheFrames", unsafe.Offsetof(cASRDiarConfig{}.SpkcacheFrames), 32},
{"cASRDiarConfig.UpdatePeriodFrames", unsafe.Offsetof(cASRDiarConfig{}.UpdatePeriodFrames), 36},
{"cASRRecognizerConfig.Backend", unsafe.Offsetof(cASRRecognizerConfig{}.Backend), 8},
{"cASRRecognizerConfig.Model", unsafe.Offsetof(cASRRecognizerConfig{}.Model), 16},
{"cASRRecognizerConfig.Streaming", unsafe.Offsetof(cASRRecognizerConfig{}.Streaming), 24},
{"cASRRecognizerConfig.Decoder", unsafe.Offsetof(cASRRecognizerConfig{}.Decoder), 32},
{"cASRRecognizerConfig.VAD", unsafe.Offsetof(cASRRecognizerConfig{}.VAD), 40},
{"cASRRecognizerConfig.Endpointing", unsafe.Offsetof(cASRRecognizerConfig{}.Endpointing), 48},
{"cASRRecognizerConfig.Postproc", unsafe.Offsetof(cASRRecognizerConfig{}.Postproc), 56},
{"cASRRecognizerConfig.Diar", unsafe.Offsetof(cASRRecognizerConfig{}.Diar), 64},
{"cASRRecognizerConfig.Batching", unsafe.Offsetof(cASRRecognizerConfig{}.Batching), 72},
{"cASRRecognitionOptions.RequestID", unsafe.Offsetof(cASRRecognitionOptions{}.RequestID), 8},
{"cASRRecognitionOptions.LanguageCode", unsafe.Offsetof(cASRRecognitionOptions{}.LanguageCode), 16},
{"cASRRecognitionOptions.InterimResults", unsafe.Offsetof(cASRRecognitionOptions{}.InterimResults), 24},
{"cASRRecognitionOptions.EnableWordTimeOffsets", unsafe.Offsetof(cASRRecognitionOptions{}.EnableWordTimeOffsets), 25},
{"cASRRecognitionOptions.EnableAutomaticPunctuation", unsafe.Offsetof(cASRRecognitionOptions{}.EnableAutomaticPunctuation), 26},
{"cASRRecognitionOptions.VerbatimTranscripts", unsafe.Offsetof(cASRRecognitionOptions{}.VerbatimTranscripts), 27},
{"cASRRecognitionOptions.ProfanityFilter", unsafe.Offsetof(cASRRecognitionOptions{}.ProfanityFilter), 28},
{"cASRRecognitionOptions.StopHistoryEouMs", unsafe.Offsetof(cASRRecognitionOptions{}.StopHistoryEouMs), 32},
{"cASRRecognitionOptions.SpeechContexts", unsafe.Offsetof(cASRRecognitionOptions{}.SpeechContexts), 40},
{"cASRRecognitionOptions.SpeechContextCount", unsafe.Offsetof(cASRRecognitionOptions{}.SpeechContextCount), 48},
{"cASRRecognitionOptions.MaxAlternatives", unsafe.Offsetof(cASRRecognitionOptions{}.MaxAlternatives), 56},
{"cASRRecognitionOptions.EnableSpeakerDiarization", unsafe.Offsetof(cASRRecognitionOptions{}.EnableSpeakerDiarization), 60},
{"cASRRecognitionOptions.MaxSpeakerCount", unsafe.Offsetof(cASRRecognitionOptions{}.MaxSpeakerCount), 64},
{"cDiarModelConfig.ModelPath", unsafe.Offsetof(cDiarModelConfig{}.ModelPath), 8},
{"cDiarModelConfig.GPU", unsafe.Offsetof(cDiarModelConfig{}.GPU), 16},
{"cDiarModelConfig.Preset", unsafe.Offsetof(cDiarModelConfig{}.Preset), 24},
{"cDiarModelConfig.ChunkFrames", unsafe.Offsetof(cDiarModelConfig{}.ChunkFrames), 32},
{"cDiarModelConfig.RightContextFrames", unsafe.Offsetof(cDiarModelConfig{}.RightContextFrames), 36},
{"cDiarModelConfig.LeftContextFrames", unsafe.Offsetof(cDiarModelConfig{}.LeftContextFrames), 40},
{"cDiarModelConfig.FIFOFrames", unsafe.Offsetof(cDiarModelConfig{}.FIFOFrames), 44},
{"cDiarModelConfig.SpkcacheFrames", unsafe.Offsetof(cDiarModelConfig{}.SpkcacheFrames), 48},
{"cDiarModelConfig.UpdatePeriodFrames", unsafe.Offsetof(cDiarModelConfig{}.UpdatePeriodFrames), 52},
{"cDiarSegmentationConfig.Onset", unsafe.Offsetof(cDiarSegmentationConfig{}.Onset), 8},
{"cDiarSegmentationConfig.Offset", unsafe.Offsetof(cDiarSegmentationConfig{}.Offset), 12},
{"cDiarSegmentationConfig.PadOnsetSec", unsafe.Offsetof(cDiarSegmentationConfig{}.PadOnsetSec), 16},
{"cDiarSegmentationConfig.PadOffsetSec", unsafe.Offsetof(cDiarSegmentationConfig{}.PadOffsetSec), 24},
{"cDiarSegmentationConfig.MinGapSec", unsafe.Offsetof(cDiarSegmentationConfig{}.MinGapSec), 32},
{"cDiarSegmentationConfig.MinDurationSec", unsafe.Offsetof(cDiarSegmentationConfig{}.MinDurationSec), 40},
{"cDiarSegment.StartTime", unsafe.Offsetof(cDiarSegment{}.StartTime), 0},
{"cDiarSegment.EndTime", unsafe.Offsetof(cDiarSegment{}.EndTime), 8},
{"cDiarSegment.Speaker", unsafe.Offsetof(cDiarSegment{}.Speaker), 16},
{"cTTSModelConfig.MagpieModel", unsafe.Offsetof(cTTSModelConfig{}.MagpieModel), 8},
{"cTTSModelConfig.CodecModel", unsafe.Offsetof(cTTSModelConfig{}.CodecModel), 16},
{"cTTSModelConfig.TokenizerModelDir", unsafe.Offsetof(cTTSModelConfig{}.TokenizerModelDir), 24},
{"cTTSModelConfig.TextNormalizerModelDir", unsafe.Offsetof(cTTSModelConfig{}.TextNormalizerModelDir), 32},
{"cTTSRuntimeConfig.Speaker", unsafe.Offsetof(cTTSRuntimeConfig{}.Speaker), 8},
{"cTTSRuntimeConfig.Threads", unsafe.Offsetof(cTTSRuntimeConfig{}.Threads), 12},
{"cTTSRuntimeConfig.CodecThreads", unsafe.Offsetof(cTTSRuntimeConfig{}.CodecThreads), 16},
{"cTTSRuntimeConfig.Seed", unsafe.Offsetof(cTTSRuntimeConfig{}.Seed), 20},
{"cTTSRuntimeConfig.Steps", unsafe.Offsetof(cTTSRuntimeConfig{}.Steps), 24},
{"cTTSRuntimeConfig.TopK", unsafe.Offsetof(cTTSRuntimeConfig{}.TopK), 28},
{"cTTSRuntimeConfig.ChunkFrames", unsafe.Offsetof(cTTSRuntimeConfig{}.ChunkFrames), 32},
{"cTTSRuntimeConfig.CodecQueueDepth", unsafe.Offsetof(cTTSRuntimeConfig{}.CodecQueueDepth), 36},
{"cTTSRuntimeConfig.CodecHistoryFrames", unsafe.Offsetof(cTTSRuntimeConfig{}.CodecHistoryFrames), 40},
{"cTTSRuntimeConfig.CodecFutureFrames", unsafe.Offsetof(cTTSRuntimeConfig{}.CodecFutureFrames), 44},
{"cTTSRuntimeConfig.WindowMs", unsafe.Offsetof(cTTSRuntimeConfig{}.WindowMs), 48},
{"cTTSRuntimeConfig.Temperature", unsafe.Offsetof(cTTSRuntimeConfig{}.Temperature), 52},
{"cTTSRuntimeConfig.OverrideTemperature", unsafe.Offsetof(cTTSRuntimeConfig{}.OverrideTemperature), 56},
{"cTTSRuntimeConfig.CFGScale", unsafe.Offsetof(cTTSRuntimeConfig{}.CFGScale), 60},
{"cTTSRuntimeConfig.OverrideCFGScale", unsafe.Offsetof(cTTSRuntimeConfig{}.OverrideCFGScale), 64},
{"cTTSRuntimeConfig.UseCFG", unsafe.Offsetof(cTTSRuntimeConfig{}.UseCFG), 65},
{"cTTSRuntimeConfig.UseLocalTransformer", unsafe.Offsetof(cTTSRuntimeConfig{}.UseLocalTransformer), 66},
{"cTTSRuntimeConfig.UseKVCache", unsafe.Offsetof(cTTSRuntimeConfig{}.UseKVCache), 67},
{"cTTSRuntimeConfig.UseStatefulCodec", unsafe.Offsetof(cTTSRuntimeConfig{}.UseStatefulCodec), 68},
{"cTTSRuntimeConfig.CodecCPU", unsafe.Offsetof(cTTSRuntimeConfig{}.CodecCPU), 69},
{"cTTSRuntimeConfig.FlushPartialChunk", unsafe.Offsetof(cTTSRuntimeConfig{}.FlushPartialChunk), 70},
{"cTTSRuntimeConfig.Verbose", unsafe.Offsetof(cTTSRuntimeConfig{}.Verbose), 71},
{"cTTSRuntimeConfig.LTBackend", unsafe.Offsetof(cTTSRuntimeConfig{}.LTBackend), 72},
{"cTTSRuntimeConfig.SamplingBackend", unsafe.Offsetof(cTTSRuntimeConfig{}.SamplingBackend), 76},
{"cTTSRuntimeConfig.UMAMode", unsafe.Offsetof(cTTSRuntimeConfig{}.UMAMode), 80},
{"cTTSRuntimeConfig.LongformMode", unsafe.Offsetof(cTTSRuntimeConfig{}.LongformMode), 84},
{"cTTSRuntimeConfig.LTFP32", unsafe.Offsetof(cTTSRuntimeConfig{}.LTFP32), 88},
{"cTTSSynthesizerConfig.Model", unsafe.Offsetof(cTTSSynthesizerConfig{}.Model), 8},
{"cTTSSynthesizerConfig.Runtime", unsafe.Offsetof(cTTSSynthesizerConfig{}.Runtime), 16},
{"cTTSSynthesizerConfig.DefaultLanguageCode", unsafe.Offsetof(cTTSSynthesizerConfig{}.DefaultLanguageCode), 24},
{"cTTSSynthesizerConfig.DefaultVoiceName", unsafe.Offsetof(cTTSSynthesizerConfig{}.DefaultVoiceName), 32},
{"cTTSSynthesisOptions.RequestID", unsafe.Offsetof(cTTSSynthesisOptions{}.RequestID), 8},
{"cTTSSynthesisOptions.LanguageCode", unsafe.Offsetof(cTTSSynthesisOptions{}.LanguageCode), 16},
{"cTTSSynthesisOptions.Speaker", unsafe.Offsetof(cTTSSynthesisOptions{}.Speaker), 24},
{"cTTSSynthesisOptions.Seed", unsafe.Offsetof(cTTSSynthesisOptions{}.Seed), 28},
{"cTTSSynthesisOptions.Steps", unsafe.Offsetof(cTTSSynthesisOptions{}.Steps), 32},
{"cTTSSynthesisOptions.TopK", unsafe.Offsetof(cTTSSynthesisOptions{}.TopK), 36},
{"cTTSSynthesisOptions.Temperature", unsafe.Offsetof(cTTSSynthesisOptions{}.Temperature), 40},
{"cTTSSynthesisOptions.OverrideTemperature", unsafe.Offsetof(cTTSSynthesisOptions{}.OverrideTemperature), 44},
{"cTTSSynthesisOptions.CFGScale", unsafe.Offsetof(cTTSSynthesisOptions{}.CFGScale), 48},
{"cTTSSynthesisOptions.OverrideCFGScale", unsafe.Offsetof(cTTSSynthesisOptions{}.OverrideCFGScale), 52},
{"cTTSSynthesisOptions.VoiceName", unsafe.Offsetof(cTTSSynthesisOptions{}.VoiceName), 56},
{"cTTSSynthesisOptions.OutputSampleRate", unsafe.Offsetof(cTTSSynthesisOptions{}.OutputSampleRate), 64},
{"cNMTBackendConfig.GPU", unsafe.Offsetof(cNMTBackendConfig{}.GPU), 8},
{"cNMTModelConfig.Path", unsafe.Offsetof(cNMTModelConfig{}.Path), 8},
{"cNMTModelConfig.NCtx", unsafe.Offsetof(cNMTModelConfig{}.NCtx), 16},
{"cNMTTranslatorConfig.Backend", unsafe.Offsetof(cNMTTranslatorConfig{}.Backend), 8},
{"cNMTTranslatorConfig.Model", unsafe.Offsetof(cNMTTranslatorConfig{}.Model), 16},
{"cNMTTranslatorConfig.Generation", unsafe.Offsetof(cNMTTranslatorConfig{}.Generation), 24},
{"cNMTTranslatorConfig.Pool", unsafe.Offsetof(cNMTTranslatorConfig{}.Pool), 32},
}
}
var _ = Describe("C struct mirrors", func() {
// These need no shared object, so they run on any checkout and catch a
// transcription slip the moment it is introduced.
It("matches the C sizeof of every mirrored struct", func() {
for _, l := range structSizes() {
Expect(l.got).To(Equal(l.want), "%s: Go mirror is %d bytes, C says %d", l.what, l.got, l.want)
}
})
It("matches the C offset of every mirrored field", func() {
for _, l := range structOffsets() {
Expect(l.got).To(Equal(l.want), "%s: Go offset %d, C offset %d", l.what, l.got, l.want)
}
})
})
var _ = Describe("C ABI binding", func() {
BeforeEach(func() {
if !librariesPresent() {
if requireLibs() {
cwd, _ := os.Getwd()
Fail("NEMO_SPEECH_REQUIRE_LIBS=1 but the shared libraries are not in " + cwd +
": these specs are the ABI defence and must not be skipped." +
" Run make -C backend/go/nemo-speech-cpp stage-libs")
}
Skip("shared libraries not built, run make in backend/go/nemo-speech-cpp")
}
Expect(openLibraries()).To(Succeed())
})
It("resolves every bound symbol", func() {
Expect(symbols()).ToNot(BeEmpty())
for _, s := range symbols() {
Expect(registerOne(s)).To(Succeed())
}
})
// The library reports its own sizeof through the size field of each
// defaults struct. A Go mirror that disagrees means every field after the
// first divergence is read from the wrong offset, which no compiler or
// linker check would catch. The three structs below are the only ones with
// a defaults entry point, so they are the only ones the runtime can be
// asked about directly.
It("mirrors the C recognition-options struct layout", func() {
def := ASRRecognitionOptionsDef()
Expect(def.Size).To(Equal(unsafe.Sizeof(cASRRecognitionOptions{})),
"cASRRecognitionOptions does not match the C layout")
})
It("mirrors the C TTS runtime-config struct layout", func() {
def := TTSRuntimeConfigDefault()
Expect(def.Size).To(Equal(unsafe.Sizeof(cTTSRuntimeConfig{})),
"cTTSRuntimeConfig does not match the C layout")
})
It("mirrors the C TTS synthesis-options struct layout", func() {
def := TTSSynthesisOptionsDefault()
Expect(def.Size).To(Equal(unsafe.Sizeof(cTTSSynthesisOptions{})),
"cTTSSynthesisOptions does not match the C layout")
})
// A size match alone cannot see a field read from the wrong offset when two
// padding mistakes cancel out, and structOffsets checks the mirrors against
// numbers transcribed by the same hand that wrote them. This spec is the
// only layer independent of that transcription: it reads values back out of
// the running library, so a systematically wrong table cannot hide here.
//
// Deliberately narrow. An earlier version pinned roughly forty default
// values, which would make a legitimate pin bump (threads 4 to 8, or a
// flipped flush_partial_chunk) fail with a message that reads like a layout
// error. What survives is only the values that are contract, not tuning:
//
// - max_alternatives is the single non-zero in an otherwise memset-zero
// struct, and asr.h documents "<= 1 = 1-best (default)". It pins offset
// 56, deep in the tail past the bool run.
// - The synthesis-options run of four -1 sentinels, each documented in
// tts.h as "< 0 = synthesizer default", pins offsets 24 through 36, and
// temperature witnesses that the run stops exactly at offset 40. A
// mirror whose tail is shifted by one field spills a -1 into that zero.
// - Two -1 sentinels at the ends of the runtime config's long int32 run
// pin offset 20 and offset 40 without depending on any tunable.
//
// Sources: src/asr/c_api.cpp nemo_speech_asr_recognition_options_default,
// src/tts/magpietts/runtime.h MagpieRuntimeConfig, src/tts/c_api.cpp
// nemo_speech_tts_synthesis_options_default.
It("reads the documented default values back through the mirrors", func() {
asr := ASRRecognitionOptionsDef()
Expect(asr.MaxAlternatives).To(Equal(int32(1)))
rt := TTSRuntimeConfigDefault()
Expect(rt.Seed).To(Equal(int32(-1)))
Expect(rt.CodecHistoryFrames).To(Equal(int32(-1)))
opt := TTSSynthesisOptionsDefault()
Expect(opt.Speaker).To(Equal(int32(-1)))
Expect(opt.Seed).To(Equal(int32(-1)))
Expect(opt.Steps).To(Equal(int32(-1)))
Expect(opt.TopK).To(Equal(int32(-1)))
Expect(opt.Temperature).To(Equal(float32(0)))
})
It("reports a non-empty version from each library", func() {
Expect(ASRVersion()).ToNot(BeEmpty())
Expect(TTSVersion()).ToNot(BeEmpty())
Expect(NMTVersion()).ToNot(BeEmpty())
})
})
+374
View File
@@ -0,0 +1,374 @@
package main
import (
"context"
"runtime"
"strconv"
"strings"
"time"
"unsafe"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// asrWord is one decoded word with its millisecond offsets and 1-based speaker
// tag (0 when diarization was not requested).
type asrWord struct {
Text string
Start int32
End int32
Speaker int32
}
// pinPtr pins v for the lifetime of p and returns its address in the uintptr
// form the config structs carry.
//
// The config structs mirror C, so their pointer members are uintptr, which the
// collector does not trace. Everything reachable only through one of them is
// therefore invisible to the GC while C is reading it, exactly as described on
// cstr, and needs the same pin. runtime.KeepAlive would cover collection but
// says nothing about relocation, and the guarantee wanted here is that the
// address C holds stays the address of the object.
func pinPtr[T any](p *runtime.Pinner, v *T) uintptr {
p.Pin(v)
// #nosec G103 -- v is pinned into p on the previous line, so its address is
// stable and traced for as long as p lives; every caller defers p.Unpin only
// after the create call that reads it. One-way, like cstr: nothing converts
// this uintptr back to a pointer.
return uintptr(unsafe.Pointer(v))
}
// asrDiarConfig builds the config for the diarizer attached to a recognizer.
//
// Extracted from loadASR for the same reason diarModelConfig was extracted from
// loadDiarizer: the six frame counts are sentinel-sensitive and invisible to
// every other check in the tree. src/asr/c_api.cpp:151-165 applies five of them
// when they are > 0 but applies left_context_frames when it is >= 0, so a
// dropped -1 does not fall back to the model's own streaming geometry, it pins
// the left context to zero. The struct is the right shape either way, so the
// layout assertions in abi_test.go cannot see it and only a spec on this builder
// can.
//
// diarGeometryDefault is shared with the standalone diarizer rather than
// restated: it is the same sentinel, from the same rule, in the same runtime.
//
// modelPath is a C pointer from cstr, not a Go string, and the caller owns its
// release.
func asrDiarConfig(modelPath uintptr) cASRDiarConfig {
return cASRDiarConfig{
Size: unsafe.Sizeof(cASRDiarConfig{}),
ModelPath: modelPath,
ChunkFrames: diarGeometryDefault,
RightContextFrames: diarGeometryDefault,
LeftContextFrames: diarGeometryDefault,
FIFOFrames: diarGeometryDefault,
SpkcacheFrames: diarGeometryDefault,
UpdatePeriodFrames: diarGeometryDefault,
}
}
// loadASR creates the recognizer, attaching VAD, PnC, ITN and diarization when
// the corresponding options were set.
//
// Every field below is assigned by name against include/nemo_speech/asr.h. The
// sub-configs are optional pointers: a nil one means "library defaults", which
// is why each is populated only when its option was given rather than always
// being attached with empty strings.
//
// Each struct's Size is load-bearing, not decoration. The runtime decides a
// field is present with HAS_FIELD (src/asr/c_api.cpp), which tests the caller's
// size against offsetof(field) + sizeof(field), so a config sent with Size 0
// has every field ignored and the model silently loads with defaults.
//
// This must not take engineMu: Load is its only caller and already holds it.
func (n *NemoSpeech) loadASR(modelFile string) error {
// nemo_speech_asr_create deep-copies every const char* into a std::string
// (src/asr/c_api.cpp to_config, via str_or_empty) and retains no pointer
// afterwards, so pinning for the duration of the create call is both
// necessary and sufficient.
var pinner runtime.Pinner
defer pinner.Unpin()
pathP, freePath := cstr(modelFile)
defer freePath()
model := cASRModelConfig{Size: unsafe.Sizeof(cASRModelConfig{}), Path: pathP}
backend := cASRBackendConfig{Size: unsafe.Sizeof(cASRBackendConfig{}), GPU: n.opts.gpu}
cfg := cASRRecognizerConfig{
Size: unsafe.Sizeof(cASRRecognizerConfig{}),
Backend: pinPtr(&pinner, &backend),
Model: pinPtr(&pinner, &model),
}
var vad cASRVADConfig
if n.opts.vadModel != "" {
p, free := cstr(n.opts.vadModel)
defer free()
vad = cASRVADConfig{Size: unsafe.Sizeof(cASRVADConfig{}), ModelPath: p}
cfg.VAD = pinPtr(&pinner, &vad)
}
var postproc cASRPostprocConfig
if n.opts.itnDir != "" || n.opts.pncModel != "" {
itnP, freeITN := cstr(n.opts.itnDir)
defer freeITN()
pncP, freePNC := cstr(n.opts.pncModel)
defer freePNC()
postproc = cASRPostprocConfig{
Size: unsafe.Sizeof(cASRPostprocConfig{}),
ITNModelDir: itnP,
PNCModelPath: pncP,
}
cfg.Postproc = pinPtr(&pinner, &postproc)
}
var diar cASRDiarConfig
if n.opts.diarModel != "" {
p, free := cstr(n.opts.diarModel)
defer free()
diar = asrDiarConfig(p)
cfg.Diar = pinPtr(&pinner, &diar)
}
xlog.Info("nemo-speech-cpp: creating recognizer",
"gpu", n.opts.gpu,
"vad", n.opts.vadModel != "",
"pnc", n.opts.pncModel != "",
"itn", n.opts.itnDir != "",
"diarization", n.opts.diarModel != "")
// #nosec G103 -- cfg is a local POD struct passed as a pointer for the
// duration of this call only; every uintptr member it carries is either a
// cstr allocation or a pinPtr address, all pinned above and released by the
// defers, and nemo_speech_asr_create deep-copies and retains nothing.
if st := ASRCreate(unsafe.Pointer(&cfg), &n.recognizer); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: asr create: %s", ASRLastError())
}
return nil
}
// recognizeF32 runs one offline decode and returns the result handle, which the
// caller must destroy.
//
// The empty-input guard is here rather than at the call site because &pcm[0]
// panics on a zero-length slice: Go never reaches the C side's own "empty
// audio" rejection. A silent clip or a truncated upload decodes to zero
// samples, which is ordinary input, not an exotic one.
//
// The caller must hold engineMu.
func recognizeF32(recognizer uintptr, opts *cASRRecognitionOptions, pcm []float32, sampleRate int32) (uintptr, error) {
if len(pcm) == 0 {
return 0, status.Error(codes.InvalidArgument, "nemo-speech-cpp: empty audio")
}
var result uintptr
// #nosec G103 -- opts is the caller's live struct, borrowed for this call
// only; its LanguageCode is a cstr allocation the caller keeps pinned across
// it. &pcm[0] is guarded by the empty check above and the length handed over
// is exactly len(pcm), so the runtime cannot read past the slice.
if st := ASRRecognizeF32(recognizer, unsafe.Pointer(opts),
&pcm[0], uint64(len(pcm)), sampleRate, &result); st != 0 {
return 0, statusErrorf(st, "nemo-speech-cpp: recognize: %s", ASRLastError())
}
return result, nil
}
// msToNanos converts a runtime word offset to the wire unit. The runtime
// reports milliseconds (src/asr/types.h); TranscriptSegment.start/end and
// TranscriptWord.start/end are int64 nanoseconds, which core/backend reads
// straight into a time.Duration.
func msToNanos(ms int32) int64 {
return int64(ms) * int64(time.Millisecond)
}
// extractWords pulls the top alternative's words out of a result handle.
func extractWords(result uintptr) []asrWord {
if ASRResultAlternativeCount(result) == 0 {
return nil
}
count := ASRResultWordCount(result, 0)
words := make([]asrWord, 0, count)
for i := uint64(0); i < count; i++ {
words = append(words, asrWord{
Text: ASRResultWordText(result, 0, i),
Start: ASRResultWordStartTime(result, 0, i),
End: ASRResultWordEndTime(result, 0, i),
Speaker: ASRResultWordSpeakerTag(result, 0, i),
})
}
return words
}
// wordsRequested reports whether the caller asked for word-level timestamps.
// The OpenAI transcription API gates word timings behind
// timestamp_granularities[] containing "word" and defaults to segment level
// otherwise; every backend here follows that contract (see
// backend/go/parakeet-cpp).
func wordsRequested(granularities []string) bool {
for _, g := range granularities {
if strings.EqualFold(strings.TrimSpace(g), "word") {
return true
}
}
return false
}
// wordsToSegments groups words into one segment per consecutive speaker run.
// Without diarization every word carries speaker 0, so this collapses to a
// single segment.
//
// The boundary is a CHANGE of speaker, not the first appearance of one: a
// conversation that returns to an earlier speaker has to start a new turn
// rather than reopen the old one.
//
// withWords additionally attaches the per-word timings that
// core/backend/transcript.go turns into the response's word list. It is off by
// default because the OpenAI contract asks for word timestamps explicitly, and
// a long transcript pays for every word twice otherwise.
func wordsToSegments(words []asrWord, withWords bool) []*pb.TranscriptSegment {
if len(words) == 0 {
return nil
}
var segs []*pb.TranscriptSegment
start := 0
flush := func(end int) {
run := words[start:end]
texts := make([]string, 0, len(run))
for _, w := range run {
texts = append(texts, w.Text)
}
seg := &pb.TranscriptSegment{
// #nosec G115 -- TranscriptSegment.Id is int32 on the wire, and segs
// holds one entry per speaker run over the words of a single decode
// result, which exhausts memory long before it reaches 2^31.
Id: int32(len(segs)),
Text: strings.Join(texts, " "),
Start: msToNanos(run[0].Start),
End: msToNanos(run[len(run)-1].End),
}
// The speaker tag is 1-based with 0 meaning untagged, so an undiarized
// run must stay unlabelled rather than be attributed to a speaker "0".
if run[0].Speaker > 0 {
seg.Speaker = strconv.Itoa(int(run[0].Speaker))
}
if withWords {
seg.Words = wordsToProto(run)
}
segs = append(segs, seg)
}
for i := 1; i < len(words); i++ {
if words[i].Speaker != words[start].Speaker {
flush(i)
start = i
}
}
flush(len(words))
return segs
}
// AudioTranscription decodes the audio at req.Dst and returns one offline
// transcription.
//
// The whole body runs inside withEngine, so the family check and the C calls
// that trust the handle happen under a single acquisition of engineMu. Decoding
// the audio is in there too: pkg/grpc/server.go already serialises RPCs on this
// backend through base.SingleThread, so the lock costs no concurrency, and the
// alternative (check, unlock, decode, relock) is the exact gap Free can land in.
func (n *NemoSpeech) AudioTranscription(ctx context.Context, req *pb.TranscriptRequest) (pb.TranscriptResult, error) {
var out *pb.TranscriptResult
if err := n.withEngine(familyASR, func() error {
r, err := n.transcribe(req)
out = r
return err
}); err != nil {
return pb.TranscriptResult{}, err
}
// transcribe returns a non-nil result whenever it returns a nil error, so
// this cannot fire today. It is a guard rather than a comment because the
// alternative to stating the invariant is a nil dereference in an RPC
// handler if a later edit ever adds a success path that forgets to set it.
if out == nil {
return pb.TranscriptResult{}, status.Error(codes.Internal,
"nemo-speech-cpp: transcription produced no result")
}
// Assembled field by field rather than dereferenced: the RPC signature
// returns the proto message by value, but the message embeds a mutex, so
// copying the struct is a copylocks violation. Every backend in this tree
// gets around it the same way, by only ever returning a composite literal.
return pb.TranscriptResult{
Text: out.Text,
Segments: out.Segments,
Language: out.Language,
Duration: out.Duration,
}, nil
}
// transcribe is AudioTranscription's body. The caller must hold engineMu.
func (n *NemoSpeech) transcribe(req *pb.TranscriptRequest) (*pb.TranscriptResult, error) {
if req.GetDst() == "" {
return nil, status.Error(codes.InvalidArgument,
"nemo-speech-cpp: TranscriptRequest.dst (audio path) is required")
}
pcm, sampleRate, err := decodeAudioMono16k(req.GetDst())
if err != nil {
return nil, status.Errorf(codes.InvalidArgument,
"nemo-speech-cpp: read audio: %v", err)
}
// Rejected here, before anything crosses the ABI, and not only inside
// recognizeF32: a silent or truncated upload decodes to zero samples, and
// there is no point building options and pinning strings for a request
// that cannot produce a transcript. recognizeF32 keeps its own guard as a
// precondition on the function.
if len(pcm) == 0 {
return nil, status.Error(codes.InvalidArgument, "nemo-speech-cpp: empty audio")
}
// A per-request language wins over the model-level default; both may be
// empty, which the runtime reads as auto/model default.
language := req.GetLanguage()
if language == "" {
language = n.opts.languageCode
}
langP, freeLang := cstr(language)
defer freeLang()
opts := ASRRecognitionOptionsDef()
opts.LanguageCode = langP
// Segments are built out of word offsets, so they are always asked for.
opts.EnableWordTimeOffsets = true
// Keyed on the recognizer owning a diar model, not on req.Diarize: asr.h
// documents that a request asking for diarization from a recognizer created
// without one fails with INVALID_ARGUMENT, and setting diar_model is already
// the operator's opt-in.
opts.EnableSpeakerDiarization = n.opts.diarModel != ""
result, err := recognizeF32(n.recognizer, &opts, pcm, sampleRate)
if err != nil {
return nil, err
}
defer ASRResultDestroy(result)
out := &pb.TranscriptResult{
Text: ASRResultTranscript(result, 0),
Segments: wordsToSegments(extractWords(result),
wordsRequested(req.GetTimestampGranularities())),
}
// Multilingual models report what they decided the audio was; monolingual
// ones report nothing, and an empty language is better than echoing back
// whatever the caller guessed.
if ASRResultLanguageCount(result, 0) > 0 {
out.Language = ASRResultLanguageCode(result, 0, 0)
}
if sampleRate > 0 {
out.Duration = float32(len(pcm)) / float32(sampleRate)
}
return out, nil
}
+526
View File
@@ -0,0 +1,526 @@
package main
import (
"context"
"strings"
"unsafe"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// streamChunkSamples is one push into a streaming session. At 16 kHz mono 1600
// samples is 100 ms, short enough that the decoder is polled often enough to
// see an endpoint promptly and short enough that a cancelled request stops
// within one push.
const streamChunkSamples = 1600
// The rates nemo_speech_asr_stream_push_f32 will resample from (asr.h). Outside
// this range the runtime has nothing to do with the audio, and 0 is NOT
// "unknown": it means "these samples are already at the model rate".
const (
minStreamSampleRate = 8000
maxStreamSampleRate = 96000
// TranscriptLiveConfig.sample_rate documents 0 as 16 kHz, which is a
// different meaning from the C API's 0, so it is resolved before the push.
defaultLiveSampleRate = 16000
)
// streamResult is one result lifted out of C memory. Everything is copied
// before nemo_speech_asr_result_destroy runs, so a streamResult outlives the
// handle it came from.
type streamResult struct {
Text string
Final bool
Words []asrWord
}
// asrSession is the streaming half of the ASR C API, narrowed to the four
// entry points the two streaming RPCs use.
//
// It is an interface because there is no NeMo GGUF small enough to keep in the
// tree, so the loops on top of it (chunking, the need-more-audio drain, the
// live config/reset protocol) would otherwise have no test at all. The seam is
// at the ABI, not at the model: a fake session scripts what the C API returns,
// it does not pretend to transcribe anything.
type asrSession interface {
// push buffers audio. It does not decode; next drives that.
push(pcm []float32, sampleRate int32) error
// finish flushes the decoder tail. The end-of-stream final then comes back
// from next.
finish() error
// next pulls one result. ok=false means the decoder needs more audio,
// which is a pause in the stream and not an error or an end.
next() (result streamResult, ok bool, err error)
close()
}
// sessionOpener creates a session for one language. n.openSession is the
// C-backed implementation.
type sessionOpener func(language string) (asrSession, error)
// cSession is the real asrSession, over one nemo_speech_asr_stream.
type cSession struct {
handle uintptr
}
func (s *cSession) push(pcm []float32, sampleRate int32) error {
// &pcm[0] panics on an empty slice, and an empty frame is ordinary input
// from a live caller: it is a keepalive, not audio.
if len(pcm) == 0 {
return nil
}
if st := ASRStreamPushF32(s.handle, &pcm[0], uint64(len(pcm)), sampleRate); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: stream push: %s", ASRLastError())
}
return nil
}
func (s *cSession) finish() error {
if st := ASRStreamFinish(s.handle); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: stream finish: %s", ASRLastError())
}
return nil
}
func (s *cSession) next() (streamResult, bool, error) {
var handle uintptr
if st := ASRStreamNext(s.handle, &handle); st != 0 {
return streamResult{}, false, statusErrorf(st, "nemo-speech-cpp: stream next: %s", ASRLastError())
}
// OK with a NULL handle is the documented "need more audio". Reading it as
// an error aborts every stream at the first gap; reading it as "keep
// pulling" spins forever.
if handle == 0 {
return streamResult{}, false, nil
}
// Destroyed here rather than by the caller: everything below is copied out
// of C memory into Go values, so nothing survives that would need it, and
// a caller that returned early would otherwise leak the result.
defer ASRResultDestroy(handle)
return streamResult{
Text: ASRResultTranscript(handle, 0),
Final: ASRResultIsFinal(handle),
Words: extractWords(handle),
}, true, nil
}
func (s *cSession) close() { ASRStreamClose(s.handle) }
// openSession starts a streaming recognition on the loaded recognizer.
//
// The caller must hold engineMu.
//
// nemo_speech_asr_streaming_recognize copies the options (src/asr/c_api.cpp
// to_options) and keeps no pointer into them, so the language buffer only has
// to stay pinned across this call, exactly as in loadASR.
func (n *NemoSpeech) openSession(language string) (asrSession, error) {
// A per-request language wins over the model-level default; both may be
// empty, which the runtime reads as auto/model default.
if language == "" {
language = n.opts.languageCode
}
langP, freeLang := cstr(language)
defer freeLang()
opts := ASRRecognitionOptionsDef()
opts.LanguageCode = langP
// Segments and the live word list are built out of word offsets, so they
// are always asked for.
opts.EnableWordTimeOffsets = true
// Keyed on the recognizer owning a diar model rather than on the request:
// asr.h documents that asking a recognizer created without one for
// diarization fails with INVALID_ARGUMENT.
opts.EnableSpeakerDiarization = n.opts.diarModel != ""
// interim_results is left off deliberately. The runtime emits interims from
// next() regardless of it, and they are filtered here rather than
// forwarded: see streamPCM's emit for why the wire contract cannot carry
// them.
var handle uintptr
// #nosec G103 -- opts is a local POD struct borrowed for this call only, and
// its one uintptr member (LanguageCode) is the cstr allocation pinned by the
// deferred freeLang above. to_options copies the struct, so nothing here
// outlives the call.
if st := ASRStreamingRecognize(n.recognizer, unsafe.Pointer(&opts), &handle); st != 0 {
return nil, statusErrorf(st, "nemo-speech-cpp: streaming recognize: %s", ASRLastError())
}
xlog.Debug("nemo-speech-cpp: streaming session open", "language", language)
return &cSession{handle: handle}, nil
}
// chunkPCM slices pcm into fixed-size chunks, leaving the final chunk short
// rather than padding it: silence padding would push audio the caller never
// sent through the encoder and shift the tail word timings.
func chunkPCM(pcm []float32, size int) [][]float32 {
if len(pcm) == 0 {
return nil
}
out := make([][]float32, 0, (len(pcm)+size-1)/size)
for off := 0; off < len(pcm); off += size {
out = append(out, pcm[off:min(off+size, len(pcm))])
}
return out
}
// drain pulls every result the session currently has, handing each to emit.
// It returns when the session reports it needs more audio, which is the loop's
// only terminating condition.
func drain(sess asrSession, emit func(streamResult) error) error {
for {
r, ok, err := sess.next()
if err != nil {
return err
}
if !ok {
return nil
}
if err := emit(r); err != nil {
return err
}
}
}
// streamPCM drives one whole clip through an open session, emitting each
// finalized utterance as a delta and closing with the assembled result.
//
// Only finals become deltas, and the reason is the wire contract:
// TranscriptStreamResponse.delta is newly-FINALIZED text that consumers
// CONCATENATE (core/http/endpoints/openai/transcription.go, and the realtime
// semantic-VAD path). An interim is the decoder's running hypothesis for the
// utterance in flight, so forwarding "he", "hell", "hello", "Hello." would
// assemble to "hehellhelloHello." rather than to the transcript. That the
// runtime also postprocesses finals only (build_result_ in
// src/asr/recognizer.cpp runs ITN and strip_formatting on the final, so it
// rewrites rather than extends the interim) means there is no diffing trick
// that would rescue them either.
//
// The cost is that the first delta of an utterance arrives at its endpoint
// rather than mid-word.
func streamPCM(ctx context.Context, sess asrSession, pcm []float32, sampleRate int32, wantWords bool, results chan<- *pb.TranscriptStreamResponse) error {
if len(pcm) == 0 {
return status.Error(codes.InvalidArgument, "nemo-speech-cpp: empty audio")
}
var (
full strings.Builder
segments []*pb.TranscriptSegment
// sawEndpoint records a final that arrived before the tail flush, i.e.
// a real endpoint rather than the end of the file.
sawEndpoint bool
flushing bool
tailText string
)
emit := func(r streamResult) error {
if !r.Final {
return nil
}
if flushing {
tailText += r.Text
} else {
sawEndpoint = true
}
if r.Text == "" {
return nil
}
// The separator is part of the delta, not added when assembling the
// final text, so concatenating the deltas reproduces FinalResult.Text
// exactly. Utterance transcripts carry no leading or trailing space of
// their own (the runner clears its buffer at each endpoint).
delta := r.Text
if full.Len() > 0 {
delta = " " + delta
}
full.WriteString(delta)
// One segment run per utterance, renumbered into the running sequence.
// wordsToSegments splits a run further on a speaker change, so a
// diarized utterance contributes one segment per turn.
segs := wordsToSegments(r.Words, wantWords)
if len(segs) == 0 {
// Word offsets were requested but a decoder head may still return
// none; a segment carrying just the text beats dropping it.
segs = []*pb.TranscriptSegment{{Text: r.Text}}
}
for _, s := range segs {
// #nosec G115 -- TranscriptSegment.Id is int32 on the wire, and
// segments holds one entry per speaker run per finalized utterance of
// a single request, which exhausts memory long before it reaches 2^31.
s.Id = int32(len(segments))
segments = append(segments, s)
}
results <- &pb.TranscriptStreamResponse{Delta: delta}
return nil
}
for _, chunk := range chunkPCM(pcm, streamChunkSamples) {
// The RPC body holds engineMu for the whole stream, so Free waits on
// it. Without this check a client that disconnected mid-file would pin
// the model against unload until the whole clip had been pushed.
if err := ctx.Err(); err != nil {
return status.Error(codes.Canceled, "nemo-speech-cpp: transcription cancelled")
}
if err := sess.push(chunk, sampleRate); err != nil {
return err
}
if err := drain(sess, emit); err != nil {
return err
}
}
flushing = true
if err := sess.finish(); err != nil {
return err
}
if err := drain(sess, emit); err != nil {
return err
}
final := &pb.TranscriptResult{
Text: full.String(),
Segments: segments,
// The tail flush returns whatever the decoder was still holding.
// Nothing held back after at least one endpoint means the last
// endpoint consumed the audio, which is what "the clip ended on an
// utterance boundary" means here. Text coming back means it ended
// mid-utterance.
Eou: sawEndpoint && tailText == "",
}
if sampleRate > 0 {
final.Duration = float32(len(pcm)) / float32(sampleRate)
}
results <- &pb.TranscriptStreamResponse{FinalResult: final}
return nil
}
// runLive drives one bidirectional live session. The protocol is the one
// documented on the RPC in backend.proto: a Config first, a ready ack once the
// session is open, deltas as utterances finalize, and a terminal result when
// the caller closes its send side.
//
// There is no context here on purpose. The gRPC host closes `in` when the
// stream context is cancelled (pkg/grpc/server.go's recv pump), so ranging
// over it is what stops this loop, and that is also what releases engineMu for
// a waiting Free.
func runLive(open sessionOpener, in <-chan *pb.TranscriptLiveRequest, out chan<- *pb.TranscriptLiveResponse) error {
first, ok := <-in
if !ok {
// The caller closed without sending anything. Nothing was opened, so
// there is nothing to report.
return nil
}
cfg := first.GetConfig()
if cfg == nil {
return status.Error(codes.InvalidArgument,
"nemo-speech-cpp: the first live message must carry a config")
}
rate, err := liveSampleRate(cfg)
if err != nil {
return err
}
sess, err := open(cfg.GetLanguage())
if err != nil {
return err
}
// A mid-stream config replaces sess, so this closes whichever session is
// current when the RPC unwinds.
defer func() { sess.close() }()
// Callers block on the first Recv waiting for this and degrade to
// non-live transcription when it does not arrive, so it goes out before
// any audio is read.
out <- &pb.TranscriptLiveResponse{Ready: true}
var (
full strings.Builder
flushing bool
)
emit := func(r streamResult) error {
// Finals only, for the same reason as streamPCM: an interim is a
// hypothesis the final rewrites, and delta is newly-finalized text.
if !r.Final || (r.Text == "" && len(r.Words) == 0) {
return nil
}
// The separator goes INTO the delta, exactly as in streamPCM, because
// the live consumer is the one that actually concatenates: the realtime
// semantic-VAD path joins the accumulated deltas with the empty string
// and only clears them at a turn reset, never at an endpoint. Adding
// the space when assembling the terminal text instead would make the
// running caption read "one.two." while the committed transcript read
// "one. two.".
delta := r.Text
if delta != "" && full.Len() > 0 {
delta = " " + delta
}
full.WriteString(delta)
out <- &pb.TranscriptLiveResponse{
Delta: delta,
// A final that arrives while audio is still coming IS the model's
// endpoint: the decoder resets its utterance there and the next one
// starts fresh, which is the turn boundary the realtime detector
// waits on. The final that comes back from the tail flush is the
// end of the STREAM, not a user yielding a turn, so it carries no
// eou even though the send side has already closed.
Eou: !flushing,
Words: wordsToProto(r.Words),
}
return nil
}
for req := range in {
switch payload := req.GetPayload().(type) {
case *pb.TranscriptLiveRequest_Config:
// A rate cannot change inside a stream (asr.h) and the decoder
// keeps utterance state, so a reconfigure has to be a fresh
// session rather than a reconfigured one.
newRate, err := liveSampleRate(payload.Config)
if err != nil {
return err
}
// Opened before the old one is closed so a failure here leaves a
// live session for the deferred close, not a dangling handle.
next, err := open(payload.Config.GetLanguage())
if err != nil {
return err
}
sess.close()
sess, rate = next, newRate
full.Reset()
case *pb.TranscriptLiveRequest_Audio:
pcm := payload.Audio.GetPcm()
if len(pcm) == 0 {
continue
}
if err := sess.push(pcm, rate); err != nil {
return err
}
if err := drain(sess, emit); err != nil {
return err
}
}
}
// Send side closed: flush the tail and emit the terminal result. Like the
// other backends' live path this carries Text only; per-utterance segments
// and the duration are the file path's concern.
flushing = true
if err := sess.finish(); err != nil {
return err
}
if err := drain(sess, emit); err != nil {
return err
}
// Not trimmed: the terminal text is the verbatim concatenation of the
// deltas, which is the invariant the concatenating consumers rely on. The
// first delta never carries the separator, so there is no leading space to
// trim off in the first place.
out <- &pb.TranscriptLiveResponse{
FinalResult: &pb.TranscriptResult{Text: full.String()},
}
return nil
}
// liveSampleRate resolves TranscriptLiveConfig.sample_rate to the rate the C
// API is given. The proto's 0 means 16 kHz; the C API's 0 means "already at the
// model rate", so the two cannot be forwarded to each other.
func liveSampleRate(cfg *pb.TranscriptLiveConfig) (int32, error) {
rate := cfg.GetSampleRate()
if rate == 0 {
return defaultLiveSampleRate, nil
}
if rate < minStreamSampleRate || rate > maxStreamSampleRate {
return 0, status.Errorf(codes.InvalidArgument,
"nemo-speech-cpp: unsupported live sample_rate %d (accepted: 0 or %d-%d Hz)",
rate, minStreamSampleRate, maxStreamSampleRate)
}
return rate, nil
}
// wordsToProto converts decoded words to the wire form. TranscriptWord.start
// and .end are int64 nanoseconds; the runtime reports milliseconds.
func wordsToProto(words []asrWord) []*pb.TranscriptWord {
if len(words) == 0 {
return nil
}
out := make([]*pb.TranscriptWord, len(words))
for i, w := range words {
out[i] = &pb.TranscriptWord{
Text: w.Text,
Start: msToNanos(w.Start),
End: msToNanos(w.End),
}
}
return out
}
// AudioTranscriptionStream decodes the audio at req.Dst through the streaming
// recognizer, emitting each finalized utterance as it lands.
//
// The body runs inside withEngine for the reason documented on withEngine, and
// that holds engineMu for the whole stream: Free waits rather than destroying
// the recognizer under a half-finished stream. streamPCM honours ctx so the
// wait is bounded by the client's disconnect rather than by its silence.
func (n *NemoSpeech) AudioTranscriptionStream(ctx context.Context, req *pb.TranscriptRequest, results chan *pb.TranscriptStreamResponse) error {
// The host ranges over this channel and only returns once it closes, so
// every path out of here, rejection included, has to close it.
defer close(results)
return n.withEngine(familyASR, func() error {
return n.transcribeStream(ctx, req, results)
})
}
// transcribeStream is AudioTranscriptionStream's body. The caller must hold
// engineMu.
func (n *NemoSpeech) transcribeStream(ctx context.Context, req *pb.TranscriptRequest, results chan<- *pb.TranscriptStreamResponse) error {
if req.GetDst() == "" {
return status.Error(codes.InvalidArgument,
"nemo-speech-cpp: TranscriptRequest.dst (audio path) is required")
}
// Checked before the decode so a client that has already gone away does
// not pay for an ffmpeg run, and so a cancellation is never reported as a
// broken file.
if err := ctx.Err(); err != nil {
return status.Error(codes.Canceled, "nemo-speech-cpp: transcription cancelled")
}
pcm, sampleRate, err := decodeAudioMono16k(req.GetDst())
if err != nil {
return status.Errorf(codes.InvalidArgument, "nemo-speech-cpp: read audio: %v", err)
}
// Before the session is opened, for the same reason as the offline path:
// there is no transcript to be had from zero samples, and opening a stream
// only to close it again asks the runtime to allocate decoder state for
// nothing.
if len(pcm) == 0 {
return status.Error(codes.InvalidArgument, "nemo-speech-cpp: empty audio")
}
sess, err := n.openSession(req.GetLanguage())
if err != nil {
return err
}
defer sess.close()
return streamPCM(ctx, sess, pcm, sampleRate,
wordsRequested(req.GetTimestampGranularities()), results)
}
// AudioTranscriptionLive serves the bidirectional live RPC over one streaming
// session. See runLive for the protocol and withEngine for the locking.
func (n *NemoSpeech) AudioTranscriptionLive(in <-chan *pb.TranscriptLiveRequest, out chan<- *pb.TranscriptLiveResponse) error {
defer close(out)
return n.withEngine(familyASR, func() error {
return runLive(n.openSession, in, out)
})
}
@@ -0,0 +1,699 @@
package main
import (
"context"
"errors"
"path/filepath"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// fakeSession is a scripted asrSession. It stands in for the streaming C API,
// not for a model: no NeMo GGUF is small enough to keep in the tree, and the
// need-more-audio drain is the easiest thing in this file to get subtly wrong
// (a mishandled NULL either spins forever or drops every result).
//
// script is one batch of results per drain. next() hands back the current
// batch one result at a time and then reports "need more audio" exactly once,
// which advances to the next batch. That is precisely the C contract:
// nemo_speech_asr_stream_next returns OK with a NULL handle when the decoder
// has consumed the buffered audio, and the loop must resume after the next
// push rather than treat it as the end of the stream.
type fakeSession struct {
script [][]streamResult
batch int
pos int
pushed [][]float32
rates []int32
finished int
closed int
pushErr error
finishErr error
nextErr error
}
func (f *fakeSession) push(pcm []float32, sampleRate int32) error {
if f.pushErr != nil {
return f.pushErr
}
f.pushed = append(f.pushed, pcm)
f.rates = append(f.rates, sampleRate)
return nil
}
func (f *fakeSession) finish() error {
if f.finishErr != nil {
return f.finishErr
}
f.finished++
return nil
}
func (f *fakeSession) next() (streamResult, bool, error) {
if f.nextErr != nil {
return streamResult{}, false, f.nextErr
}
if f.batch >= len(f.script) {
return streamResult{}, false, nil
}
if f.pos >= len(f.script[f.batch]) {
f.batch++
f.pos = 0
return streamResult{}, false, nil
}
r := f.script[f.batch][f.pos]
f.pos++
return r, true, nil
}
func (f *fakeSession) close() { f.closed++ }
// samples returns the flat concatenation of everything pushed, so a spec can
// assert the whole clip reached the engine without caring how it was sliced.
func (f *fakeSession) samples() []float32 {
var out []float32
for _, c := range f.pushed {
out = append(out, c...)
}
return out
}
// collect drains a response channel into a slice. The channels are unbuffered
// in the specs on purpose: a producer that stops honouring cancellation would
// otherwise fill a buffer and look healthy.
func collect[T any](ch chan T) chan []T {
done := make(chan []T, 1)
go func() {
var got []T
for v := range ch {
got = append(got, v)
}
done <- got
}()
return done
}
var _ = Describe("chunkPCM", func() {
It("splits into equal chunks when evenly divisible", func() {
chunks := chunkPCM(make([]float32, 400), 100)
Expect(chunks).To(HaveLen(4))
for _, c := range chunks {
Expect(c).To(HaveLen(100))
}
})
// Padding the tail with silence would push phantom audio through the
// encoder and shift the tail word timings, so the final chunk stays short.
It("makes the final chunk short rather than padding it", func() {
chunks := chunkPCM(make([]float32, 250), 100)
Expect(chunks).To(HaveLen(3))
Expect(chunks[2]).To(HaveLen(50))
})
It("returns one chunk when the input is shorter than the chunk size", func() {
chunks := chunkPCM(make([]float32, 10), 100)
Expect(chunks).To(HaveLen(1))
Expect(chunks[0]).To(HaveLen(10))
})
It("returns nothing for empty input", func() {
Expect(chunkPCM(nil, 100)).To(BeEmpty())
Expect(chunkPCM([]float32{}, 100)).To(BeEmpty())
})
// Every spec above works on all-zero audio, so none of them can tell a
// correct slicing from one that reorders or repeats windows. Audio fed out
// of order still decodes, it just decodes to nonsense.
It("preserves sample order across the chunk boundaries", func() {
pcm := []float32{1, 2, 3, 4, 5}
chunks := chunkPCM(pcm, 2)
Expect(chunks).To(HaveLen(3))
Expect(chunks[0]).To(Equal([]float32{1, 2}))
Expect(chunks[1]).To(Equal([]float32{3, 4}))
Expect(chunks[2]).To(Equal([]float32{5}))
})
})
var _ = Describe("drain", func() {
It("emits every result in a batch and stops on need-more-audio", func() {
sess := &fakeSession{script: [][]streamResult{
{{Text: "a"}, {Text: "b", Final: true}},
{{Text: "c"}},
}}
var got []string
Expect(drain(sess, func(r streamResult) error {
got = append(got, r.Text)
return nil
})).To(Succeed())
Expect(got).To(Equal([]string{"a", "b"}))
})
// The NULL handle is a pause, not an end: the next drain, after more audio
// has been pushed, must pick the stream back up.
It("resumes on the next drain after a need-more-audio pause", func() {
sess := &fakeSession{script: [][]streamResult{{{Text: "a"}}, {{Text: "b"}}}}
var got []string
emit := func(r streamResult) error { got = append(got, r.Text); return nil }
Expect(drain(sess, emit)).To(Succeed())
Expect(drain(sess, emit)).To(Succeed())
Expect(got).To(Equal([]string{"a", "b"}))
})
It("returns nothing and no error for a stream with no results ready", func() {
var got []string
Expect(drain(&fakeSession{}, func(r streamResult) error {
got = append(got, r.Text)
return nil
})).To(Succeed())
Expect(got).To(BeEmpty())
})
It("propagates a failure from the runtime", func() {
sess := &fakeSession{nextErr: errors.New("boom")}
Expect(drain(sess, func(streamResult) error { return nil })).To(MatchError(ContainSubstring("boom")))
})
It("stops pulling once emit fails", func() {
sess := &fakeSession{script: [][]streamResult{{{Text: "a"}, {Text: "b"}}}}
Expect(drain(sess, func(streamResult) error {
return errors.New("send failed")
})).To(MatchError(ContainSubstring("send failed")))
Expect(sess.pos).To(Equal(1))
})
})
var _ = Describe("streamPCM", func() {
streamWords := func(ctx context.Context, sess asrSession, pcm []float32, rate int32, wantWords bool) ([]*pb.TranscriptStreamResponse, error) {
GinkgoHelper()
results := make(chan *pb.TranscriptStreamResponse)
done := collect(results)
err := streamPCM(ctx, sess, pcm, rate, wantWords, results)
close(results)
return <-done, err
}
stream := func(ctx context.Context, sess asrSession, pcm []float32, rate int32) ([]*pb.TranscriptStreamResponse, error) {
GinkgoHelper()
return streamWords(ctx, sess, pcm, rate, false)
}
It("pushes the whole clip in chunks at the clip's own sample rate", func() {
sess := &fakeSession{}
pcm := make([]float32, streamChunkSamples*2+7)
_, err := stream(context.Background(), sess, pcm, 16000)
Expect(err).ToNot(HaveOccurred())
Expect(sess.pushed).To(HaveLen(3))
Expect(sess.samples()).To(HaveLen(len(pcm)))
for _, r := range sess.rates {
Expect(r).To(Equal(int32(16000)))
}
})
It("finishes the stream once, after the last chunk", func() {
sess := &fakeSession{}
_, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).ToNot(HaveOccurred())
Expect(sess.finished).To(Equal(1))
})
// Interims are the decoder's running hypothesis for the utterance in
// flight. The wire contract is that delta is newly FINALIZED text and that
// concatenating the deltas reproduces the transcript, so forwarding an
// interim would duplicate every word it later re-sends inside the final.
It("emits a delta per final and nothing for interims", func() {
sess := &fakeSession{script: [][]streamResult{{
{Text: "hel"},
{Text: "hello"},
{Text: "Hello.", Final: true},
}}}
got, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).ToNot(HaveOccurred())
var deltas []string
for _, r := range got {
if r.GetDelta() != "" {
deltas = append(deltas, r.GetDelta())
}
}
Expect(deltas).To(Equal([]string{"Hello."}))
})
It("reproduces the final transcript by concatenating the deltas", func() {
sess := &fakeSession{script: [][]streamResult{{
{Text: "One.", Final: true},
{Text: "Two.", Final: true},
}}}
got, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).ToNot(HaveOccurred())
var joined string
var final *pb.TranscriptResult
for _, r := range got {
joined += r.GetDelta()
if r.GetFinalResult() != nil {
final = r.GetFinalResult()
}
}
Expect(final).ToNot(BeNil())
Expect(final.GetText()).To(Equal("One. Two."))
Expect(joined).To(Equal(final.GetText()))
})
It("sends the terminal final result last and only once", func() {
sess := &fakeSession{script: [][]streamResult{{{Text: "hi", Final: true}}}}
got, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).ToNot(HaveOccurred())
Expect(got).ToNot(BeEmpty())
var finals int
for _, r := range got {
if r.GetFinalResult() != nil {
finals++
}
}
Expect(finals).To(Equal(1))
Expect(got[len(got)-1].GetFinalResult()).ToNot(BeNil())
})
It("reports the clip duration in seconds", func() {
sess := &fakeSession{}
got, err := stream(context.Background(), sess, make([]float32, 8000), 16000)
Expect(err).ToNot(HaveOccurred())
Expect(got[len(got)-1].GetFinalResult().GetDuration()).To(BeNumerically("~", 0.5, 1e-6))
})
It("builds per-utterance segments with nanosecond timestamps", func() {
sess := &fakeSession{script: [][]streamResult{{
{Text: "one", Final: true, Words: []asrWord{{Text: "one", Start: 0, End: 500}}},
{Text: "two", Final: true, Words: []asrWord{{Text: "two", Start: 900, End: 1400}}},
}}}
got, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).ToNot(HaveOccurred())
segs := got[len(got)-1].GetFinalResult().GetSegments()
Expect(segs).To(HaveLen(2))
Expect(segs[0].GetId()).To(Equal(int32(0)))
Expect(segs[1].GetId()).To(Equal(int32(1)))
Expect(time.Duration(segs[1].GetStart())).To(Equal(900 * time.Millisecond))
Expect(time.Duration(segs[1].GetEnd())).To(Equal(1400 * time.Millisecond))
})
// core/backend/transcript.go builds the response's word list out of
// TranscriptSegment.Words, so leaving it unset makes
// timestamp_granularities: ["word"] come back empty.
It("attaches the word timings only when they were asked for", func() {
script := func() [][]streamResult {
return [][]streamResult{{{Text: "one", Final: true,
Words: []asrWord{{Text: "one", Start: 100, End: 500}}}}}
}
got, err := streamWords(context.Background(), &fakeSession{script: script()}, make([]float32, 10), 16000, true)
Expect(err).ToNot(HaveOccurred())
segs := got[len(got)-1].GetFinalResult().GetSegments()
Expect(segs[0].GetWords()).To(HaveLen(1))
Expect(segs[0].GetWords()[0].GetText()).To(Equal("one"))
Expect(time.Duration(segs[0].GetWords()[0].GetStart())).To(Equal(100 * time.Millisecond))
got, err = streamWords(context.Background(), &fakeSession{script: script()}, make([]float32, 10), 16000, false)
Expect(err).ToNot(HaveOccurred())
segs = got[len(got)-1].GetFinalResult().GetSegments()
Expect(segs[0].GetText()).To(Equal("one"))
Expect(segs[0].GetWords()).To(BeEmpty())
})
// The flush that nemo_speech_asr_stream_finish triggers returns whatever the
// decoder was still holding. Nothing held back means the last endpoint
// consumed the audio, which is exactly "the clip ended on an utterance
// boundary"; text coming back means it ended mid-utterance.
It("marks eou when the tail flush had nothing left to emit", func() {
sess := &fakeSession{script: [][]streamResult{
{{Text: "done.", Final: true}},
{{Text: "", Final: true}},
}}
got, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).ToNot(HaveOccurred())
Expect(got[len(got)-1].GetFinalResult().GetEou()).To(BeTrue())
})
It("does not mark eou when the tail flush produced text", func() {
sess := &fakeSession{script: [][]streamResult{
{{Text: "done.", Final: true}},
{{Text: "and more", Final: true}},
}}
got, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).ToNot(HaveOccurred())
Expect(got[len(got)-1].GetFinalResult().GetEou()).To(BeFalse())
})
// The RPC body runs inside withEngine, so it holds the engine mutex for the
// whole stream and Free waits on it. A loop that ignored cancellation would
// pin the model against unload for as long as a disconnected client's audio
// takes to push.
It("stops promptly when the request context is cancelled", func() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
sess := &fakeSession{}
_, err := stream(ctx, sess, make([]float32, streamChunkSamples*4), 16000)
Expect(status.Code(err)).To(Equal(codes.Canceled))
Expect(sess.pushed).To(BeEmpty())
})
It("reports a push failure", func() {
sess := &fakeSession{pushErr: errors.New("push blew up")}
_, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).To(MatchError(ContainSubstring("push blew up")))
})
It("reports a finish failure", func() {
sess := &fakeSession{finishErr: errors.New("finish blew up")}
_, err := stream(context.Background(), sess, make([]float32, 10), 16000)
Expect(err).To(MatchError(ContainSubstring("finish blew up")))
})
})
var _ = Describe("runLive", func() {
// live drives runLive against a fake opener and returns everything the RPC
// wrote plus the sessions it opened.
live := func(reqs []*pb.TranscriptLiveRequest, script ...[][]streamResult) ([]*pb.TranscriptLiveResponse, []*fakeSession, error) {
GinkgoHelper()
var opened []*fakeSession
open := func(language string) (asrSession, error) {
s := &fakeSession{}
if len(opened) < len(script) {
s.script = script[len(opened)]
}
opened = append(opened, s)
return s, nil
}
in := make(chan *pb.TranscriptLiveRequest)
out := make(chan *pb.TranscriptLiveResponse)
done := collect(out)
go func() {
defer close(in)
for _, r := range reqs {
in <- r
}
}()
err := runLive(open, in, out)
close(out)
return <-done, opened, err
}
cfg := func(rate int32) *pb.TranscriptLiveRequest {
return &pb.TranscriptLiveRequest{Payload: &pb.TranscriptLiveRequest_Config{
Config: &pb.TranscriptLiveConfig{SampleRate: rate},
}}
}
audio := func(pcm ...float32) *pb.TranscriptLiveRequest {
return &pb.TranscriptLiveRequest{Payload: &pb.TranscriptLiveRequest_Audio{
Audio: &pb.TranscriptLiveAudio{Pcm: pcm},
}}
}
It("requires the first message to carry a config", func() {
_, opened, err := live([]*pb.TranscriptLiveRequest{audio(1, 2, 3)})
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(opened).To(BeEmpty())
})
It("returns without error when the caller closes without sending anything", func() {
got, opened, err := live(nil)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(BeEmpty())
Expect(opened).To(BeEmpty())
})
// Callers block on the first Recv waiting for this ack, and degrade to
// non-live transcription when it does not arrive.
It("acknowledges a successful open before any transcript", func() {
got, _, err := live([]*pb.TranscriptLiveRequest{cfg(0)})
Expect(err).ToNot(HaveOccurred())
Expect(got).ToNot(BeEmpty())
Expect(got[0].GetReady()).To(BeTrue())
})
// The proto documents 0 as "16 kHz". The C API reads 0 as "these samples
// are already at the model rate" and skips resampling, so forwarding the
// zero through would silently mean something else.
It("resolves the default sample rate to 16 kHz before pushing", func() {
_, opened, err := live([]*pb.TranscriptLiveRequest{cfg(0), audio(1, 2, 3)})
Expect(err).ToNot(HaveOccurred())
Expect(opened).To(HaveLen(1))
Expect(opened[0].rates).To(Equal([]int32{16000}))
})
It("pushes at the configured sample rate", func() {
_, opened, err := live([]*pb.TranscriptLiveRequest{cfg(8000), audio(1, 2, 3)})
Expect(err).ToNot(HaveOccurred())
Expect(opened[0].rates).To(Equal([]int32{8000}))
Expect(opened[0].samples()).To(Equal([]float32{1, 2, 3}))
})
It("rejects a sample rate the runtime cannot resample", func() {
_, opened, err := live([]*pb.TranscriptLiveRequest{cfg(4000)})
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(opened).To(BeEmpty())
})
It("ignores an empty audio frame instead of pushing it", func() {
_, opened, err := live([]*pb.TranscriptLiveRequest{cfg(0), audio()})
Expect(err).ToNot(HaveOccurred())
Expect(opened[0].pushed).To(BeEmpty())
})
It("streams a delta with its words and marks the utterance boundary", func() {
got, _, err := live(
[]*pb.TranscriptLiveRequest{cfg(0), audio(1)},
[][]streamResult{{
{Text: "partial"},
{Text: "Hello there.", Final: true, Words: []asrWord{
{Text: "Hello", Start: 100, End: 400},
{Text: "there", Start: 400, End: 900},
}},
}},
)
Expect(err).ToNot(HaveOccurred())
var deltas []*pb.TranscriptLiveResponse
for _, r := range got {
if r.GetDelta() != "" {
deltas = append(deltas, r)
}
}
Expect(deltas).To(HaveLen(1))
Expect(deltas[0].GetDelta()).To(Equal("Hello there."))
Expect(deltas[0].GetEou()).To(BeTrue())
Expect(deltas[0].GetWords()).To(HaveLen(2))
Expect(time.Duration(deltas[0].GetWords()[1].GetStart())).To(Equal(400 * time.Millisecond))
Expect(time.Duration(deltas[0].GetWords()[1].GetEnd())).To(Equal(900 * time.Millisecond))
})
It("finishes and closes the session when the caller closes the send side", func() {
got, opened, err := live(
[]*pb.TranscriptLiveRequest{cfg(0), audio(1)},
[][]streamResult{{{Text: "one.", Final: true}}, {{Text: "two.", Final: true}}},
)
Expect(err).ToNot(HaveOccurred())
Expect(opened[0].finished).To(Equal(1))
Expect(opened[0].closed).To(Equal(1))
Expect(got[len(got)-1].GetFinalResult()).ToNot(BeNil())
Expect(got[len(got)-1].GetFinalResult().GetText()).To(Equal("one. two."))
})
// The live path is the one with a consumer that really concatenates: the
// realtime semantic-VAD path joins the accumulated deltas with the empty
// string and clears them only at a turn reset, never at an utterance
// boundary. A separator added when assembling the terminal text instead of
// inside the delta makes the running caption read "one.two." while the
// committed transcript reads "one. two.".
It("reproduces the final transcript by concatenating the deltas", func() {
got, _, err := live(
[]*pb.TranscriptLiveRequest{cfg(0), audio(1)},
[][]streamResult{{{Text: "one.", Final: true}}, {{Text: "two.", Final: true}}},
)
Expect(err).ToNot(HaveOccurred())
var joined string
var final *pb.TranscriptResult
for _, r := range got {
joined += r.GetDelta()
if r.GetFinalResult() != nil {
final = r.GetFinalResult()
}
}
Expect(final).ToNot(BeNil())
Expect(final.GetText()).To(Equal("one. two."))
Expect(joined).To(Equal(final.GetText()))
})
// Eou is the model's endpoint, which is a user yielding the turn. The final
// that comes back from the tail flush is the end of the stream: the send
// side has already closed, so reporting a turn boundary there tells the
// turn detector something that did not happen.
It("marks the endpoint finals but not the tail flush", func() {
got, _, err := live(
[]*pb.TranscriptLiveRequest{cfg(0), audio(1)},
[][]streamResult{{{Text: "one.", Final: true}}, {{Text: "two.", Final: true}}},
)
Expect(err).ToNot(HaveOccurred())
var eous []bool
for _, r := range got {
if r.GetDelta() != "" {
eous = append(eous, r.GetEou())
}
}
Expect(eous).To(Equal([]bool{true, false}))
})
// A rate cannot change inside a stream and the decoder keeps no state
// across a reset, so a second config has to be a fresh session, not a
// reconfigured one.
It("opens a fresh session on a mid-stream config and drops the old transcript", func() {
got, opened, err := live(
[]*pb.TranscriptLiveRequest{cfg(0), audio(1), cfg(0), audio(2)},
[][]streamResult{{{Text: "dropped.", Final: true}}},
[][]streamResult{{{Text: "kept.", Final: true}}},
)
Expect(err).ToNot(HaveOccurred())
Expect(opened).To(HaveLen(2))
Expect(opened[0].closed).To(Equal(1))
Expect(got[len(got)-1].GetFinalResult().GetText()).To(Equal("kept."))
})
It("reports a push failure and still closes the session", func() {
var opened []*fakeSession
open := func(string) (asrSession, error) {
s := &fakeSession{pushErr: errors.New("push blew up")}
opened = append(opened, s)
return s, nil
}
in := make(chan *pb.TranscriptLiveRequest, 2)
in <- cfg(0)
in <- audio(1, 2)
close(in)
out := make(chan *pb.TranscriptLiveResponse, 8)
err := runLive(open, in, out)
Expect(err).To(MatchError(ContainSubstring("push blew up")))
Expect(opened[0].closed).To(Equal(1))
})
It("propagates a failure to open the session", func() {
open := func(string) (asrSession, error) { return nil, errors.New("no streaming here") }
in := make(chan *pb.TranscriptLiveRequest, 1)
in <- cfg(0)
close(in)
out := make(chan *pb.TranscriptLiveResponse, 8)
Expect(runLive(open, in, out)).To(MatchError(ContainSubstring("no streaming here")))
})
})
var _ = Describe("AudioTranscriptionStream", func() {
run := func(ctx context.Context, n *NemoSpeech, req *pb.TranscriptRequest) ([]*pb.TranscriptStreamResponse, error) {
GinkgoHelper()
results := make(chan *pb.TranscriptStreamResponse)
done := collect(results)
err := n.AudioTranscriptionStream(ctx, req, results)
return <-done, err
}
// The RPC owns the channel: the gRPC host ranges over it and only returns
// once it closes, so a rejection path that forgets to close hangs the call
// instead of failing it.
It("closes the results channel on every rejection path", func() {
for _, n := range []*NemoSpeech{{fam: familyTTS}, {fam: familyASR}, {}} {
_, err := run(context.Background(), n, &pb.TranscriptRequest{})
Expect(err).To(HaveOccurred())
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
}
})
It("refuses a model loaded as another family", func() {
n := &NemoSpeech{fam: familyTTS}
_, err := run(context.Background(), n, &pb.TranscriptRequest{Dst: "x.wav"})
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(err.Error()).To(ContainSubstring("tts"))
})
It("requires a destination path", func() {
n := &NemoSpeech{fam: familyASR}
_, err := run(context.Background(), n, &pb.TranscriptRequest{})
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
// Cancellation is checked before the decode so a client that has already
// gone away does not pay for an ffmpeg run, and so the check cannot be
// mistaken for the decode failing.
It("returns cancelled without touching the audio", func() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
n := &NemoSpeech{fam: familyASR}
_, err := run(ctx, n, &pb.TranscriptRequest{
Dst: filepath.Join(GinkgoT().TempDir(), "absent.wav"),
})
Expect(status.Code(err)).To(Equal(codes.Canceled))
})
It("reports an audio file it cannot read", func() {
n := &NemoSpeech{fam: familyASR}
_, err := run(context.Background(), n, &pb.TranscriptRequest{
Dst: filepath.Join(GinkgoT().TempDir(), "absent.wav"),
})
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
// Same ordering constraint as the offline path: a clip that decodes to no
// samples has to be refused before a session is opened, which is also
// before any bound entry point is called. Nothing is loaded here, so a
// guard placed after the open would panic instead of failing.
It("refuses a decodable clip that carries no samples, before opening a session", func() {
path := filepath.Join(GinkgoT().TempDir(), "silence.wav")
writeMono16kWAV(path, 0)
n := &NemoSpeech{fam: familyASR}
var err error
Expect(func() {
_, err = run(context.Background(), n, &pb.TranscriptRequest{Dst: path})
}).ToNot(Panic())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("empty audio"))
})
})
var _ = Describe("AudioTranscriptionLive", func() {
It("refuses a model loaded as another family and closes the output", func() {
n := &NemoSpeech{fam: familyNMT}
in := make(chan *pb.TranscriptLiveRequest)
close(in)
out := make(chan *pb.TranscriptLiveResponse)
done := collect(out)
err := n.AudioTranscriptionLive(in, out)
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(<-done).To(BeEmpty())
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
It("refuses an unloaded model", func() {
n := &NemoSpeech{}
in := make(chan *pb.TranscriptLiveRequest)
close(in)
out := make(chan *pb.TranscriptLiveResponse)
done := collect(out)
err := n.AudioTranscriptionLive(in, out)
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(<-done).To(BeEmpty())
})
})
+367
View File
@@ -0,0 +1,367 @@
package main
import (
"context"
"math"
"os"
"path/filepath"
"time"
"unsafe"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// writeMono16kWAV writes `frames` samples of 16 kHz mono 16-bit silence.
// That is already AudioToWav's target format, so the decode path copies the
// file through instead of shelling out to ffmpeg, which the test host may not
// have.
func writeMono16kWAV(path string, frames int) {
GinkgoHelper()
f, err := os.Create(path)
Expect(err).ToNot(HaveOccurred())
enc := wav.NewEncoder(f, 16000, 16, 1, 1)
Expect(enc.Write(&audio.IntBuffer{
Format: &audio.Format{NumChannels: 1, SampleRate: 16000},
SourceBitDepth: 16,
Data: make([]int, frames),
})).To(Succeed())
Expect(enc.Close()).To(Succeed())
Expect(f.Close()).To(Succeed())
}
var _ = Describe("wordsToSegments", func() {
It("groups words into one segment per speaker run", func() {
words := []asrWord{
{Text: "hello", Start: 0, End: 400, Speaker: 1},
{Text: "there", Start: 400, End: 800, Speaker: 1},
{Text: "hi", Start: 900, End: 1200, Speaker: 2},
}
segs := wordsToSegments(words, false)
Expect(segs).To(HaveLen(2))
Expect(segs[0].Text).To(Equal("hello there"))
Expect(segs[1].Text).To(Equal("hi"))
})
// A run is bounded by a CHANGE of speaker, not by the speaker id being new.
// Grouping that keyed on the id itself (a map, or a comparison against the
// first word) would merge the two A turns into one segment spanning B, and
// the three-word spec above cannot see that because it never returns to an
// earlier speaker.
It("starts a new segment when an earlier speaker takes another turn", func() {
words := []asrWord{
{Text: "one", Start: 0, End: 100, Speaker: 1},
{Text: "two", Start: 100, End: 200, Speaker: 2},
{Text: "three", Start: 200, End: 300, Speaker: 1},
}
segs := wordsToSegments(words, false)
Expect(segs).To(HaveLen(3))
Expect(segs[0].Text).To(Equal("one"))
Expect(segs[1].Text).To(Equal("two"))
Expect(segs[2].Text).To(Equal("three"))
})
// TranscriptSegment.start/end are int64 nanoseconds, not seconds:
// core/backend/transcript.go reads them straight into a time.Duration. The
// runtime reports word offsets in milliseconds (src/asr/types.h:46).
It("converts millisecond word times to nanoseconds", func() {
words := []asrWord{{Text: "a", Start: 1500, End: 2250, Speaker: 0}}
segs := wordsToSegments(words, false)
Expect(segs).To(HaveLen(1))
Expect(time.Duration(segs[0].Start)).To(Equal(1500 * time.Millisecond))
Expect(time.Duration(segs[0].End)).To(Equal(2250 * time.Millisecond))
})
It("spans a segment from its first word's start to its last word's end", func() {
words := []asrWord{
{Text: "a", Start: 100, End: 200, Speaker: 0},
{Text: "b", Start: 500, End: 900, Speaker: 0},
}
segs := wordsToSegments(words, false)
Expect(segs).To(HaveLen(1))
Expect(time.Duration(segs[0].Start)).To(Equal(100 * time.Millisecond))
Expect(time.Duration(segs[0].End)).To(Equal(900 * time.Millisecond))
})
It("produces a single segment when no speaker tags are present", func() {
words := []asrWord{
{Text: "a", Start: 0, End: 100, Speaker: 0},
{Text: "b", Start: 100, End: 200, Speaker: 0},
}
segs := wordsToSegments(words, false)
Expect(segs).To(HaveLen(1))
Expect(segs[0].Text).To(Equal("a b"))
})
It("returns no segments for no words", func() {
Expect(wordsToSegments(nil, false)).To(BeEmpty())
Expect(wordsToSegments([]asrWord{}, false)).To(BeEmpty())
})
It("numbers the segments from zero in order", func() {
words := []asrWord{
{Text: "a", Speaker: 1},
{Text: "b", Speaker: 2},
{Text: "c", Speaker: 3},
}
segs := wordsToSegments(words, false)
Expect(segs).To(HaveLen(3))
for i, s := range segs {
Expect(s.Id).To(Equal(int32(i)))
}
})
// TranscriptSegment.Words is what core/backend/transcript.go turns into the
// response's word list, so an unset one makes timestamp_granularities:
// ["word"] come back empty however good the timings were.
It("attaches the per-word timings only when they were asked for", func() {
words := []asrWord{
{Text: "a", Start: 0, End: 100},
{Text: "b", Start: 100, End: 250},
}
with := wordsToSegments(words, true)
Expect(with[0].Words).To(HaveLen(2))
Expect(with[0].Words[1].Text).To(Equal("b"))
Expect(time.Duration(with[0].Words[1].Start)).To(Equal(100 * time.Millisecond))
Expect(time.Duration(with[0].Words[1].End)).To(Equal(250 * time.Millisecond))
Expect(wordsToSegments(words, false)[0].Words).To(BeEmpty())
})
// A speaker change splits the run, and each segment must carry only its own
// words rather than the whole utterance's.
It("gives each speaker run only its own words", func() {
segs := wordsToSegments([]asrWord{
{Text: "a", Speaker: 1},
{Text: "b", Speaker: 2},
}, true)
Expect(segs).To(HaveLen(2))
Expect(segs[0].Words).To(HaveLen(1))
Expect(segs[0].Words[0].Text).To(Equal("a"))
Expect(segs[1].Words[0].Text).To(Equal("b"))
})
// The C ABI documents the speaker tag as 1-based with 0 meaning "untagged",
// so a run of untagged words must not come back attributed to a speaker
// literally named "0".
It("labels a diarized run and leaves an untagged one unlabelled", func() {
Expect(wordsToSegments([]asrWord{{Text: "a", Speaker: 2}}, false)[0].Speaker).To(Equal("2"))
Expect(wordsToSegments([]asrWord{{Text: "a", Speaker: 0}}, false)[0].Speaker).To(BeEmpty())
})
})
var _ = Describe("wordsRequested", func() {
It("recognises the OpenAI word granularity in any casing or padding", func() {
Expect(wordsRequested([]string{"word"})).To(BeTrue())
Expect(wordsRequested([]string{"segment", " Word "})).To(BeTrue())
})
It("defaults to segment level", func() {
Expect(wordsRequested(nil)).To(BeFalse())
Expect(wordsRequested([]string{"segment"})).To(BeFalse())
})
})
var _ = Describe("recognizeF32", func() {
// &pcm[0] panics on a zero-length slice, and a silent or empty upload is
// ordinary input rather than an exotic one. The C side rejects empty audio
// too, but Go never gets that far.
It("refuses empty audio instead of indexing an empty slice", func() {
// A zero options struct is enough: the guard has to fire before the
// options are ever handed across the ABI, and building real ones would
// need the library bound, which this spec deliberately does not.
opts := cASRRecognitionOptions{}
for _, pcm := range [][]float32{nil, {}} {
var (
handle uintptr
err error
)
Expect(func() { handle, err = recognizeF32(0, &opts, pcm, 16000) }).ToNot(Panic())
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("empty audio"))
Expect(handle).To(BeZero())
}
})
})
var _ = Describe("AudioTranscription", func() {
// The gate has to fire before anything expensive: a model loaded as TTS
// cannot transcribe whatever the request says, and reading the audio first
// would report a file problem for a configuration one.
It("refuses a model loaded as another family, before it reads the audio", func() {
n := &NemoSpeech{fam: familyTTS}
_, err := n.AudioTranscription(context.Background(), &pb.TranscriptRequest{
Dst: filepath.Join(GinkgoT().TempDir(), "does-not-exist.wav"),
})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(err.Error()).To(ContainSubstring("tts"))
})
It("refuses an unloaded model", func() {
n := &NemoSpeech{}
_, err := n.AudioTranscription(context.Background(), &pb.TranscriptRequest{Dst: "ignored.wav"})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
})
// A lock leaked on a rejection path deadlocks the next request rather than
// failing it, which is far harder to diagnose than the failure itself.
It("releases the engine lock on every rejection path", func() {
n := &NemoSpeech{fam: familyTTS}
_, err := n.AudioTranscription(context.Background(), &pb.TranscriptRequest{Dst: "x.wav"})
Expect(err).To(HaveOccurred())
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
It("reports an audio file it cannot read", func() {
n := &NemoSpeech{fam: familyASR}
_, err := n.AudioTranscription(context.Background(), &pb.TranscriptRequest{
Dst: filepath.Join(GinkgoT().TempDir(), "absent.wav"),
})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
It("requires a destination path", func() {
n := &NemoSpeech{fam: familyASR}
_, err := n.AudioTranscription(context.Background(), &pb.TranscriptRequest{})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
// The whole rejection path end to end, on the input that actually reaches
// it: a silent or truncated upload decodes to zero samples, and the guard
// has to fire between the decode and the ABI. Nothing is loaded here (no
// recognizer, and the specs that bind the library may not have run), so
// this also pins the ORDER: a guard placed after the options are built
// calls a nil-bound entry point and panics rather than failing.
It("refuses a decodable clip that carries no samples", func() {
path := filepath.Join(GinkgoT().TempDir(), "silence.wav")
writeMono16kWAV(path, 0)
n := &NemoSpeech{fam: familyASR, recognizer: 0}
var err error
Expect(func() {
_, err = n.AudioTranscription(context.Background(), &pb.TranscriptRequest{Dst: path})
}).ToNot(Panic())
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("empty audio"))
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
})
var _ = Describe("sampleRateOf", func() {
// 0 is not "unknown" to this runtime: nemo_speech_asr_recognize_f32 and
// nemo_speech_asr_stream_push_f32 both read a 0 rate as "these samples are
// already at the model rate" and skip resampling. Falling back to it for an
// undecodable header would silently pitch-shift the audio instead of
// failing, so an unknown rate has to be an error.
It("rejects a buffer whose format the decoder did not fill in", func() {
_, err := sampleRateOf(&audio.IntBuffer{})
Expect(err).To(HaveOccurred())
})
It("rejects a non-positive sample rate", func() {
_, err := sampleRateOf(&audio.IntBuffer{Format: &audio.Format{SampleRate: 0, NumChannels: 1}})
Expect(err).To(HaveOccurred())
})
// The WAV header carries the sample rate as an unsigned 32-bit field, which
// go-audio widens to int. Anything above the int32 range therefore passes a
// "> 0" test and then narrows to a NEGATIVE rate, which the runtime would take
// as a resampling ratio rather than reject. The failure is silent, so the
// bound is asserted rather than left to the caller.
//
// Written as a conversion plus one rather than as the constant MaxInt32+1:
// the untyped form does not fit an int on a 32-bit build and would not
// compile there, while this wraps to a negative rate the same guard rejects.
It("rejects a rate that would not survive the narrowing to int32", func() {
_, err := sampleRateOf(&audio.IntBuffer{
Format: &audio.Format{SampleRate: int(math.MaxInt32) + 1, NumChannels: 1},
})
Expect(err).To(HaveOccurred())
})
It("returns the decoded rate", func() {
rate, err := sampleRateOf(&audio.IntBuffer{Format: &audio.Format{SampleRate: 22050, NumChannels: 1}})
Expect(err).ToNot(HaveOccurred())
Expect(rate).To(Equal(int32(22050)))
})
})
var _ = Describe("decodeAudioMono16k", func() {
It("decodes a 16 kHz mono WAV to float32 samples at its own rate", func() {
path := filepath.Join(GinkgoT().TempDir(), "silence.wav")
writeMono16kWAV(path, 800)
pcm, rate, err := decodeAudioMono16k(path)
Expect(err).ToNot(HaveOccurred())
Expect(rate).To(Equal(int32(16000)))
Expect(pcm).To(HaveLen(800))
})
// A zero-frame WAV is what a truncated upload decodes to, and it is the
// input recognizeF32's guard exists for.
It("decodes a WAV with no frames to an empty slice", func() {
path := filepath.Join(GinkgoT().TempDir(), "empty.wav")
writeMono16kWAV(path, 0)
pcm, _, err := decodeAudioMono16k(path)
Expect(err).ToNot(HaveOccurred())
Expect(pcm).To(BeEmpty())
})
It("reports a file that does not exist", func() {
_, _, err := decodeAudioMono16k(filepath.Join(GinkgoT().TempDir(), "nope.wav"))
Expect(err).To(HaveOccurred())
})
})
// The six frame counts on the recognizer-attached diarizer are
// sentinel-sensitive and invisible to every other check in the tree.
// src/asr/c_api.cpp:151-165 applies five of them when they are > 0 but applies
// left_context_frames when it is >= 0, so a dropped -1 does not fall back to
// the model's own streaming geometry, it pins the left context to zero. The
// struct is the right shape either way, so abi_test.go's layout assertions
// cannot see it.
var _ = Describe("asrDiarConfig", func() {
It("keeps the model path it was given", func() {
Expect(asrDiarConfig(42).ModelPath).To(Equal(uintptr(42)))
})
// A config sent with the wrong size has every field past it ignored by
// HAS_FIELD, and the diarizer attaches with defaults instead of failing.
It("declares the size the runtime validates against", func() {
Expect(asrDiarConfig(42).Size).To(Equal(unsafe.Sizeof(cASRDiarConfig{})))
})
It("leaves every frame count at the sentinel that means default", func() {
cfg := asrDiarConfig(42)
Expect(cfg.ChunkFrames).To(Equal(diarGeometryDefault))
Expect(cfg.RightContextFrames).To(Equal(diarGeometryDefault))
Expect(cfg.LeftContextFrames).To(Equal(diarGeometryDefault))
Expect(cfg.FIFOFrames).To(Equal(diarGeometryDefault))
Expect(cfg.SpkcacheFrames).To(Equal(diarGeometryDefault))
Expect(cfg.UpdatePeriodFrames).To(Equal(diarGeometryDefault))
})
// Stated separately from the field-by-field assertions above: the whole
// group is only "unset" to the runtime while the sentinel stays negative,
// and zero is a value it would apply to the left context.
It("uses a negative sentinel, not zero", func() {
Expect(diarGeometryDefault).To(BeNumerically("<", 0))
})
})
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"errors"
"math"
"os"
"path/filepath"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
"github.com/mudler/LocalAI/pkg/utils"
)
// decodeAudioMono16k converts an arbitrary audio file to 16 kHz mono PCM and
// returns the float32 samples together with the rate they are actually at.
//
// pkg/utils exposes the ffmpeg normalisation (AudioToWav) but no decode, so
// every Go ASR backend pairs it with go-audio itself. This mirrors
// backend/go/parakeet-cpp rather than adding a shared helper: the backends
// differ in what they need back (parakeet wants a duration, this one wants the
// sample rate to hand to the runtime), so a shared signature would be a
// lowest-common-denominator of both.
func decodeAudioMono16k(path string) ([]float32, int32, error) {
dir, err := os.MkdirTemp("", "nemo-speech")
if err != nil {
return nil, 0, err
}
defer func() { _ = os.RemoveAll(dir) }()
// A WAV already at 16 kHz mono 16-bit is hardlinked or copied through
// without spawning ffmpeg, so the common case costs nothing.
converted := filepath.Join(dir, "converted.wav")
if err := utils.AudioToWav(path, converted); err != nil {
return nil, 0, err
}
// #nosec G304 -- converted is filepath.Join of a directory this function just
// created with os.MkdirTemp and a constant basename. The request-controlled
// path is the INPUT to AudioToWav and never reaches this open.
fh, err := os.Open(converted)
if err != nil {
return nil, 0, err
}
defer func() { _ = fh.Close() }()
buf, err := wav.NewDecoder(fh).FullPCMBuffer()
if err != nil {
return nil, 0, err
}
// The rate is read back from the decoded file rather than assumed to be
// 16000. AudioToWav always lands there today, but the runtime resamples
// anything from 8 to 96 kHz off this number, so a wrong one would not fail,
// it would silently pitch-shift the audio and quietly degrade the transcript.
rate, err := sampleRateOf(buf)
if err != nil {
return nil, 0, err
}
return buf.AsFloat32Buffer().Data, rate, nil
}
// sampleRateOf reads the decoded rate back off the buffer.
//
// It is an error rather than a zero fallback because 0 is not "unknown" to this
// runtime: nemo_speech_asr_recognize_f32 and nemo_speech_asr_stream_push_f32
// both read a 0 rate as "these samples are already at the model rate" and skip
// resampling (include/nemo_speech/asr.h). Handing 0 over for a header the
// decoder could not read would not fail, it would silently pitch-shift the
// audio and quietly degrade the transcript, which is the same failure the
// caller comment warns about for a wrong rate.
//
// The upper bound is what makes the narrowing to int32 safe rather than merely
// unlikely. go-audio reads the WAV header's sample rate as an unsigned 32-bit
// field into an int, so on a 64-bit build a header claiming more than 2^31-1
// survives the "> 0" test and then narrows to a NEGATIVE rate, which the runtime
// would take as a resampling ratio. Nothing this backend decodes can reach that
// today (AudioToWav either passes through a WAV it has confirmed is exactly
// 16 kHz or runs ffmpeg with -ar 16000), but that is a property of a helper in
// another package, and this function exists precisely because the rate is read
// back rather than assumed.
func sampleRateOf(buf *audio.IntBuffer) (int32, error) {
if buf.Format == nil || buf.Format.SampleRate <= 0 || buf.Format.SampleRate > math.MaxInt32 {
return 0, errors.New("nemo-speech-cpp: decoded audio has no usable sample rate")
}
return int32(buf.Format.SampleRate), nil
}
+502
View File
@@ -0,0 +1,502 @@
package main
import (
"strconv"
"unsafe"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// diarSegmentsMaxAttempts bounds the count-then-fill retry.
//
// On a finished stream the count is stable and one attempt is always enough.
// The bound exists because the RPC holds engineMu for its whole body, so a
// runtime whose count kept growing would not merely spin, it would block the
// unload behind it.
const diarSegmentsMaxAttempts = 4
// maxDiarSegments caps the buffer collectSegments will allocate from a count
// the C side reported.
//
// make() panics rather than erroring on a length it cannot satisfy, and a
// panic in an RPC handler takes the backend process down, so an uninitialised
// or corrupted size_t coming back across the ABI would kill the model rather
// than fail the request. The ceiling turns that into a diagnosable error.
//
// It is set far above anything real: a segment spans at least one 80 ms frame,
// so 2^22 segments is upwards of 93 hours of audio, and the buffer itself
// would already be 100 MB at 24 bytes each.
const maxDiarSegments = 1 << 22
// diarSegmenter is the result half of the diarization C API: the two-call
// protocol nemo_speech_diar_segments documents.
//
// The two calls are the same C function with a different `out`, but they are
// separate methods here because their contracts differ. countSegments passes
// out=NULL, which the runtime answers by writing *count and returning OK
// without touching a buffer. fillSegments passes a real buffer and gets
// INVALID_ARGUMENT if it is too short, having written *count first, which is
// what makes a growth retry possible at all.
type diarSegmenter interface {
// countSegments is the size query. It never fails for lack of a buffer.
countSegments() (uint64, error)
// fillSegments fills buf and returns the count the runtime reported. That
// count is meaningful even alongside an error: on a short buffer the
// runtime writes it before rejecting the call.
fillSegments(buf []cDiarSegment) (uint64, error)
}
// diarStream is one diarization job over the C API, narrowed to what the RPC
// uses.
//
// It is an interface for the same reason asrSession is: no Sortformer GGUF is
// small enough to keep in the tree, so without a seam at the ABI the loop on
// top of it (the empty guard, chunking, finish-before-query, the growth retry)
// would have no test at all. A fake here scripts what C returns; it does not
// pretend to diarize anything.
type diarStream interface {
diarSegmenter
push(pcm []float32, sampleRate int32) error
finish() error
close()
}
// diarStreamOpener creates a job. n.openDiarStream is the C-backed one.
//
// The segmentation config is handed over at open time rather than per query
// because it belongs to the whole job: every segments call on one stream must
// use the same postprocessing or the segment ids would not be comparable
// between calls.
type diarStreamOpener func(cfg *cDiarSegmentationConfig) (diarStream, error)
// cDiarStream is the real diarStream, over one nemo_speech_diar_stream.
type cDiarStream struct {
handle uintptr
cfg *cDiarSegmentationConfig
}
// cfgPtr hands the segmentation config to C, or NULL when the request asked
// for no postprocessing. NULL is not the same as a zeroed struct in spirit
// even though src/asr/c_api.cpp treats them alike today: diar.h documents NULL
// as "library defaults", so it is the one form that cannot be invalidated by a
// future field whose sentinel is not zero.
func (s *cDiarStream) cfgPtr() unsafe.Pointer {
if s.cfg == nil {
return nil
}
// #nosec G103 -- a plain *T to unsafe.Pointer conversion of a non-nil,
// GC-traced field. cDiarSegmentationConfig is pure scalars (no uintptr
// members to pin) and the stream owns it for its whole life, so the only
// requirement is that it outlive the DiarSegments call, which it does.
return unsafe.Pointer(s.cfg)
}
func (s *cDiarStream) push(pcm []float32, sampleRate int32) error {
// &pcm[0] panics on an empty slice before the C side ever sees the call.
if len(pcm) == 0 {
return nil
}
if st := DiarStreamPushF32(s.handle, &pcm[0], uint64(len(pcm)), sampleRate); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: diarization push: %s", ASRLastError())
}
return nil
}
func (s *cDiarStream) finish() error {
if st := DiarStreamFinish(s.handle); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: diarization finish: %s", ASRLastError())
}
return nil
}
func (s *cDiarStream) close() { DiarStreamClose(s.handle) }
func (s *cDiarStream) countSegments() (uint64, error) {
var count uint64
// out=NULL and capacity=0: the size query. The runtime reads capacity only
// once it has a buffer to check it against.
if st := DiarSegments(s.handle, s.cfgPtr(), nil, 0, &count); st != 0 {
return 0, statusErrorf(st,
"nemo-speech-cpp: diarization segment count: %s", ASRLastError())
}
return count, nil
}
func (s *cDiarStream) fillSegments(buf []cDiarSegment) (uint64, error) {
if len(buf) == 0 {
// A NULL out would silently turn this into a second size query, and the
// caller would read it as "filled nothing" rather than "asked nothing".
return 0, status.Error(codes.Internal,
"nemo-speech-cpp: diarization segment fill needs a buffer")
}
var count uint64
// #nosec G103 -- &buf[0] is guarded by the empty check above, and the
// capacity handed over is exactly len(buf), so the runtime cannot write past
// the caller's allocation. collectSegments sizes buf under maxDiarSegments
// and rejects a reported count larger than it rather than slicing to it.
st := DiarSegments(s.handle, s.cfgPtr(), unsafe.Pointer(&buf[0]), uint64(len(buf)), &count)
if st != 0 {
// count is returned alongside the error on purpose: a too-small buffer
// is rejected only after the runtime has written the size it wanted.
return count, statusErrorf(st,
"nemo-speech-cpp: diarization segments: %s", ASRLastError())
}
return count, nil
}
// diarGeometryDefault is the sentinel that means "keep the preset's value" for
// every one of nemo_speech_diar_model_config's six frame counts.
//
// It has to be negative, not zero, and that is not a style choice.
// src/asr/c_api.cpp:497-512 applies five of the six overrides when they are
// > 0 but applies left_context_frames when it is >= 0, so a zero-valued config
// reads as "unset" for five fields and as an explicit left context of zero for
// the sixth. That silently changes the model's streaming geometry, and no
// layout assertion can see it because the struct is the right shape either way.
const diarGeometryDefault int32 = -1
// diarModelConfig builds the create-time config for the standalone diarizer.
//
// Extracted from loadDiarizer purely so the sentinels above can be asserted:
// they are invisible to every other check in the tree, including the layout
// assertions, so a spec pinning them is the only thing standing between a
// dropped -1 and a quietly mis-configured model.
//
// modelPath is a C pointer from cstr, not a Go string, and the caller owns its
// release. preset is deliberately left NULL, which diar.h reads as "streaming".
// The "offline" preset is a different accuracy/latency tradeoff for long files
// and is worth exposing, but not on an unverified guess: no Sortformer GGUF
// exists here to measure the difference on.
func diarModelConfig(modelPath uintptr, gpu int32) cDiarModelConfig {
return cDiarModelConfig{
Size: unsafe.Sizeof(cDiarModelConfig{}),
ModelPath: modelPath,
GPU: gpu,
ChunkFrames: diarGeometryDefault,
RightContextFrames: diarGeometryDefault,
LeftContextFrames: diarGeometryDefault,
FIFOFrames: diarGeometryDefault,
SpkcacheFrames: diarGeometryDefault,
UpdatePeriodFrames: diarGeometryDefault,
}
}
// loadDiarizer creates the standalone Sortformer diarizer.
//
// This must not take engineMu: Load is its only caller and already holds it.
func (n *NemoSpeech) loadDiarizer(modelFile string) error {
pathP, freePath := cstr(modelFile)
defer freePath()
cfg := diarModelConfig(pathP, n.opts.gpu)
xlog.Info("nemo-speech-cpp: creating diarizer", "gpu", n.opts.gpu)
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Its
// only uintptr member is ModelPath, the cstr allocation pinned by the
// deferred freePath above (Preset is deliberately NULL), and
// nemo_speech_diar_create deep-copies the path and retains nothing.
if st := DiarCreate(unsafe.Pointer(&cfg), &n.diarizer); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: diarizer create: %s", ASRLastError())
}
return nil
}
// openDiarStream starts a diarization job on the loaded model.
//
// The caller must hold engineMu.
func (n *NemoSpeech) openDiarStream(cfg *cDiarSegmentationConfig) (diarStream, error) {
var handle uintptr
if st := DiarStreamOpen(n.diarizer, &handle); st != 0 {
return nil, statusErrorf(st,
"nemo-speech-cpp: diarization stream open: %s", ASRLastError())
}
return &cDiarStream{handle: handle, cfg: cfg}, nil
}
// sizeofDiarSegmentationConfig is the size the runtime validates the config
// against. It is a function so the specs can assert the value the config
// actually carries rather than restate the number.
func sizeofDiarSegmentationConfig() uintptr {
return unsafe.Sizeof(cDiarSegmentationConfig{})
}
// segmentationConfig maps the request's postprocessing knobs onto
// nemo_speech_diar_segmentation_config, or returns nil when none were set.
//
// Only two of DiarizeRequest's tuning fields have a real equivalent here, and
// both are exact rather than approximate: NeMo's ts_vad postprocessing is the
// same algorithm the proto's wording describes.
//
// - min_duration_on ("discard segments shorter than this") is min_duration_sec
// ("drop segments shorter than this"), which c_api.cpp assigns to
// DiarSegmentationCfg.min_duration_on.
// - min_duration_off ("merge gaps shorter than this") is min_gap_sec ("fill
// silence gaps shorter than this"), assigned to min_duration_off.
//
// The names cross over between the proto and the C header, which is exactly the
// kind of transposition a layout assertion cannot see, so each mapping is
// pinned by its own spec.
//
// Nothing is written for a non-positive value: the runtime tests every field
// with > 0 and keeps its default otherwise, so a zero here means "unset" on
// both sides.
func segmentationConfig(req *pb.DiarizeRequest) *cDiarSegmentationConfig {
cfg := cDiarSegmentationConfig{Size: sizeofDiarSegmentationConfig()}
var set bool
if v := req.GetMinDurationOn(); v > 0 {
cfg.MinDurationSec = float64(v)
set = true
}
if v := req.GetMinDurationOff(); v > 0 {
cfg.MinGapSec = float64(v)
set = true
}
if !set {
return nil
}
return &cfg
}
// unsupportedRequestFields names the DiarizeRequest fields this backend cannot
// honour, so they are logged rather than silently dropped.
//
// Each is a deliberate omission, not a gap waiting to be filled:
//
// - num_speakers, min_speakers, max_speakers: Sortformer is end-to-end and
// its speaker capacity is fixed by the checkpoint (v2: 4).
// nemo_speech_diar_num_speakers reports that capacity, it does not set it,
// and there is no config field for a target count.
// - clustering_threshold: there is no clustering stage. The nearest knob is
// the onset/offset probability hysteresis, which is a different quantity on
// a different scale, so mapping one onto the other would invent an
// equivalence the header does not have.
// - include_text: this pipeline carries no ASR at all (diar.h: "no ASR
// involved"). Word-level speaker tags on a transcript are the ASR surface's
// job, through diar_model plus enable_speaker_diarization.
// - threads: neither nemo_speech_diar_model_config nor the segmentation
// config has a thread count.
func unsupportedRequestFields(req *pb.DiarizeRequest) []string {
var out []string
if req.GetNumSpeakers() != 0 {
out = append(out, "num_speakers")
}
if req.GetMinSpeakers() != 0 {
out = append(out, "min_speakers")
}
if req.GetMaxSpeakers() != 0 {
out = append(out, "max_speakers")
}
if req.GetClusteringThreshold() != 0 {
out = append(out, "clustering_threshold")
}
if req.GetIncludeText() {
out = append(out, "include_text")
}
if req.GetThreads() != 0 {
out = append(out, "threads")
}
return out
}
// collectSegments runs the count-then-fill protocol and returns the segments.
//
// The growth retry is not defensive padding. nemo_speech_diar_segments writes
// *count and only then rejects a buffer that is too small, so the size a
// rejected call reports is the size to retry with; without the retry a stream
// that gained a segment between the two calls would fail the whole request.
// Truncating to the first count instead would be worse still, dropping turns
// with nothing to show for it.
func collectSegments(s diarSegmenter) ([]cDiarSegment, error) {
want, err := s.countSegments()
if err != nil {
return nil, err
}
for range diarSegmentsMaxAttempts {
if want == 0 {
// No segments means no fill: the fill call needs a non-empty buffer
// to be distinguishable from a second size query.
return nil, nil
}
if want > maxDiarSegments {
return nil, status.Errorf(codes.Internal,
"nemo-speech-cpp: diarization reported %d segments, above the %d ceiling", want, maxDiarSegments)
}
buf := make([]cDiarSegment, want)
got, fillErr := s.fillSegments(buf)
if fillErr == nil {
if got > want {
// The runtime cannot report this on success (it rejects a short
// buffer instead), so it means the ABI is not what this code
// thinks it is. Slicing to it would read past the allocation.
return nil, status.Errorf(codes.Internal,
"nemo-speech-cpp: diarization returned %d segments for a %d-segment buffer", got, want)
}
return buf[:got], nil
}
// A count that did not grow means the call failed for some other
// reason, and retrying the same size would just fail the same way.
if got <= want {
return nil, fillErr
}
want = got
}
return nil, status.Error(codes.Internal,
"nemo-speech-cpp: diarization segment count kept growing, giving up")
}
// toDiarizeSegments converts the runtime's segments to the wire form.
//
// No unit conversion happens here, and that is the point: nemo_speech_diar_segment
// carries start_time and end_time in SECONDS already (diar.h), and
// DiarizeSegment.start/end are seconds too. The frame indices the model works
// in never reach this layer, so nemo_speech_diar_seconds_per_frame is not
// involved. The narrowing to float32 is the proto's choice of type; at 80 ms
// resolution it is lossless for any clip short enough to hold in memory.
//
// The speaker label is the runtime's 1-based tag rendered as a decimal string,
// which is what wordsToSegments emits for the ASR path. The same speaker has to
// read the same way whether the caller diarized a file or transcribed it.
func toDiarizeSegments(in []cDiarSegment) []*pb.DiarizeSegment {
if len(in) == 0 {
return nil
}
out := make([]*pb.DiarizeSegment, 0, len(in))
for i, s := range in {
out = append(out, &pb.DiarizeSegment{
Id: int32(i),
Start: float32(s.StartTime),
End: float32(s.EndTime),
Speaker: strconv.Itoa(int(s.Speaker)),
})
}
return out
}
// distinctSpeakers counts the speaker labels present in the segments.
//
// This is what DiarizeResponse.num_speakers is documented to hold, and it is
// NOT nemo_speech_diar_num_speakers: that reports the checkpoint's capacity
// (four for Sortformer v2), so a two-person interview would come back claiming
// four speakers.
func distinctSpeakers(segs []*pb.DiarizeSegment) int32 {
seen := make(map[string]struct{}, len(segs))
for _, s := range segs {
seen[s.GetSpeaker()] = struct{}{}
}
// #nosec G115 -- seen holds at most one entry per segment, and collectSegments
// refuses any count above maxDiarSegments (2^22), so this is orders of
// magnitude below the int32 the proto field is.
return int32(len(seen))
}
// diarizePCM drives one whole clip through a diarization job.
//
// The caller must hold engineMu.
func diarizePCM(open diarStreamOpener, pcm []float32, sampleRate int32, cfg *cDiarSegmentationConfig) (*pb.DiarizeResponse, error) {
// Before the stream is opened, not inside the push: a silent or truncated
// upload decodes to zero samples, &pcm[0] panics on that, and there is no
// diarization to be had from it anyway.
if len(pcm) == 0 {
return nil, status.Error(codes.InvalidArgument, "nemo-speech-cpp: empty audio")
}
stream, err := open(cfg)
if err != nil {
return nil, err
}
defer stream.close()
// Chunked rather than pushed whole so the runtime advances as it goes
// instead of buffering the entire clip before the first chunk boundary.
for _, chunk := range chunkPCM(pcm, streamChunkSamples) {
if err := stream.push(chunk, sampleRate); err != nil {
return nil, err
}
}
// Before the query, always: finish is what labels the audio tail, so
// segmenting first drops the last turn of every clip.
if err := stream.finish(); err != nil {
return nil, err
}
raw, err := collectSegments(stream)
if err != nil {
return nil, err
}
segs := toDiarizeSegments(raw)
out := &pb.DiarizeResponse{
Segments: segs,
NumSpeakers: distinctSpeakers(segs),
}
// 0 is the proto's "unknown" and the C API's "already at the model rate",
// so a rate that means the latter must not be divided by.
if sampleRate > 0 {
out.Duration = float32(len(pcm)) / float32(sampleRate)
}
// Language and the per-segment text stay empty: there is no ASR in this
// pipeline to fill them, and the proto documents both as optional.
return out, nil
}
// Diarize labels who spoke when in the audio at req.Dst.
//
// The whole body runs inside withEngine, so the family check and the C calls
// that trust the handle happen under a single acquisition of engineMu. The
// audio decode is in there too, for the reason documented on
// AudioTranscription: the backend already serialises RPCs, so the wider hold
// costs nothing, and the narrower one is the gap Free can land in.
func (n *NemoSpeech) Diarize(req *pb.DiarizeRequest) (pb.DiarizeResponse, error) {
var out *pb.DiarizeResponse
if err := n.withEngine(familyDiarization, func() error {
r, err := n.diarize(req)
out = r
return err
}); err != nil {
return pb.DiarizeResponse{}, err
}
if out == nil {
return pb.DiarizeResponse{}, status.Error(codes.Internal,
"nemo-speech-cpp: diarization produced no result")
}
// Assembled field by field rather than dereferenced: the RPC returns the
// message by value and the message embeds a mutex, so copying the struct is
// a copylocks violation.
return pb.DiarizeResponse{
Segments: out.Segments,
NumSpeakers: out.NumSpeakers,
Duration: out.Duration,
Language: out.Language,
}, nil
}
// diarize is Diarize's body. The caller must hold engineMu.
func (n *NemoSpeech) diarize(req *pb.DiarizeRequest) (*pb.DiarizeResponse, error) {
if req.GetDst() == "" {
return nil, status.Error(codes.InvalidArgument,
"nemo-speech-cpp: DiarizeRequest.dst (audio path) is required")
}
// Logged rather than rejected: a client that asks for a speaker count still
// wants the diarization it can have, and a request that names a field this
// backend drops should say so somewhere the operator can find it.
if dropped := unsupportedRequestFields(req); len(dropped) > 0 {
xlog.Warn("nemo-speech-cpp: ignoring diarization request fields this model has no equivalent for",
"fields", dropped)
}
pcm, sampleRate, err := decodeAudioMono16k(req.GetDst())
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "nemo-speech-cpp: read audio: %v", err)
}
return diarizePCM(n.openDiarStream, pcm, sampleRate, segmentationConfig(req))
}
+540
View File
@@ -0,0 +1,540 @@
package main
import (
"errors"
"path/filepath"
"unsafe"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// fakeDiarStream scripts what the C API returns for one diarization job.
//
// There is no Sortformer GGUF in the tree, so this is the only way the loop on
// top of the ABI (the empty guard, chunking, the count-then-fill protocol, the
// buffer growth retry) gets tested at all. It fakes the C contract, not the
// model: segs is whatever nemo_speech_diar_segments would have produced.
type fakeDiarStream struct {
segs []cDiarSegment
// countErr and fillErrs script failures. fillErrs is consumed one entry per
// fillSegments call so a growth retry can be scripted.
countErr error
fillErrs []error
// queryCount, when non-zero, is what the size query reports instead of
// len(segs), so a runtime that under-reported can be scripted.
queryCount uint64
// growTo, when non-zero, is the count reported by the FIRST fillSegments
// call, standing in for a runtime whose segment list outgrew the size query.
growTo uint64
pushed [][]float32
rates []int32
finished int
closed int
counts int
fills int
// opened records the segmentation config the opener was handed.
cfg *cDiarSegmentationConfig
}
func (f *fakeDiarStream) push(pcm []float32, rate int32) error {
f.pushed = append(f.pushed, pcm)
f.rates = append(f.rates, rate)
return nil
}
func (f *fakeDiarStream) finish() error {
f.finished++
return nil
}
func (f *fakeDiarStream) close() { f.closed++ }
func (f *fakeDiarStream) countSegments() (uint64, error) {
f.counts++
if f.countErr != nil {
return 0, f.countErr
}
if f.queryCount > 0 {
return f.queryCount, nil
}
return uint64(len(f.segs)), nil
}
func (f *fakeDiarStream) fillSegments(buf []cDiarSegment) (uint64, error) {
f.fills++
var err error
if len(f.fillErrs) > 0 {
err, f.fillErrs = f.fillErrs[0], f.fillErrs[1:]
}
if f.fills == 1 && f.growTo > 0 {
// The runtime writes *count before it rejects a short buffer, so a
// growth failure still reports the count the caller needs.
return f.growTo, err
}
n := copy(buf, f.segs)
return uint64(n), err
}
// allPushed flattens what the fake received, so a spec can assert the audio
// arrived intact regardless of how it was chunked.
func (f *fakeDiarStream) allPushed() []float32 {
var out []float32
for _, c := range f.pushed {
out = append(out, c...)
}
return out
}
func (f *fakeDiarStream) opener() diarStreamOpener {
return func(cfg *cDiarSegmentationConfig) (diarStream, error) {
f.cfg = cfg
return f, nil
}
}
var _ = Describe("Diarize", func() {
It("refuses when the loaded model is not a diarization model", func() {
n := &NemoSpeech{fam: familyASR}
_, err := n.Diarize(&pb.DiarizeRequest{Dst: "/tmp/whatever.wav"})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
})
It("refuses on a model that was never loaded", func() {
n := &NemoSpeech{}
_, err := n.Diarize(&pb.DiarizeRequest{Dst: "/tmp/whatever.wav"})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
})
// The family gate has to run before anything reads the request, or a
// misrouted request would be reported as a bad path rather than as a model
// that cannot diarize.
It("reports a missing audio path on a diarization model", func() {
n := &NemoSpeech{fam: familyDiarization}
_, err := n.Diarize(&pb.DiarizeRequest{})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("dst"))
})
It("reports audio it cannot read", func() {
n := &NemoSpeech{fam: familyDiarization}
missing := filepath.Join(GinkgoT().TempDir(), "absent.wav")
_, err := n.Diarize(&pb.DiarizeRequest{Dst: missing})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("read audio"))
})
// A rejection must leave the mutex free, or the next request deadlocks
// rather than fails.
It("releases the engine lock on every rejection path", func() {
n := &NemoSpeech{fam: familyDiarization}
_, err := n.Diarize(&pb.DiarizeRequest{})
Expect(err).To(HaveOccurred())
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
})
var _ = Describe("diarizePCM", func() {
// Task 7 found that a purego-bound entry point reached with zero samples
// panics on &pcm[0], so a silent clip must be rejected before the stream is
// ever opened, not inside the push.
It("rejects empty audio without opening a stream", func() {
opened := false
open := func(*cDiarSegmentationConfig) (diarStream, error) {
opened = true
return &fakeDiarStream{}, nil
}
_, err := diarizePCM(open, nil, 16000, nil)
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("empty audio"))
Expect(opened).To(BeFalse())
})
It("pushes the whole clip, finishes, and closes the stream", func() {
pcm := make([]float32, streamChunkSamples*2+7)
for i := range pcm {
pcm[i] = float32(i)
}
f := &fakeDiarStream{}
_, err := diarizePCM(f.opener(), pcm, 16000, nil)
Expect(err).ToNot(HaveOccurred())
Expect(f.allPushed()).To(Equal(pcm))
Expect(f.pushed).To(HaveLen(3), "the clip must be chunked, not pushed whole")
Expect(f.rates).To(HaveEach(int32(16000)))
Expect(f.finished).To(Equal(1))
Expect(f.closed).To(Equal(1))
})
// Segments must come from a finished stream: the tail of the audio is only
// labelled by finish, so asking first silently drops the last turn.
It("finishes the stream before it asks for segments", func() {
f := &fakeDiarStream{segs: []cDiarSegment{{StartTime: 0, EndTime: 1, Speaker: 1}}}
f.fillErrs = nil
var finishedAtCount int
wrapped := func(cfg *cDiarSegmentationConfig) (diarStream, error) {
f.cfg = cfg
return &countObserver{fakeDiarStream: f, seen: &finishedAtCount}, nil
}
_, err := diarizePCM(wrapped, []float32{1, 2, 3}, 16000, nil)
Expect(err).ToNot(HaveOccurred())
Expect(finishedAtCount).To(Equal(1), "the size query ran before finish")
})
It("converts the runtime's seconds straight through and numbers the segments", func() {
f := &fakeDiarStream{segs: []cDiarSegment{
{StartTime: 0, EndTime: 0.8, Speaker: 1},
{StartTime: 0.8, EndTime: 2.0, Speaker: 2},
}}
res, err := diarizePCM(f.opener(), []float32{1, 2, 3}, 16000, nil)
Expect(err).ToNot(HaveOccurred())
Expect(res.Segments).To(HaveLen(2))
Expect(res.Segments[0].GetId()).To(Equal(int32(0)))
Expect(res.Segments[0].GetStart()).To(BeNumerically("~", 0.0, 1e-6))
Expect(res.Segments[0].GetEnd()).To(BeNumerically("~", 0.8, 1e-6))
Expect(res.Segments[0].GetSpeaker()).To(Equal("1"))
Expect(res.Segments[1].GetId()).To(Equal(int32(1)))
Expect(res.Segments[1].GetStart()).To(BeNumerically("~", 0.8, 1e-6))
Expect(res.Segments[1].GetEnd()).To(BeNumerically("~", 2.0, 1e-6))
Expect(res.Segments[1].GetSpeaker()).To(Equal("2"))
})
It("reports the clip duration in seconds", func() {
f := &fakeDiarStream{}
res, err := diarizePCM(f.opener(), make([]float32, 32000), 16000, nil)
Expect(err).ToNot(HaveOccurred())
Expect(res.GetDuration()).To(BeNumerically("~", 2.0, 1e-6))
})
// 0 is the proto's documented "unknown", and it is also the C API's "these
// samples are already at the model rate", so a rate that cannot be trusted
// must not be turned into a duration.
It("reports no duration when the sample rate is unknown", func() {
f := &fakeDiarStream{}
res, err := diarizePCM(f.opener(), make([]float32, 32000), 0, nil)
Expect(err).ToNot(HaveOccurred())
Expect(res.GetDuration()).To(BeZero())
})
It("hands the segmentation config to the opener", func() {
f := &fakeDiarStream{}
cfg := &cDiarSegmentationConfig{MinDurationSec: 0.5}
_, err := diarizePCM(f.opener(), []float32{1}, 16000, cfg)
Expect(err).ToNot(HaveOccurred())
Expect(f.cfg).To(BeIdenticalTo(cfg))
})
It("closes the stream when the segment query fails", func() {
f := &fakeDiarStream{countErr: errors.New("boom")}
_, err := diarizePCM(f.opener(), []float32{1}, 16000, nil)
Expect(err).To(MatchError(ContainSubstring("boom")))
Expect(f.closed).To(Equal(1))
})
// The pipeline carries no ASR, so text and language stay empty whatever the
// caller asked for.
It("leaves the transcript fields empty", func() {
f := &fakeDiarStream{segs: []cDiarSegment{{StartTime: 0, EndTime: 1, Speaker: 1}}}
res, err := diarizePCM(f.opener(), []float32{1}, 16000, nil)
Expect(err).ToNot(HaveOccurred())
Expect(res.GetLanguage()).To(BeEmpty())
Expect(res.Segments[0].GetText()).To(BeEmpty())
})
})
// countObserver records how many size queries had run by the time finish was
// called, so the ordering can be asserted without reaching into diarizePCM.
type countObserver struct {
*fakeDiarStream
seen *int
}
func (c *countObserver) finish() error {
*c.seen = c.counts + 1 // finish must run before the first query
return c.fakeDiarStream.finish()
}
var _ = Describe("distinctSpeakers", func() {
It("counts labels, not segments", func() {
segs := []*pb.DiarizeSegment{
{Speaker: "1"}, {Speaker: "2"}, {Speaker: "1"}, {Speaker: "2"}, {Speaker: "1"},
}
Expect(distinctSpeakers(segs)).To(Equal(int32(2)))
})
It("counts a single-speaker recording as one", func() {
segs := []*pb.DiarizeSegment{{Speaker: "1"}, {Speaker: "1"}, {Speaker: "1"}}
Expect(distinctSpeakers(segs)).To(Equal(int32(1)))
})
// Four segments over three labels, not three over three: with the segment
// count and the label count equal, a `return len(segs)` would satisfy this
// spec and it would assert nothing.
It("counts every distinct label once", func() {
segs := []*pb.DiarizeSegment{{Speaker: "1"}, {Speaker: "2"}, {Speaker: "3"}, {Speaker: "2"}}
Expect(distinctSpeakers(segs)).To(Equal(int32(3)))
})
It("is zero with no segments", func() {
Expect(distinctSpeakers(nil)).To(BeZero())
})
// The response field is documented as the count of distinct labels in
// `segments`, which is not the model's capacity: Sortformer v2 can label
// four speakers whatever the clip actually contains.
It("reports what the segments contain, not the model capacity", func() {
f := &fakeDiarStream{segs: []cDiarSegment{
{StartTime: 0, EndTime: 1, Speaker: 1},
{StartTime: 1, EndTime: 2, Speaker: 2},
{StartTime: 2, EndTime: 3, Speaker: 1},
}}
res, err := diarizePCM(f.opener(), []float32{1}, 16000, nil)
Expect(err).ToNot(HaveOccurred())
Expect(res.GetNumSpeakers()).To(Equal(int32(2)))
})
It("reports no speakers when the runtime found no segments", func() {
f := &fakeDiarStream{}
res, err := diarizePCM(f.opener(), []float32{1}, 16000, nil)
Expect(err).ToNot(HaveOccurred())
Expect(res.Segments).To(BeEmpty())
Expect(res.GetNumSpeakers()).To(BeZero())
})
})
var _ = Describe("collectSegments", func() {
It("skips the fill entirely when there is nothing to collect", func() {
f := &fakeDiarStream{}
segs, err := collectSegments(f)
Expect(err).ToNot(HaveOccurred())
Expect(segs).To(BeEmpty())
Expect(f.counts).To(Equal(1))
Expect(f.fills).To(BeZero(), "a zero count must not be followed by a fill")
})
It("sizes the buffer from the query and fills it", func() {
f := &fakeDiarStream{segs: []cDiarSegment{
{StartTime: 0, EndTime: 1, Speaker: 1},
{StartTime: 1, EndTime: 2, Speaker: 2},
}}
segs, err := collectSegments(f)
Expect(err).ToNot(HaveOccurred())
Expect(segs).To(HaveLen(2))
Expect(segs[1].Speaker).To(Equal(int32(2)))
Expect(f.counts).To(Equal(1))
Expect(f.fills).To(Equal(1))
})
// nemo_speech_diar_segments writes *count and only then rejects a buffer
// that is too small, so the rejected call still reports the size to retry
// with. Truncating instead would silently drop turns.
It("grows the buffer and retries when the count outran the query", func() {
f := &fakeDiarStream{
segs: []cDiarSegment{
{StartTime: 0, EndTime: 1, Speaker: 1},
{StartTime: 1, EndTime: 2, Speaker: 2},
{StartTime: 2, EndTime: 3, Speaker: 1},
},
// The query saw two, the fill found three and rejected the buffer.
queryCount: 2,
growTo: 3,
fillErrs: []error{errors.New("capacity too small (need 3)")},
}
segs, err := collectSegments(f)
Expect(err).ToNot(HaveOccurred())
Expect(segs).To(HaveLen(3))
Expect(f.fills).To(Equal(2))
})
It("propagates a failure that is not about capacity", func() {
f := &fakeDiarStream{
segs: []cDiarSegment{{StartTime: 0, EndTime: 1, Speaker: 1}},
fillErrs: []error{errors.New("boom")},
}
_, err := collectSegments(f)
Expect(err).To(MatchError(ContainSubstring("boom")))
Expect(f.fills).To(Equal(1), "a non-capacity failure must not be retried")
})
// make() panics on a length it cannot satisfy, and a panic in an RPC
// handler kills the backend process. A count that could only come from an
// uninitialised or corrupted size_t must fail the request instead.
It("refuses an implausible count rather than trying to allocate it", func() {
f := &fakeDiarStream{queryCount: maxDiarSegments + 1}
_, err := collectSegments(f)
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Internal))
Expect(err.Error()).To(ContainSubstring("ceiling"))
Expect(f.fills).To(BeZero(), "nothing must be allocated or filled for a bad count")
})
It("still accepts a count right at the ceiling", func() {
// Only the guard is under test, so the fill is scripted to report zero
// rather than actually materialising a hundred megabytes of segments.
f := &fakeDiarStream{queryCount: maxDiarSegments}
segs, err := collectSegments(f)
Expect(err).ToNot(HaveOccurred())
Expect(segs).To(BeEmpty())
Expect(f.fills).To(Equal(1))
})
It("propagates a failed size query", func() {
f := &fakeDiarStream{countErr: errors.New("no stream")}
_, err := collectSegments(f)
Expect(err).To(MatchError(ContainSubstring("no stream")))
Expect(f.fills).To(BeZero())
})
// A runtime whose count grew on every attempt would otherwise loop forever
// holding engineMu, which blocks the unload too.
It("gives up rather than retrying forever", func() {
f := &fakeDiarStream{segs: []cDiarSegment{{StartTime: 0, EndTime: 1, Speaker: 1}}}
g := &alwaysGrowing{fakeDiarStream: f}
_, err := collectSegments(g)
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Internal))
Expect(f.fills).To(Equal(diarSegmentsMaxAttempts))
})
})
// alwaysGrowing reports a bigger count on every fill, which is the pathological
// case the attempt bound exists for.
type alwaysGrowing struct {
*fakeDiarStream
n uint64
}
func (a *alwaysGrowing) fillSegments([]cDiarSegment) (uint64, error) {
a.n += 10
a.fills++
return a.n, errors.New("capacity too small")
}
// The six frame counts are the one part of the create config that no other
// check in the tree can see. The layout assertions pin the struct's shape, and
// a wrong VALUE keeps that shape exactly, so without these specs deleting a
// sentinel is invisible: c_api.cpp applies left_context_frames at >= 0, so a
// dropped -1 there silently pins the model's left context to zero.
var _ = Describe("diarModelConfig", func() {
It("declares its own size so the runtime accepts the fields", func() {
Expect(diarModelConfig(0, -1).Size).To(Equal(unsafe.Sizeof(cDiarModelConfig{})))
})
It("carries the model path and the configured device", func() {
cfg := diarModelConfig(0xDEADBEEF, 2)
Expect(cfg.ModelPath).To(Equal(uintptr(0xDEADBEEF)))
Expect(cfg.GPU).To(Equal(int32(2)))
})
It("passes the CPU sentinel through untouched", func() {
Expect(diarModelConfig(0, -1).GPU).To(Equal(int32(-1)))
})
// Asserted field by field rather than as a whole struct so a failure names
// the sentinel that went missing.
It("leaves every frame-geometry override at the negative sentinel", func() {
cfg := diarModelConfig(0, -1)
Expect(cfg.ChunkFrames).To(Equal(int32(-1)), "chunk_frames")
Expect(cfg.RightContextFrames).To(Equal(int32(-1)), "right_context_frames")
Expect(cfg.FIFOFrames).To(Equal(int32(-1)), "fifo_frames")
Expect(cfg.SpkcacheFrames).To(Equal(int32(-1)), "spkcache_frames")
Expect(cfg.UpdatePeriodFrames).To(Equal(int32(-1)), "update_period_frames")
// Called out on its own because it is the only one of the six the
// runtime applies at >= 0: zero here is a valid explicit left context,
// not "unset", so this is the field a dropped sentinel actually breaks.
Expect(cfg.LeftContextFrames).To(Equal(int32(-1)), "left_context_frames")
Expect(cfg.LeftContextFrames).To(BeNumerically("<", 0),
"left_context_frames is applied at >= 0, so a non-negative value pins the geometry")
})
// The preset selects the streaming geometry wholesale, so it has to stay
// NULL until there is a model to verify a different one against.
It("leaves the preset unset", func() {
Expect(diarModelConfig(0, -1).Preset).To(BeZero())
})
})
var _ = Describe("segmentationConfig", func() {
// A request that set nothing must stay NULL on the C side: diar.h documents
// NULL as "library defaults", and those defaults are NeMo's callhome-tuned
// values for this checkpoint rather than zeros.
It("is absent when the request asked for no postprocessing", func() {
Expect(segmentationConfig(&pb.DiarizeRequest{})).To(BeNil())
})
It("maps min_duration_on onto the minimum segment duration", func() {
cfg := segmentationConfig(&pb.DiarizeRequest{MinDurationOn: 0.4})
Expect(cfg).ToNot(BeNil())
Expect(cfg.MinDurationSec).To(BeNumerically("~", 0.4, 1e-6))
Expect(cfg.MinGapSec).To(BeZero())
})
It("maps min_duration_off onto the gap fill", func() {
cfg := segmentationConfig(&pb.DiarizeRequest{MinDurationOff: 0.25})
Expect(cfg).ToNot(BeNil())
Expect(cfg.MinGapSec).To(BeNumerically("~", 0.25, 1e-6))
Expect(cfg.MinDurationSec).To(BeZero())
})
It("declares its own size so the runtime accepts the fields", func() {
cfg := segmentationConfig(&pb.DiarizeRequest{MinDurationOn: 0.4})
Expect(cfg.Size).To(Equal(sizeofDiarSegmentationConfig()))
})
// The onset/offset hysteresis is not a clustering threshold and Sortformer
// has no clustering stage at all, so mapping one onto the other would be an
// invented equivalence. It has to stay unset.
It("ignores fields this pipeline has no equivalent for", func() {
Expect(segmentationConfig(&pb.DiarizeRequest{
NumSpeakers: 2,
MinSpeakers: 1,
MaxSpeakers: 4,
ClusteringThreshold: 0.7,
IncludeText: true,
Threads: 8,
})).To(BeNil())
})
It("ignores non-positive values, which the runtime reads as unset", func() {
Expect(segmentationConfig(&pb.DiarizeRequest{MinDurationOn: -1, MinDurationOff: 0})).To(BeNil())
})
})
var _ = Describe("unsupportedRequestFields", func() {
It("is empty for a request this backend can honour in full", func() {
Expect(unsupportedRequestFields(&pb.DiarizeRequest{
Dst: "/tmp/a.wav",
MinDurationOn: 0.4,
MinDurationOff: 0.2,
})).To(BeEmpty())
})
It("names every field it had to drop", func() {
Expect(unsupportedRequestFields(&pb.DiarizeRequest{
NumSpeakers: 2,
MinSpeakers: 1,
MaxSpeakers: 4,
ClusteringThreshold: 0.7,
IncludeText: true,
Threads: 8,
})).To(ConsistOf(
"num_speakers", "min_speakers", "max_speakers",
"clustering_threshold", "include_text", "threads",
))
})
})
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
gguf "github.com/gpustack/gguf-parser-go"
)
// auxOnlyArchitectures are converted NeMo components that attach to a primary
// model but are never loadable on their own. Pointing a model config at one is
// a configuration mistake worth naming explicitly.
var auxOnlyArchitectures = map[string]string{
"nemo-nano-codec": "a TTS codec, set it with the codec_model option on a magpietts model",
"vad": "a VAD model, set it with the vad_model option on an asr model",
"pnc": "a punctuation model, set it with the pnc_model option on an asr model",
}
// familyFor maps a GGUF general.architecture value to a model family.
//
// Unknown architectures resolve to NMT rather than an error: NMT GGUFs come
// from llama.cpp's converter and carry an ordinary LLM architecture, so there
// is no NeMo-specific string to match. The user selected this backend
// explicitly, which is the signal that the model is meant for it.
func familyFor(arch string) (family, error) {
if reason, ok := auxOnlyArchitectures[arch]; ok {
return familyUnknown, fmt.Errorf(
"nemo-speech-cpp: %q is %s, not a model that can be loaded directly", arch, reason)
}
switch arch {
case "asr":
return familyASR, nil
case "sortformer":
return familyDiarization, nil
case "magpietts":
return familyTTS, nil
}
return familyNMT, nil
}
// ggufArchitecture reads general.architecture from a GGUF file.
func ggufArchitecture(path string) (string, error) {
f, err := gguf.ParseGGUFFile(path, gguf.UseMMap(), gguf.SkipLargeMetadata())
if err != nil {
return "", fmt.Errorf("nemo-speech-cpp: parse gguf %q: %w", path, err)
}
kv, found := f.Header.MetadataKV.Index([]string{"general.architecture"})
if found == 0 {
return "", fmt.Errorf("nemo-speech-cpp: %q has no general.architecture key", path)
}
arch := kv["general.architecture"]
// ValueString panics on a mistyped key, and a hand-written or half-converted
// GGUF is exactly where that happens. This function is the load-time guard;
// it reports, it does not take the process down.
if arch.ValueType != gguf.GGUFMetadataValueTypeString {
return "", fmt.Errorf(
"nemo-speech-cpp: %q has a non-string general.architecture (type %v)", path, arch.ValueType)
}
return arch.ValueString(), nil
}
// discoverTTSAssets fills in codecModel and tokenizerDir when they were not set
// explicitly, by scanning the primary GGUF's own directory.
//
// A missing asset is a hard error rather than a warning: the runtime would
// otherwise load and emit garbage audio, which surfaces far from the cause.
func discoverTTSAssets(primaryGGUF string, o *loadOptions) error {
dir := filepath.Dir(primaryGGUF)
if o.codecModel == "" {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("nemo-speech-cpp: scan %q for a codec model: %w", dir, err)
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
candidate := filepath.Join(dir, name)
// Skip the primary model itself: a file called nanocodec-magpie.gguf
// would otherwise be selected as its own codec. Compare basenames,
// because candidate is Cleaned by filepath.Join while primaryGGUF
// arrives as the caller wrote it, so "/models//magpie.gguf" would
// slip past a whole-path equality.
if name == filepath.Base(primaryGGUF) {
continue
}
if strings.Contains(strings.ToLower(name), "nanocodec") ||
strings.Contains(strings.ToLower(name), "nano-codec") {
o.codecModel = candidate
break
}
}
}
if o.codecModel == "" {
return fmt.Errorf(
"nemo-speech-cpp: no NanoCodec GGUF found next to %q, set the codec_model option",
primaryGGUF)
}
if o.tokenizerDir == "" {
candidate := filepath.Join(dir, "extracted")
if st, err := os.Stat(candidate); err == nil && st.IsDir() {
o.tokenizerDir = candidate
}
}
if o.tokenizerDir == "" {
return fmt.Errorf(
"nemo-speech-cpp: no tokenizer directory found next to %q, set the tokenizer_dir option",
primaryGGUF)
}
return nil
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"encoding/binary"
"os"
"path/filepath"
gguf "github.com/gpustack/gguf-parser-go"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("familyFor", func() {
It("maps the NeMo architectures to their families", func() {
for arch, want := range map[string]family{
"asr": familyASR,
"sortformer": familyDiarization,
"magpietts": familyTTS,
} {
got, err := familyFor(arch)
Expect(err).ToNot(HaveOccurred(), "arch %q", arch)
Expect(got).To(Equal(want), "arch %q", arch)
}
})
It("treats an unknown architecture as NMT", func() {
// NMT GGUFs are produced by llama.cpp's converter, so they carry an LLM
// architecture such as qwen3 rather than a NeMo-specific string.
got, err := familyFor("qwen3")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(familyNMT))
})
It("rejects an auxiliary-only architecture as a primary model", func() {
for _, arch := range []string{"nemo-nano-codec", "vad", "pnc"} {
_, err := familyFor(arch)
Expect(err).To(HaveOccurred(), "arch %q", arch)
Expect(err.Error()).To(ContainSubstring(arch))
}
})
})
// ggufWithArchValue builds a minimal GGUF v3 carrying general.architecture as
// its single metadata entry, with the caller's value type and encoded value.
func ggufWithArchValue(valueType gguf.GGUFMetadataValueType, value []byte) []byte {
const key = "general.architecture"
var b []byte
b = append(b, 'G', 'G', 'U', 'F')
b = binary.LittleEndian.AppendUint32(b, 3) // version
b = binary.LittleEndian.AppendUint64(b, 0) // tensor count
b = binary.LittleEndian.AppendUint64(b, 1) // metadata kv count
b = binary.LittleEndian.AppendUint64(b, uint64(len(key)))
b = append(b, key...)
b = binary.LittleEndian.AppendUint32(b, uint32(valueType))
return append(b, value...)
}
// writeGGUFWithUint32Arch writes a minimal GGUF v3 whose single metadata entry
// is general.architecture typed UINT32 rather than STRING. Handwritten and
// half-converted files really do carry mistyped keys, and the parser hands them
// back rather than rejecting them.
func writeGGUFWithUint32Arch(path string) {
b := ggufWithArchValue(gguf.GGUFMetadataValueTypeUint32, binary.LittleEndian.AppendUint32(nil, 7))
ExpectWithOffset(1, os.WriteFile(path, b, 0o600)).To(Succeed())
}
// writeGGUFWithArch writes a minimal GGUF v3 that parses cleanly and reports
// arch as its general.architecture. It is the only way to reach the code past
// ggufArchitecture in a test, since there are no real NeMo GGUFs to point at.
func writeGGUFWithArch(path, arch string) {
v := binary.LittleEndian.AppendUint64(nil, uint64(len(arch)))
v = append(v, arch...)
ExpectWithOffset(1, os.WriteFile(path, ggufWithArchValue(gguf.GGUFMetadataValueTypeString, v), 0o600)).To(Succeed())
}
var _ = Describe("ggufArchitecture", func() {
It("returns an error rather than panicking on a file that is not a GGUF", func() {
p := filepath.Join(GinkgoT().TempDir(), "not-a-model.gguf")
Expect(os.WriteFile(p, []byte("definitely not a gguf header"), 0o600)).To(Succeed())
_, err := ggufArchitecture(p)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(p))
})
It("returns an error rather than panicking when general.architecture is not a string", func() {
p := filepath.Join(GinkgoT().TempDir(), "mistyped-arch.gguf")
writeGGUFWithUint32Arch(p)
arch, err := ggufArchitecture(p)
Expect(err).To(HaveOccurred())
Expect(arch).To(BeEmpty())
Expect(err.Error()).To(ContainSubstring("general.architecture"))
})
})
var _ = Describe("discoverTTSAssets", func() {
var dir string
BeforeEach(func() {
dir = GinkgoT().TempDir()
})
write := func(name string) string {
p := filepath.Join(dir, name)
Expect(os.WriteFile(p, []byte("x"), 0o600)).To(Succeed())
return p
}
It("finds a sibling nanocodec gguf and extracted dir", func() {
primary := write("magpie.f16.gguf")
codec := write("nemo-nano-codec-22khz.f16.gguf")
Expect(os.Mkdir(filepath.Join(dir, "extracted"), 0o755)).To(Succeed())
o := loadOptions{}
Expect(discoverTTSAssets(primary, &o)).To(Succeed())
Expect(o.codecModel).To(Equal(codec))
Expect(o.tokenizerDir).To(Equal(filepath.Join(dir, "extracted")))
})
It("never selects the primary gguf as its own codec", func() {
// A file named so it would match a naive *.gguf scan.
primary := write("nanocodec-magpie.gguf")
Expect(os.Mkdir(filepath.Join(dir, "extracted"), 0o755)).To(Succeed())
o := loadOptions{}
err := discoverTTSAssets(primary, &o)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("codec_model"))
})
It("never selects the primary gguf as its own codec through an uncleaned path", func() {
// LocalAI joins the model directory and the model name itself, so a
// trailing separator on ModelPath produces a doubled slash here. The
// self-codec guard has to survive that.
write("nanocodec-magpie.gguf")
primary := dir + "//nanocodec-magpie.gguf"
Expect(os.Mkdir(filepath.Join(dir, "extracted"), 0o755)).To(Succeed())
o := loadOptions{}
err := discoverTTSAssets(primary, &o)
Expect(err).To(HaveOccurred())
Expect(o.codecModel).To(BeEmpty())
Expect(err.Error()).To(ContainSubstring("codec_model"))
})
It("does not overwrite explicitly configured paths", func() {
primary := write("magpie.f16.gguf")
write("nemo-nano-codec.gguf")
Expect(os.Mkdir(filepath.Join(dir, "extracted"), 0o755)).To(Succeed())
o := loadOptions{codecModel: "/explicit/codec.gguf", tokenizerDir: "/explicit/tok"}
Expect(discoverTTSAssets(primary, &o)).To(Succeed())
Expect(o.codecModel).To(Equal("/explicit/codec.gguf"))
Expect(o.tokenizerDir).To(Equal("/explicit/tok"))
})
It("names the missing option key when the tokenizer dir cannot be found", func() {
primary := write("magpie.f16.gguf")
write("nemo-nano-codec.gguf")
o := loadOptions{}
err := discoverTTSAssets(primary, &o)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("tokenizer_dir"))
})
})
+76
View File
@@ -0,0 +1,76 @@
package main
// Started internally by LocalAI, one gRPC server per loaded model.
//
// Binds NVIDIA NeMo-Speech.cpp through purego. The runtime splits its C ABI
// across three shared objects: asr (which also exports the diarization
// symbols), tts, and nmt. Library names can be overridden with
// NEMO_SPEECH_ASR_LIBRARY / _TTS_LIBRARY / _NMT_LIBRARY, mirroring the
// PARAKEET_LIBRARY convention in the sibling backends.
//
// The naming is asymmetric on purpose: upstream links a dedicated
// libnemo_speech_asr_c / libnemo_speech_nmt_c around a private C++ core, but
// compiles the TTS c_api straight into libnemo_speech_tts and only aliases the
// nemo_speech_tts_c CMake target, so there is no libnemo_speech_tts_c on disk.
import (
"flag"
"fmt"
"os"
"runtime"
"github.com/ebitengine/purego"
grpc "github.com/mudler/LocalAI/pkg/grpc"
)
var addr = flag.String("addr", "localhost:50051", "the address to connect to")
// libSuffix is the platform's shared-object extension.
func libSuffix() string {
if runtime.GOOS == "darwin" {
return ".dylib"
}
return ".so"
}
// libraryName resolves an override env var, falling back to the platform name.
func libraryName(envVar, base string) string {
if v := os.Getenv(envVar); v != "" {
return v
}
return base + libSuffix()
}
func main() {
flag.Parse()
if err := openLibraries(); err != nil {
panic(err)
}
if err := grpc.StartServer(*addr, &NemoSpeech{}); err != nil {
panic(err)
}
}
// openLibraries dlopens the three C ABI shared objects. All three are opened
// eagerly so a packaging mistake fails at startup with a clear message rather
// than at first inference of one particular family.
func openLibraries() error {
for _, l := range []struct {
env string
base string
dst *uintptr
}{
{"NEMO_SPEECH_ASR_LIBRARY", "libnemo_speech_asr_c", &asrLib},
{"NEMO_SPEECH_TTS_LIBRARY", "libnemo_speech_tts", &ttsLib},
{"NEMO_SPEECH_NMT_LIBRARY", "libnemo_speech_nmt_c", &nmtLib},
} {
name := libraryName(l.env, l.base)
h, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("nemo-speech-cpp: dlopen %q: %w", name, err)
}
*l.dst = h
}
return registerSymbols()
}
+273
View File
@@ -0,0 +1,273 @@
package main
import (
"errors"
"fmt"
"runtime"
"sync"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// family is the model family selected at load time from the GGUF architecture.
type family int
const (
familyUnknown family = iota
familyASR
familyDiarization
familyTTS
familyNMT
)
func (f family) String() string {
switch f {
case familyASR:
return "asr"
case familyDiarization:
return "diarization"
case familyTTS:
return "tts"
case familyNMT:
return "nmt"
}
return "unknown"
}
// NemoSpeech is one loaded model. Exactly one of the handles is non-zero,
// matching fam.
type NemoSpeech struct {
base.SingleThread
fam family
opts loadOptions
// engineMu guards fam and the handles, and serializes calls into the C
// runtime for this model. Its participants today are withEngine and Free;
// the per-family RPCs in Tasks 6 to 9 join it by routing through withEngine.
engineMu sync.Mutex
// synth and nmt are shortened rather than spelled out: synthesizer and
// translator are the names of the two RPC-side interfaces those handles are
// wrapped in (tts.go, nmt.go), and a field sharing a name with an interface in
// the same package makes every construction site read as a conversion.
recognizer uintptr
diarizer uintptr
synth uintptr
nmt uintptr
}
// cstr allocates a NUL-terminated C string and returns its pointer plus a
// release function. The empty string maps to a null pointer because the C API
// treats NULL and "" as equivalent for every optional field.
//
// The address leaves the Go type system as a uintptr, which the collector does
// not trace, so the bytes are pinned for as long as C may read them. Pinning is
// the only mechanism with a documented guarantee here: the config structs hold
// raw addresses, and an unpinned Go allocation is free to be collected (and, in
// principle, moved) the moment its last traced reference dies.
//
// The returned pointer is for C only, and the direction is one-way. Converting
// it back to an unsafe.Pointer to read the bytes from Go is checked by checkptr
// (which -race turns on) and kills the process with
//
// fatal error: checkptr: pointer arithmetic result points to invalid allocation
//
// as soon as the address lands inside a Go allocation, which is exactly what
// this produces. C reading it is fine because C is not instrumented; Go reading
// it back is not.
//
// The caller MUST defer the release function immediately, in the same statement
// that takes the pointer. Dropping it leaks the pin, which the runtime reports
// at the next collection as:
//
// runtime.Pinner: found leaking pinned pointer; forgot to call Unpin()?
//
// That is loud and wrong-looking on purpose: the alternative failure mode is C
// reading freed memory, which shows up as rare corruption with no trace back
// to here.
func cstr(s string) (uintptr, func()) {
if s == "" {
return 0, func() {}
}
b := append([]byte(s), 0)
pin := new(runtime.Pinner)
pin.Pin(&b[0])
// #nosec G103 -- b is non-empty (s != "" above) and &b[0] is pinned on the
// previous line, so the address C receives cannot be collected or moved
// until the returned release runs. One-way by construction: the doc comment
// above forbids converting this uintptr back, which is what keeps checkptr
// (and therefore -race) out of it.
return uintptr(unsafe.Pointer(&b[0])), func() {
if pin == nil {
return
}
pin.Unpin()
pin = nil
}
}
// There is deliberately no inverse of cstr in this package. Every C entry point
// that returns a string is bound in abi.go with a Go `string` return, which
// purego converts from the char* itself, so a hand-rolled reader would have no
// production caller and would exist only as an unsafe helper waiting to be
// pointed at the wrong kind of address. Reach for purego's conversion instead;
// if a future symbol genuinely needs the raw char* (to tell NULL from ""), bind
// it as uintptr at that call site, where the ownership can be reasoned about.
// requireFamily gates an RPC on the family selected at load time. Returning
// Unimplemented rather than a nil dereference means a misconfigured model YAML
// produces a message a user can act on.
//
// Callers must already hold engineMu: Free writes n.fam under it, so an
// unlocked read here is a data race. Use withEngine rather than calling this
// directly.
func (n *NemoSpeech) requireFamily(want family) error {
if n.fam != want {
return status.Errorf(codes.Unimplemented,
"nemo-speech-cpp: this model was loaded as %s, not %s", n.fam, want)
}
return nil
}
// withEngine runs fn holding engineMu, having first checked the family.
//
// Every RPC must go through this rather than calling requireFamily on its own.
// pkg/grpc/server.go takes the backend lock around each RPC but calls Free
// without it, so a teardown can land mid-request. Checking the family and then
// making the C calls that trust it under two separate acquisitions leaves a
// window in which Free destroys the handle, and the request goes on to use a
// zeroed one.
func (n *NemoSpeech) withEngine(want family, fn func() error) error {
n.engineMu.Lock()
defer n.engineMu.Unlock()
if err := n.requireFamily(want); err != nil {
return err
}
return fn()
}
func (n *NemoSpeech) Load(opts *pb.ModelOptions) error {
modelFile := opts.GetModelFile()
if modelFile == "" {
return errors.New("nemo-speech-cpp: ModelFile is required")
}
// Free writes fam and the handles under engineMu and runs without the
// backend lock that serialises the RPCs (pkg/grpc/server.go), so the
// load-side writes to those same fields need the same protection: without
// it this is the write-side half of the race withEngine closed on the read
// side. n.opts is in here too, since the loaders read it.
//
// The loaders called below must NOT take engineMu themselves; sync.Mutex is
// not reentrant and this is why.
n.engineMu.Lock()
defer n.engineMu.Unlock()
n.opts = parseOptions(opts.GetOptions(), opts.GetModelPath())
arch, err := ggufArchitecture(modelFile)
if err != nil {
return err
}
fam, err := familyFor(arch)
if err != nil {
return err
}
xlog.Info("nemo-speech-cpp: loading model", "arch", arch, "family", fam.String())
// fam is committed only once the family-specific loader has succeeded.
// requireFamily is the gate every RPC goes through, so a half-loaded model
// that kept its family would route requests at a handle that was never
// created.
switch fam {
case familyASR:
err = n.loadASR(modelFile)
case familyDiarization:
err = n.loadDiarizer(modelFile)
case familyTTS:
if err = discoverTTSAssets(modelFile, &n.opts); err == nil {
err = n.loadTTS(modelFile)
}
case familyNMT:
err = n.loadNMT(modelFile)
default:
err = fmt.Errorf("nemo-speech-cpp: unhandled family for architecture %q", arch)
}
if err != nil {
return err
}
n.fam = fam
return nil
}
// Free destroys the runtime handle created at load time.
//
// base.SingleThread.Free is a no-op that derived backends are expected to
// override, and every family here owns C memory that only its own destroy
// entry point can release, so without this an unloaded model leaks a whole
// acoustic model. Clearing fam as well means an RPC that races the unload is
// refused by the gate rather than handed a dangling handle, but that only holds
// for callers that took engineMu, which today means callers that went through
// withEngine.
func (n *NemoSpeech) Free() error {
n.engineMu.Lock()
defer n.engineMu.Unlock()
// Guarded on the handle, not on fam: a load that failed part way through
// leaves fam unset, and the destroy functions are nil pointers until
// openLibraries has bound them.
// Each is tested independently rather than switched on: the one-handle
// invariant is an invariant, and if it ever broke, a switch would silently
// leak the others.
if n.recognizer != 0 {
ASRDestroy(n.recognizer)
n.recognizer = 0
}
if n.diarizer != 0 {
DiarDestroy(n.diarizer)
n.diarizer = 0
}
if n.synth != 0 {
TTSDestroy(n.synth)
n.synth = 0
}
if n.nmt != 0 {
NMTDestroy(n.nmt)
n.nmt = 0
}
n.fam = familyUnknown
return nil
}
// The loaders are one per family: loadASR in asr.go, loadDiarizer in diar.go,
// loadTTS in tts.go and loadNMT in nmt.go. Each populates its config structs
// from n.opts and stores the handle in the matching field.
//
// Locking protocol, in both directions:
//
// - Every RPC must hold engineMu across its family check AND its C calls,
// which means wrapping its body in withEngine. Free runs without the
// backend lock (pkg/grpc/server.go:1019), so anything that checks the
// family and then releases the lock before calling C can have the handle
// destroyed underneath it. asr.go's AudioTranscription is the worked
// example: even the audio decode sits inside the closure, because the
// backend already serialises RPCs through base.SingleThread and so the
// wider hold costs nothing.
// - A loader must NOT take engineMu. Load holds it across the whole switch,
// and sync.Mutex is not reentrant, so locking in a loader deadlocks.
//
// One consequence the streaming RPCs have to plan around: a stream whose body
// is wrapped in withEngine holds engineMu for the WHOLE stream, so Free blocks
// until the stream ends rather than tearing the handle out from under it. That
// is the behaviour we want (a half-closed stream over a destroyed recognizer
// has no good outcome), but it means an unload waits on a client that has
// stopped sending, so a streaming loop must have its own way out: honour the
// request context and stop on it, rather than blocking forever on the next
// chunk.
@@ -0,0 +1,13 @@
package main
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestNemoSpeech(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "nemo-speech-cpp Backend Suite")
}
@@ -0,0 +1,260 @@
package main
import (
"errors"
"os"
"path/filepath"
"runtime"
"sync"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
var _ = Describe("requireFamily", func() {
It("accepts the loaded family", func() {
n := &NemoSpeech{fam: familyASR}
Expect(n.requireFamily(familyASR)).To(Succeed())
})
It("rejects a mismatched family with Unimplemented and names both", func() {
n := &NemoSpeech{fam: familyTTS}
err := n.requireFamily(familyASR)
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(err.Error()).To(ContainSubstring("tts"))
Expect(err.Error()).To(ContainSubstring("asr"))
})
It("rejects an unloaded model", func() {
n := &NemoSpeech{}
err := n.requireFamily(familyASR)
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
})
It("rejects every family when the model is unloaded", func() {
n := &NemoSpeech{}
for _, f := range []family{familyASR, familyDiarization, familyTTS, familyNMT} {
Expect(n.requireFamily(f)).To(HaveOccurred(), "family %s must be gated on an unloaded model", f)
}
})
})
// The brief's round-trip spec (cstr then a reader) cannot exist: cstr pins a Go
// allocation, and converting a uintptr back into a pointer to Go memory is a
// checkptr violation that aborts the process under -race. So cstr is asserted
// on what is observable without dereferencing its result.
var _ = Describe("cstr", func() {
It("returns a non-null pointer for a non-empty string", func() {
p, free := cstr("hello")
defer free()
Expect(p).ToNot(BeZero())
})
It("returns a null pointer for the empty string", func() {
// The C API documents NULL and "" as equivalent for optional fields, and
// passing NULL avoids allocating for every unset option.
p, free := cstr("")
defer free()
Expect(p).To(BeZero())
})
// The pin has to hold for the whole create call, which spans at least one
// safepoint. A collection must therefore neither move nor invalidate the
// address that C was handed.
It("keeps the pointer stable across a garbage collection", func() {
p, free := cstr("/models/nemo/parakeet.gguf")
defer free()
before := p
runtime.GC()
runtime.GC()
Expect(p).To(Equal(before))
})
It("survives releasing more than once", func() {
_, free := cstr("twice")
free()
Expect(free).ToNot(Panic())
})
// A dropped release leaks the pin, and the runtime turns that into a process
// abort at some later collection. Nothing can catch it, so this only pins the
// contract in prose: release in the same statement that takes the pointer.
It("releases without panicking when used as documented", func() {
Expect(func() {
p, free := cstr("released")
defer free()
_ = p
}).ToNot(Panic())
})
})
// pkg/grpc/server.go:1019 calls Free without taking the backend lock every
// other RPC holds, so a teardown really can land while a request is in flight.
// The family check and the C calls that trust it therefore have to happen under
// engineMu together, or Free can destroy the handle in the gap between them.
var _ = Describe("engine locking", func() {
It("serialises a teardown against an in-flight request", func() {
n := &NemoSpeech{fam: familyASR}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer GinkgoRecover()
defer wg.Done()
for i := 0; i < 2000; i++ {
// Errors are expected once the teardown wins the race; what must
// not happen is an unsynchronised read of the family.
_ = n.withEngine(familyASR, func() error { return nil })
}
}()
go func() {
defer GinkgoRecover()
defer wg.Done()
for i := 0; i < 2000; i++ {
Expect(n.Free()).To(Succeed())
}
}()
wg.Wait()
})
It("refuses the body when the family does not match, and still unlocks", func() {
n := &NemoSpeech{fam: familyTTS}
called := false
err := n.withEngine(familyASR, func() error {
called = true
return nil
})
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(called).To(BeFalse())
// A lock leaked on the rejection path would deadlock the next request
// rather than fail it, so prove the mutex is free afterwards.
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
It("propagates the body's error and still unlocks", func() {
n := &NemoSpeech{fam: familyASR}
boom := errors.New("boom")
Expect(n.withEngine(familyASR, func() error { return boom })).To(MatchError(boom))
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
})
var _ = Describe("Free", func() {
// The destroy entry points are nil function values until openLibraries has
// bound them, so an unloaded model must not reach them. LocalAI frees every
// backend it shuts down, including one whose Load failed.
It("is a no-op on a model that was never loaded", func() {
n := &NemoSpeech{}
Expect(n.Free()).To(Succeed())
})
It("is idempotent", func() {
n := &NemoSpeech{}
Expect(n.Free()).To(Succeed())
Expect(n.Free()).To(Succeed())
})
It("does not reach the runtime for a load that failed part way through", func() {
n := &NemoSpeech{}
path := filepath.Join(GinkgoT().TempDir(), "broken.gguf")
Expect(os.WriteFile(path, []byte("broken"), 0o600)).To(Succeed())
Expect(n.Load(&pb.ModelOptions{ModelFile: path})).ToNot(Succeed())
Expect(n.Free()).To(Succeed())
})
})
var _ = Describe("Load", func() {
It("rejects an empty model file", func() {
n := &NemoSpeech{}
err := n.Load(&pb.ModelOptions{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ModelFile"))
})
It("reports a model file that does not exist", func() {
n := &NemoSpeech{}
missing := filepath.Join(GinkgoT().TempDir(), "absent.gguf")
err := n.Load(&pb.ModelOptions{ModelFile: missing})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("absent.gguf"))
})
It("reports a file that is not a GGUF", func() {
n := &NemoSpeech{}
path := filepath.Join(GinkgoT().TempDir(), "notagguf.gguf")
Expect(os.WriteFile(path, []byte("this is not a gguf file at all"), 0o600)).To(Succeed())
err := n.Load(&pb.ModelOptions{ModelFile: path})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("nemo-speech-cpp"))
})
// A failed load must not leave a family selected, or the RPC gate would wave
// requests through to a nil handle.
It("leaves no family selected when the load fails", func() {
n := &NemoSpeech{}
path := filepath.Join(GinkgoT().TempDir(), "broken.gguf")
Expect(os.WriteFile(path, []byte("broken"), 0o600)).To(Succeed())
Expect(n.Load(&pb.ModelOptions{ModelFile: path})).ToNot(Succeed())
Expect(n.fam).To(Equal(familyUnknown))
Expect(n.requireFamily(familyASR)).To(HaveOccurred())
})
// The load path picks a family and only then runs that family's loader, so
// there is a window where the family is known and the load still fails.
// Committing n.fam before the loader runs would leave the RPC gate open on a
// handle that was never created, and pkg/grpc/server.go keeps serving the
// instance after a failed LoadModel, so the next request really would reach
// it. TTS is the only family whose loader can fail before touching C.
It("does not select the family until that family's loader has succeeded", func() {
dir := GinkgoT().TempDir()
path := filepath.Join(dir, "magpie.f16.gguf")
writeGGUFWithArch(path, "magpietts")
// Self-guard: if the handwritten GGUF ever stops parsing, Load would fail
// at ggufArchitecture instead, before a family is ever chosen, and the
// assertions below would pass without exercising the ordering at all.
Expect(ggufArchitecture(path)).To(Equal("magpietts"))
// No sibling codec in the directory, so discoverTTSAssets fails after
// familyFor has already resolved familyTTS.
n := &NemoSpeech{}
err := n.Load(&pb.ModelOptions{ModelFile: path})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("codec_model"))
Expect(n.fam).To(Equal(familyUnknown))
Expect(n.requireFamily(familyTTS)).To(HaveOccurred())
})
It("closes the family gate again after a free", func() {
n := &NemoSpeech{fam: familyASR}
Expect(n.Free()).To(Succeed())
Expect(n.fam).To(Equal(familyUnknown))
Expect(n.requireFamily(familyASR)).To(HaveOccurred())
})
It("parses the model options before it touches the model file", func() {
// The options are what tell a TTS load where its codec lives, so they have
// to be in place before any family-specific loader runs.
n := &NemoSpeech{}
path := filepath.Join(GinkgoT().TempDir(), "broken.gguf")
Expect(os.WriteFile(path, []byte("broken"), 0o600)).To(Succeed())
Expect(n.Load(&pb.ModelOptions{
ModelFile: path,
ModelPath: "/models",
Options: []string{"gpu:2", "codec_model:codec.gguf"},
})).ToNot(Succeed())
Expect(n.opts.gpu).To(Equal(int32(2)))
Expect(n.opts.codecModel).To(Equal("/models/codec.gguf"))
})
})
+368
View File
@@ -0,0 +1,368 @@
package main
import (
"regexp"
"runtime"
"strings"
"unsafe"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// pairDirective matches a leading "[src->tgt] " override.
//
// Each side is an unbounded run of two-letter segments, not one or two of them.
// Either side may also be omitted, which keeps the model-level default for it:
// resolve_tag accepts a READY pair tag in one field with the other empty
// (src/nmt/langpairs.cc:167-172), so "[->en-de]" names a pair for one request.
//
// The two rules together are what force the unbounded run. A regional code on
// its own is only two segments (pt-br, zh-cn, es-us) and would parse under a
// stricter pattern; it is the SINGLE-FIELD form of a regional pair that runs to
// three (en-zh-cn, en-zh-tw, en-es-us, en-pt-br, pt-br-en, zh-tw-en). And the
// failure is not a mis-split: a pattern too short to cover the tag does not
// match the directive at all, so the whole bracket survives into the text and
// is handed to the model as something to translate.
//
// The codes are not normalised or validated here. normalize_language_code
// lowercases and folds BCP-47 down to a supported base, and is_supported has the
// authoritative table; duplicating either would be a second source of truth that
// drifts on the next pin bump.
var pairDirective = regexp.MustCompile(`^\[\s*([a-zA-Z]{2}(?:-[a-zA-Z]{2})*)?\s*->\s*([a-zA-Z]{2}(?:-[a-zA-Z]{2})*)?\s*\]\s*`)
// translator is the NMT half of the C API, narrowed to what Predict uses.
//
// It is an interface for the same reason synthesizer and diarStream are: no
// Riva-Translate GGUF is small enough to keep in the tree, so the layer above
// the ABI (pair resolution, validation, the text array, the single-chunk stream)
// would otherwise have no test at all. A fake here scripts what the C API
// returns; it does not pretend to translate anything.
type translator interface {
// translate returns one translation per input text, in order.
translate(texts []string, source, target string) ([]string, error)
}
// cTranslator is the real translator, over one nemo_speech_nmt_translator.
type cTranslator struct {
handle uintptr
}
// nmtTexts builds the `const char* const* texts` argument and returns it with
// the release the caller MUST defer.
//
// Two levels need pinning, not one. cstr pins each string's bytes, but the array
// carrying their addresses is a separate Go allocation holding uintptrs: the
// collector neither traces through it nor is obliged to leave it where it is,
// and C dereferences it for the whole call. Pinning only the strings would leave
// the array itself free to move out from under the runtime.
//
// An empty element is refused rather than passed on. cstr maps "" to NULL and
// src/nmt/c_api.cpp maps a NULL element back to "" (str_or_empty), so a blank
// text would come back as a confident translation of nothing rather than an
// error.
func nmtTexts(texts []string) ([]uintptr, func(), error) {
pin := new(runtime.Pinner)
// The pin is released first so that the array stops being pinned before the
// strings it points at do.
frees := []func(){pin.Unpin}
release := func() {
for _, f := range frees {
f()
}
}
if len(texts) == 0 {
return nil, release, status.Error(codes.InvalidArgument,
"nemo-speech-cpp: nothing to translate")
}
ptrs := make([]uintptr, len(texts))
for i, t := range texts {
if t == "" {
return nil, release, status.Error(codes.InvalidArgument,
"nemo-speech-cpp: nothing to translate")
}
p, free := cstr(t)
frees = append(frees, free)
ptrs[i] = p
}
pin.Pin(&ptrs[0])
return ptrs, release, nil
}
func (t *cTranslator) translate(texts []string, source, target string) ([]string, error) {
ptrs, release, err := nmtTexts(texts)
if err != nil {
release()
return nil, err
}
defer release()
// source and target cross as Go strings: purego NUL-terminates and copies
// them itself for the duration of the call, and c_api.cpp deep-copies both
// into std::string before doing anything with them.
var result uintptr
if st := NMTTranslate(t.handle, &ptrs[0], uint64(len(ptrs)), source, target, &result); st != 0 {
// An unsupported language pair arrives here as INVALID_ARGUMENT
// (src/nmt/translator.cpp throws std::invalid_argument, which
// src/nmt/c_api.cpp's guard maps to it), which statusErrorf turns into
// the caller-facing code rather than Internal.
return nil, statusErrorf(st, "nemo-speech-cpp: translate: %s", NMTLastError())
}
defer NMTResultDestroy(result)
count := NMTResultCount(result)
out := make([]string, 0, count)
for i := uint64(0); i < count; i++ {
out = append(out, NMTResultText(result, i))
}
return out, nil
}
// nmtTranslatorConfig builds the create-time config.
//
// Extracted from loadNMT so its four adjacent pointer fields can be asserted
// against distinct sentinels. Backend, Model, Generation and Pool are all
// uintptr and all sit next to each other, so transposing two of them changes
// neither the struct's size nor any field's offset: the layout assertions in
// abi_test.go are blind to it, and what it produces at runtime is the backend
// config being read as the model config.
//
// Generation and Pool stay NULL, which nmt.h documents as "library defaults":
// max_new_tokens (256) and contexts (1) are create-time settings this backend
// has no option to fill them from, and PredictOptions carries no per-request
// equivalent that a create-time config could honour anyway.
//
// backend and model are pinned addresses, not Go pointers, and the caller owns
// the pins.
func nmtTranslatorConfig(backend, model uintptr) cNMTTranslatorConfig {
return cNMTTranslatorConfig{
Size: unsafe.Sizeof(cNMTTranslatorConfig{}),
Backend: backend,
Model: model,
}
}
// loadNMT creates the Riva-Translate translator.
//
// This must not take engineMu: Load is its only caller and already holds it.
func (n *NemoSpeech) loadNMT(modelFile string) error {
// nemo_speech_nmt_create deep-copies the path into a std::string
// (src/nmt/c_api.cpp to_config, via str_or_empty) and retains no pointer
// afterwards, so pinning for the duration of the create call is both
// necessary and sufficient.
var pinner runtime.Pinner
defer pinner.Unpin()
pathP, freePath := cstr(modelFile)
defer freePath()
// NCtx is left at 0, which to_config reads as "keep the default" (it applies
// the field only when > 0) and which the runtime resolves to 1024 tokens.
// That is sized for the sentence-length input Riva-Translate is built for,
// and raising it costs one n_ctx-sized KV cache per pooled context, so it
// wants a deliberate option rather than a guess made here.
model := cNMTModelConfig{Size: unsafe.Sizeof(cNMTModelConfig{}), Path: pathP}
// BackendConfig.gpu defaults to 0 in C++ (device 0), not to CPU, and
// to_config assigns it unconditionally, so the option's own -1 default is
// what keeps an unconfigured model on the CPU.
backend := cNMTBackendConfig{Size: unsafe.Sizeof(cNMTBackendConfig{}), GPU: n.opts.gpu}
cfg := nmtTranslatorConfig(pinPtr(&pinner, &backend), pinPtr(&pinner, &model))
xlog.Info("nemo-speech-cpp: creating translator",
"gpu", n.opts.gpu,
"source_language", n.opts.sourceLanguage,
"target_language", n.opts.targetLanguage)
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Its
// Backend and Model members are pinPtr addresses held by the pinner unpinned
// on return, Model.Path is the cstr allocation freed by the defer above, and
// nemo_speech_nmt_create deep-copies everything it reads.
if st := NMTCreate(unsafe.Pointer(&cfg), &n.nmt); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: nmt create: %s", NMTLastError())
}
return nil
}
// languagePair resolves the languages for one request and returns the text to
// translate.
//
// nemo_speech_nmt_translate takes explicit source and target languages and has
// no free-form generation entry point at all, so there is no prompt in the LLM
// sense to carry an instruction. The pair therefore comes from the model
// options, and a leading "[src->tgt]" directive is the only per-request control
// Predict can offer.
func (n *NemoSpeech) languagePair(prompt string) (source, target, text string) {
source, target = n.opts.sourceLanguage, n.opts.targetLanguage
m := pairDirective.FindStringSubmatch(prompt)
if m == nil {
return source, target, strings.TrimSpace(prompt)
}
// An omitted side keeps the model-level default rather than blanking it.
if m[1] != "" {
source = m[1]
}
if m[2] != "" {
target = m[2]
}
// The directive must not survive into the text: the runtime wraps it in a
// chat template (src/nmt/langpairs.cc build_prompt), so anything left here is
// translated along with the sentence.
return source, target, strings.TrimSpace(prompt[len(m[0]):])
}
// unsupportedPredictFields names the PredictOptions fields a caller may have set
// that this C API has no way to honour, so they are logged rather than silently
// dropped.
//
// The list is deliberately narrow. Everything nemo_speech_nmt_translate accepts
// is in its five arguments: a translator, the texts, and two language codes.
// Everything else in PredictOptions is therefore unsupported, and naming all of
// it would log on every single request, because LocalAI fills the sampling
// defaults in from the model config whether or not the user asked for them.
//
// So the sampling and decoding knobs (temperature, top_p, top_k, min_p, seed,
// tokens, repeat/frequency/presence penalties, mirostat, tfz, typical_p,
// stop_prompts, prompt caching, rope scaling, n_draft, logit_bias) are ignored
// silently: there is no field for any of them on either side of the ABI.
// max_new_tokens and n_ctx exist but are CREATE-time settings on the translator,
// not per-request ones, so PredictOptions.Tokens has nowhere to go either.
//
// What is named here is the structural asks: requests that only make sense
// against a general language model, where honouring them partially would be
// worse than saying nothing at all.
func unsupportedPredictFields(opts *pb.PredictOptions) []string {
var out []string
if opts.GetGrammar() != "" {
out = append(out, "grammar")
}
if opts.GetTools() != "" {
out = append(out, "tools")
}
if len(opts.GetImages()) > 0 {
out = append(out, "images")
}
if len(opts.GetVideos()) > 0 {
out = append(out, "videos")
}
if len(opts.GetAudios()) > 0 {
out = append(out, "audios")
}
if opts.GetNegativePrompt() != "" {
out = append(out, "negative_prompt")
}
if opts.GetLogprobs() > 0 {
out = append(out, "logprobs")
}
return out
}
// translateText runs one translation and returns it.
//
// The two rejections happen before anything crosses the ABI. An empty text would
// otherwise reach the runtime as a NULL element (see nmtTexts), and a missing
// target would come back as "unsupported language pair: -> ", which names
// neither the option the operator has to set nor the request that failed.
func translateText(t translator, source, target, text string) (string, error) {
if text == "" {
return "", status.Error(codes.InvalidArgument,
"nemo-speech-cpp: PredictOptions.prompt is required, it is the text to translate")
}
if target == "" {
return "", status.Error(codes.InvalidArgument,
"nemo-speech-cpp: no target language: set the target_language model option, "+
"or prefix the prompt with a [src->tgt] directive")
}
out, err := t.translate([]string{text}, source, target)
if err != nil {
return "", err
}
// One text in, one translation out. A call that returned OK with none is a
// runtime bug, and the empty string it would hand back reaches the user as a
// successful but blank completion with nothing anywhere to say why.
if len(out) == 0 {
return "", status.Error(codes.Internal, "nemo-speech-cpp: translation produced no result")
}
return out[0], nil
}
// streamTranslation runs one translation and puts the whole of it on out as a
// single chunk.
//
// That is a limit of the C API and not a shortcut taken here.
// nemo_speech_nmt_translate has no token callback and no incremental result: it
// returns once the decode has finished, with the completed text. There is
// nothing finer to stream, and splitting the finished string into fake chunks
// would imitate progress that never happened.
//
// out is not closed here. PredictStream owns it, and closing it in one of two
// places depending on how far the request got is how a stream ends up
// half-closed.
func streamTranslation(t translator, source, target, text string, out chan<- string) error {
translated, err := translateText(t, source, target, text)
if err != nil {
return err
}
out <- translated
return nil
}
// resolveRequest is the shared front half of both RPCs: it names what it is
// dropping and works out the pair and the text.
func (n *NemoSpeech) resolveRequest(opts *pb.PredictOptions) (source, target, text string) {
// Logged rather than rejected, for the reason the diarization path logs its
// own dropped fields: a caller that asked for something extra still wants the
// translation it can have, and a request naming a field this backend drops
// should say so where an operator can find it.
if dropped := unsupportedPredictFields(opts); len(dropped) > 0 {
xlog.Warn("nemo-speech-cpp: ignoring request fields this model has no equivalent for",
"fields", dropped)
}
return n.languagePair(opts.GetPrompt())
}
// Predict translates PredictOptions.Prompt.
//
// The whole body runs inside withEngine, so the family check and the C calls
// that trust the handle happen under a single acquisition of engineMu. See the
// handoff notes at the bottom of nemospeech.go: Free runs without the backend
// lock, so anything that checks the family and then releases the lock before
// calling C can have the handle destroyed underneath it.
func (n *NemoSpeech) Predict(opts *pb.PredictOptions) (string, error) {
var out string
if err := n.withEngine(familyNMT, func() error {
source, target, text := n.resolveRequest(opts)
s, err := translateText(&cTranslator{handle: n.nmt}, source, target, text)
out = s
return err
}); err != nil {
return "", err
}
return out, nil
}
// PredictStream translates PredictOptions.Prompt and emits the result on
// results.
//
// results is closed on EVERY path, including the family rejection and a
// validation failure, and the close is deferred outside withEngine so that a
// rejected family still closes it. This is the LEGACY streaming contract, which
// is the opposite of PredictStreamRich's: pkg/grpc/server.go:529 calls this and
// then blocks on a drain goroutine that only finishes when the channel closes,
// so a channel left open does not fail the request, it hangs the RPC and, with
// the backend lock still held, every request queued behind it. The rich variant
// is the one whose channel the host closes; this one is not.
func (n *NemoSpeech) PredictStream(opts *pb.PredictOptions, results chan string) error {
defer close(results)
return n.withEngine(familyNMT, func() error {
source, target, text := n.resolveRequest(opts)
return streamTranslation(&cTranslator{handle: n.nmt}, source, target, text, results)
})
}
+416
View File
@@ -0,0 +1,416 @@
package main
import (
"errors"
"unsafe"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// fakeTranslator scripts the C API's answer and records what it was asked, so
// the layer above the ABI (pair resolution, validation, the single-element text
// array) has a test at all. No Riva-Translate GGUF is small enough to keep in
// the tree, and this pretends to translate nothing.
type fakeTranslator struct {
texts []string
source, target string
calls int
out []string
err error
}
func (f *fakeTranslator) translate(texts []string, source, target string) ([]string, error) {
f.calls++
f.texts = texts
f.source = source
f.target = target
return f.out, f.err
}
// collectStrings drains ch until it closes and hands back everything it saw.
// The host does the same, so a channel this backend forgets to close hangs the
// RPC rather than failing it.
func collectStrings(ch chan string) chan []string {
done := make(chan []string, 1)
go func() {
var got []string
for s := range ch {
got = append(got, s)
}
done <- got
}()
return done
}
var _ = Describe("languagePair", func() {
It("uses the configured pair and returns the prompt unchanged", func() {
n := &NemoSpeech{opts: loadOptions{sourceLanguage: "en", targetLanguage: "de"}}
src, tgt, text := n.languagePair("hello world")
Expect(src).To(Equal("en"))
Expect(tgt).To(Equal("de"))
Expect(text).To(Equal("hello world"))
})
// nemo_speech_nmt_translate takes explicit languages and has no prompt path,
// so an inline directive is the only way a caller can pick a pair per request.
It("honours an inline pair directive and strips it from the text", func() {
n := &NemoSpeech{opts: loadOptions{sourceLanguage: "en", targetLanguage: "de"}}
src, tgt, text := n.languagePair("[en->fr] hello world")
Expect(src).To(Equal("en"))
Expect(tgt).To(Equal("fr"))
Expect(text).To(Equal("hello world"))
})
// The directive has to be gone from what reaches the model: the runtime
// wraps the text in a chat template (src/nmt/langpairs.cc build_prompt), so
// a leftover "[en->fr]" would be translated along with the sentence.
It("leaves no trace of the directive in the translated text", func() {
n := &NemoSpeech{opts: loadOptions{targetLanguage: "de"}}
_, _, text := n.languagePair("[en->fr] hello world")
Expect(text).ToNot(ContainSubstring("["))
Expect(text).ToNot(ContainSubstring("->"))
Expect(text).ToNot(ContainSubstring("fr"))
Expect(text).To(Equal("hello world"))
})
It("leaves an unparseable directive in the text", func() {
n := &NemoSpeech{opts: loadOptions{sourceLanguage: "en", targetLanguage: "de"}}
src, tgt, text := n.languagePair("[not a directive] hi")
Expect(src).To(Equal("en"))
Expect(tgt).To(Equal("de"))
Expect(text).To(Equal("[not a directive] hi"))
})
It("trims surrounding whitespace from the text", func() {
n := &NemoSpeech{opts: loadOptions{sourceLanguage: "en", targetLanguage: "de"}}
_, _, text := n.languagePair(" hello ")
Expect(text).To(Equal("hello"))
})
// The model's own tags carry region subtags (src/nmt/langpairs.cc: en-zh-cn,
// pt-br, es-us), so a directive that only accepted bare two-letter codes
// could not name half the pairs the runtime supports.
It("accepts a regional code on either side", func() {
n := &NemoSpeech{}
src, tgt, text := n.languagePair("[pt-br->en] ola")
Expect(src).To(Equal("pt-br"))
Expect(tgt).To(Equal("en"))
Expect(text).To(Equal("ola"))
src, tgt, _ = n.languagePair("[en->zh-cn] hi")
Expect(src).To(Equal("en"))
Expect(tgt).To(Equal("zh-cn"))
})
// resolve_tag accepts a ready pair tag in one field with the other empty, so
// a directive that names only one side must keep the configured value for the
// other rather than blanking it.
It("keeps the configured code for a side the directive omits", func() {
n := &NemoSpeech{opts: loadOptions{sourceLanguage: "en", targetLanguage: "de"}}
src, tgt, text := n.languagePair("[->fr] hello")
Expect(src).To(Equal("en"))
Expect(tgt).To(Equal("fr"))
Expect(text).To(Equal("hello"))
src, tgt, _ = n.languagePair("[fr->] hello")
Expect(src).To(Equal("fr"))
Expect(tgt).To(Equal("de"))
})
// resolve_tag (src/nmt/langpairs.cc:167-172) accepts a READY pair tag in one
// field with the other empty, and the model's own tags run to three segments
// (en-zh-cn, en-zh-tw, en-es-us, en-pt-br). That single-field three-segment
// form is the case a two-segment pattern cannot express: it does not merely
// mis-split the tag, it fails to match the directive at all, so the whole
// bracket survives into the text and is handed to the model as something to
// translate.
//
// Two-segment codes like pt-br and zh-cn are NOT this case; they parse either
// way.
It("accepts a three-segment pair tag given in one side of the directive", func() {
n := &NemoSpeech{opts: loadOptions{targetLanguage: "de"}}
src, tgt, text := n.languagePair("[->en-zh-cn] hi")
Expect(src).To(BeEmpty())
Expect(tgt).To(Equal("en-zh-cn"))
Expect(text).To(Equal("hi"))
src, tgt, text = n.languagePair("[pt-br-en->] hola")
Expect(src).To(Equal("pt-br-en"))
Expect(tgt).To(Equal("de"))
Expect(text).To(Equal("hola"))
})
It("does not treat a bracketed sentence as a directive", func() {
n := &NemoSpeech{opts: loadOptions{targetLanguage: "de"}}
_, _, text := n.languagePair("[see figure 1] the cat sat")
Expect(text).To(Equal("[see figure 1] the cat sat"))
})
})
var _ = Describe("nmtTranslatorConfig", func() {
// Backend, Model, Generation and Pool are four adjacent same-typed pointers.
// Transposing two of them changes neither the struct's size nor any field's
// offset, so the layout assertions in abi_test.go cannot see it, and the
// failure it produces is the runtime reading the backend config as the model
// config. Distinct sentinels are the only thing that catches it.
It("wires each pointer into its own field", func() {
cfg := nmtTranslatorConfig(0xB, 0xD)
Expect(cfg.Backend).To(Equal(uintptr(0xB)))
Expect(cfg.Model).To(Equal(uintptr(0xD)))
})
// NULL is what nmt.h documents as "library defaults" for a subsystem config,
// and this backend has no option to fill either of them from.
It("leaves the generation and pool configs null", func() {
cfg := nmtTranslatorConfig(0xB, 0xD)
Expect(cfg.Generation).To(BeZero())
Expect(cfg.Pool).To(BeZero())
})
// The runtime decides a field is present with HAS_FIELD, which tests the
// caller's size against offsetof + sizeof (src/nmt/c_api.cpp), so a config
// sent with Size 0 has every field ignored and the model loads from a path
// it was never given.
It("declares its own size", func() {
Expect(nmtTranslatorConfig(0xB, 0xD).Size).To(Equal(unsafe.Sizeof(cNMTTranslatorConfig{})))
})
})
var _ = Describe("nmtTexts", func() {
It("produces one non-null pointer per text", func() {
ptrs, release, err := nmtTexts([]string{"one", "two", "three"})
Expect(err).ToNot(HaveOccurred())
defer release()
Expect(ptrs).To(HaveLen(3))
for i, p := range ptrs {
Expect(p).ToNot(BeZero(), "texts[%d] must not be NULL", i)
}
// Distinct addresses: one buffer reused for every element would make the
// runtime translate the last text three times.
Expect(ptrs[0]).ToNot(Equal(ptrs[1]))
Expect(ptrs[1]).ToNot(Equal(ptrs[2]))
})
// cstr maps "" to NULL and src/nmt/c_api.cpp maps a NULL element back to "",
// so a blank text would be answered with a translation of nothing instead of
// an error.
It("refuses an empty element", func() {
_, release, err := nmtTexts([]string{"one", ""})
Expect(release).ToNot(BeNil())
release()
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
It("refuses an empty batch", func() {
_, release, err := nmtTexts(nil)
Expect(release).ToNot(BeNil())
release()
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
It("survives releasing more than once", func() {
_, release, err := nmtTexts([]string{"once"})
Expect(err).ToNot(HaveOccurred())
release()
Expect(release).ToNot(Panic())
})
})
var _ = Describe("translateText", func() {
It("passes the resolved pair and the text through to the runtime", func() {
f := &fakeTranslator{out: []string{"hallo welt"}}
got, err := translateText(f, "en", "de", "hello world")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("hallo welt"))
Expect(f.texts).To(Equal([]string{"hello world"}))
Expect(f.source).To(Equal("en"))
Expect(f.target).To(Equal("de"))
})
// A single-pair model is configured with target_language alone, and
// resolve_tag accepts a ready tag in one field with the other empty.
It("allows an empty source language", func() {
f := &fakeTranslator{out: []string{"ciao"}}
_, err := translateText(f, "", "en-it", "hi")
Expect(err).ToNot(HaveOccurred())
Expect(f.source).To(BeEmpty())
})
It("rejects a missing target language and names the option to set", func() {
f := &fakeTranslator{}
_, err := translateText(f, "en", "", "hello")
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("target_language"))
Expect(f.calls).To(BeZero())
})
It("rejects an empty text without calling the runtime", func() {
f := &fakeTranslator{}
_, err := translateText(f, "en", "de", "")
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(f.calls).To(BeZero())
})
It("propagates a runtime failure", func() {
boom := errors.New("boom")
_, err := translateText(&fakeTranslator{err: boom}, "en", "de", "hello")
Expect(err).To(MatchError(boom))
})
// A call that returned OK with no translations is a runtime bug, and the
// empty string it would hand back reaches the user as a successful but blank
// completion with nothing anywhere to say why.
It("refuses a result that carries no translation", func() {
_, err := translateText(&fakeTranslator{}, "en", "de", "hello")
Expect(status.Code(err)).To(Equal(codes.Internal))
})
It("takes the first translation when the runtime returns several", func() {
f := &fakeTranslator{out: []string{"first", "second"}}
got, err := translateText(f, "en", "de", "hello")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("first"))
})
})
var _ = Describe("Predict", func() {
It("refuses a model loaded as another family", func() {
n := &NemoSpeech{fam: familyASR}
out, err := n.Predict(&pb.PredictOptions{Prompt: "hello"})
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(out).To(BeEmpty())
// A lock leaked on the rejection path deadlocks the next request rather
// than failing it.
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
It("refuses an unloaded model", func() {
n := &NemoSpeech{}
_, err := n.Predict(&pb.PredictOptions{Prompt: "hello"})
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
})
// The validation has to happen before anything crosses the ABI: nothing is
// loaded here, so a guard placed after the C call would panic on a nil
// function value instead of failing the request.
It("rejects an empty prompt before it reaches the runtime", func() {
n := &NemoSpeech{fam: familyNMT, opts: loadOptions{targetLanguage: "de"}}
var err error
Expect(func() {
_, err = n.Predict(&pb.PredictOptions{})
}).ToNot(Panic())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
It("rejects a request with no target language, before it reaches the runtime", func() {
n := &NemoSpeech{fam: familyNMT}
var err error
Expect(func() {
_, err = n.Predict(&pb.PredictOptions{Prompt: "hello"})
}).ToNot(Panic())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("target_language"))
})
})
var _ = Describe("PredictStream", func() {
// pkg/grpc/server.go drains this channel from a goroutine and then blocks on
// that goroutine finishing, so a channel left open does not fail the request,
// it hangs the RPC and every request queued behind the backend lock.
It("closes the channel when the family does not match", func() {
n := &NemoSpeech{fam: familyTTS}
ch := make(chan string)
done := collectStrings(ch)
err := n.PredictStream(&pb.PredictOptions{Prompt: "hello"}, ch)
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(<-done).To(BeEmpty())
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
It("closes the channel when the request is rejected", func() {
n := &NemoSpeech{fam: familyNMT}
ch := make(chan string)
done := collectStrings(ch)
err := n.PredictStream(&pb.PredictOptions{Prompt: "hello"}, ch)
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(<-done).To(BeEmpty())
})
It("closes the channel on an unloaded model", func() {
n := &NemoSpeech{}
ch := make(chan string)
done := collectStrings(ch)
Expect(n.PredictStream(&pb.PredictOptions{Prompt: "hi"}, ch)).ToNot(Succeed())
Expect(<-done).To(BeEmpty())
})
// The C API has no token callback, so the whole translation is one chunk.
// The seam is the only place that can be asserted without a model.
It("emits the whole translation as a single chunk", func() {
f := &fakeTranslator{out: []string{"hallo welt"}}
ch := make(chan string)
done := collectStrings(ch)
Expect(streamTranslation(f, "en", "de", "hello world", ch)).To(Succeed())
close(ch)
Expect(<-done).To(Equal([]string{"hallo welt"}))
})
It("emits nothing when the translation fails", func() {
f := &fakeTranslator{err: errors.New("boom")}
ch := make(chan string)
done := collectStrings(ch)
Expect(streamTranslation(f, "en", "de", "hello", ch)).ToNot(Succeed())
close(ch)
Expect(<-done).To(BeEmpty())
})
})
var _ = Describe("unsupportedPredictFields", func() {
It("names nothing for a plain translation request", func() {
Expect(unsupportedPredictFields(&pb.PredictOptions{Prompt: "hello"})).To(BeEmpty())
})
// The sampling knobs are deliberately absent from this list: LocalAI fills
// them in from the model config on every request, so warning about them
// would log on every translation and say nothing.
It("stays quiet about sampling parameters the runtime has no field for", func() {
Expect(unsupportedPredictFields(&pb.PredictOptions{
Prompt: "hello",
Temperature: 0.7,
TopP: 0.9,
TopK: 40,
Seed: 42,
Tokens: 256,
})).To(BeEmpty())
})
It("names the asks the C API cannot serve at all", func() {
got := unsupportedPredictFields(&pb.PredictOptions{
Prompt: "hello",
Grammar: "root ::= x",
Tools: `[{"type":"function"}]`,
Images: []string{"a.png"},
Videos: []string{"a.mp4"},
Audios: []string{"a.wav"},
NegativePrompt: "no",
Logprobs: 3,
})
Expect(got).To(ConsistOf("grammar", "tools", "images", "videos", "audios",
"negative_prompt", "logprobs"))
})
})
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"path/filepath"
"strconv"
"strings"
"github.com/mudler/xlog"
)
// loadOptions holds the parsed model-level options. Path fields are resolved
// against ModelOptions.ModelPath at parse time so every consumer sees an
// absolute path.
type loadOptions struct {
// ASR
vadModel string
pncModel string
diarModel string
itnDir string
languageCode string
// TTS
codecModel string
tokenizerDir string
tnDir string
// NMT
sourceLanguage string
targetLanguage string
// gpu is the device index passed to the runtime's backend config.
// -1 selects CPU, matching the C API's own sentinel.
gpu int32
}
// splitOption splits on the FIRST colon so values may themselves contain one.
func splitOption(o string) (key, value string, ok bool) {
i := strings.Index(o, ":")
if i < 0 {
return "", "", false
}
return strings.TrimSpace(o[:i]), strings.TrimSpace(o[i+1:]), true
}
// resolve makes a relative asset path absolute against the models directory.
// Empty stays empty so callers can test for "unset".
func resolve(base, p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(base, p)
}
// parseOptions reads the backend "key:value" option slice. Unknown keys are
// ignored rather than rejected, so a config written for a newer backend still
// loads on an older one.
func parseOptions(opts []string, modelPath string) loadOptions {
o := loadOptions{gpu: -1}
for _, oo := range opts {
key, value, ok := splitOption(oo)
if !ok {
continue
}
switch key {
case "vad_model":
o.vadModel = resolve(modelPath, value)
case "pnc_model":
o.pncModel = resolve(modelPath, value)
case "diar_model":
o.diarModel = resolve(modelPath, value)
case "itn_dir":
o.itnDir = resolve(modelPath, value)
case "language_code":
o.languageCode = value
case "codec_model":
o.codecModel = resolve(modelPath, value)
case "tokenizer_dir":
o.tokenizerDir = resolve(modelPath, value)
case "tn_dir":
o.tnDir = resolve(modelPath, value)
case "source_language":
o.sourceLanguage = value
case "target_language":
o.targetLanguage = value
case "gpu":
// An unknown key is ignored for forward compatibility, but a known key
// with an unparseable value is a typo, and this one fails expensively:
// the model still loads and still produces correct output, just on CPU
// and far slower, with nothing anywhere to say why.
n, err := strconv.ParseInt(value, 10, 32)
if err != nil {
xlog.Warn("nemo-speech-cpp: ignoring unparseable option value, falling back to CPU",
"key", key, "value", value)
continue
}
o.gpu = int32(n)
}
}
return o
}
@@ -0,0 +1,70 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parseOptions", func() {
It("parses every known key", func() {
o := parseOptions([]string{
"vad_model:silero.gguf",
"pnc_model:pnc.gguf",
"diar_model:sortformer.gguf",
"itn_dir:tn_configs",
"language_code:es-ES",
"codec_model:nanocodec.gguf",
"tokenizer_dir:extracted",
"tn_dir:tn",
"source_language:en",
"target_language:de",
}, "/models")
Expect(o.vadModel).To(Equal("/models/silero.gguf"))
Expect(o.pncModel).To(Equal("/models/pnc.gguf"))
Expect(o.diarModel).To(Equal("/models/sortformer.gguf"))
Expect(o.itnDir).To(Equal("/models/tn_configs"))
Expect(o.languageCode).To(Equal("es-ES"))
Expect(o.codecModel).To(Equal("/models/nanocodec.gguf"))
Expect(o.tokenizerDir).To(Equal("/models/extracted"))
Expect(o.tnDir).To(Equal("/models/tn"))
Expect(o.sourceLanguage).To(Equal("en"))
Expect(o.targetLanguage).To(Equal("de"))
})
It("leaves absolute paths untouched", func() {
o := parseOptions([]string{"vad_model:/abs/silero.gguf"}, "/models")
Expect(o.vadModel).To(Equal("/abs/silero.gguf"))
})
It("ignores unknown keys and entries without a separator", func() {
o := parseOptions([]string{"nonsense", "unknown_key:value"}, "/models")
Expect(o).To(Equal(loadOptions{gpu: -1}))
})
It("trims whitespace around keys and values", func() {
o := parseOptions([]string{" language_code : en-US "}, "/models")
Expect(o.languageCode).To(Equal("en-US"))
})
It("keeps a value containing a colon intact", func() {
// URIs must survive the split on the FIRST colon.
o := parseOptions([]string{"tokenizer_dir:/a/b:c"}, "/models")
Expect(o.tokenizerDir).To(Equal("/a/b:c"))
})
It("leaves an empty value empty so callers can detect unset", func() {
o := parseOptions([]string{"vad_model:"}, "/models")
Expect(o.vadModel).To(BeEmpty())
})
It("defaults gpu to -1 meaning CPU", func() {
o := parseOptions(nil, "/models")
Expect(o.gpu).To(Equal(int32(-1)))
})
It("parses an explicit gpu index", func() {
o := parseOptions([]string{"gpu:0"}, "/models")
Expect(o.gpu).To(Equal(int32(0)))
})
})
+161
View File
@@ -0,0 +1,161 @@
#!/bin/bash
#
# Bundle the nemo-speech-cpp-grpc binary, the five nemo_speech shared objects,
# the text-normalization stack on a WITH_NORM build, the core runtime libs
# (libc/libstdc++/libgomp + ld.so) and the GPU runtime for the active BUILD_TYPE
# so the package is self-contained. Mirrors backend/go/whisper/package.sh;
# run.sh routes the (CGO_ENABLED=0) binary through lib/ld.so so the packaged
# libc is used instead of the host's.
#
# Five, not three: ASR and NMT each ship a thin _c ABI shim plus the
# implementation DSO it depends on, while TTS ships one object with no _c
# suffix at all.
set -e
CURDIR=$(dirname "$(realpath "$0")")
REPO_ROOT="${CURDIR}/../../.."
mkdir -p "$CURDIR/package/lib"
cp -avf "$CURDIR/nemo-speech-cpp-grpc" "$CURDIR/package/"
cp -avf "$CURDIR/run.sh" "$CURDIR/package/"
# The runtime ships three C ABI shared objects, not one. All three are
# required: main.go dlopens them eagerly, so a package missing any of them
# fails at startup. ASR and NMT expose the ABI through a dedicated _c library;
# TTS compiles its c_api into libnemo_speech_tts itself and has no _c variant,
# hence the asymmetric list. purego.Dlopen resolves them via the
# NEMO_SPEECH_*_LIBRARY paths that run.sh points at lib/.
#
# libnemo_speech_asr and libnemo_speech_nmt are in the list because the matching
# _c shims carry a DT_NEEDED on them: dlopen of the shim fails without the
# implementation DSO alongside it.
for lib in libnemo_speech_asr_c libnemo_speech_asr libnemo_speech_tts libnemo_speech_nmt_c libnemo_speech_nmt; do
cp -avf "$CURDIR"/${lib}.so* "$CURDIR/package/lib/" 2>/dev/null || true
cp -avf "$CURDIR"/${lib}*.dylib "$CURDIR/package/lib/" 2>/dev/null || true
if ! ls "$CURDIR"/package/lib/${lib}.* >/dev/null 2>&1; then
echo "ERROR: ${lib} shared library not found in $CURDIR, run 'make' first" >&2
exit 1
fi
done
# Text normalization (WITH_NORM=ON, Linux only) links Sparrowhawk and OpenFST
# into libnemo_speech_asr.so. Those live in a project-local prefix that the
# Makefile stages here, so anything staged that is not a nemo_speech object is
# part of that stack. Absent on a WITH_NORM=OFF build, which is why this is a
# glob that tolerates no matches rather than a required list.
shopt -s nullglob
for so in "$CURDIR"/*.so "$CURDIR"/*.so.* "$CURDIR"/*.dylib; do
case "$(basename "$so")" in
libnemo_speech_*) continue ;;
esac
cp -avf "$so" "$CURDIR/package/lib/"
done
shopt -u nullglob
# Detect architecture and copy the core runtime libs the shared objects link
# against, plus the matching dynamic loader as lib/ld.so.
source "$CURDIR/../../../scripts/build/package-system-libs.sh" "$CURDIR/package/lib" ""
# Dependency-closure guard.
#
# The lists above are maintained by hand, and the WITH_NORM build in particular
# pulls in transitive dependencies nobody enumerated: Sparrowhawk drags in
# protobuf, re2 and absl, none of which package-system-libs.sh provides. Rather
# than hard-code that set, walk the DT_NEEDED entries of everything staged and
# copy whatever is still unresolved. On a WITH_NORM=OFF build the closure is
# already complete, so this copies nothing.
#
# Skipped deliberately: the core runtime set that package-system-libs.sh owns,
# and the GPU stack that package-gpu-libs.sh owns.
shopt -s nullglob
staged_libs=("$CURDIR"/package/lib/*.so*)
shopt -u nullglob
if [ "$(uname)" != "Darwin" ] && [ "${#staged_libs[@]}" -gt 0 ]; then
# No silent skip. If the closure cannot be checked, the package cannot be
# shown to be complete, and shipping an unverified one is the failure this
# guard exists to prevent.
if command -v readelf >/dev/null 2>&1; then
read_needed() { readelf -d "$1" 2>/dev/null | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p'; }
elif command -v objdump >/dev/null 2>&1; then
read_needed() { objdump -p "$1" 2>/dev/null | awk '$1 == "NEEDED" { print $2 }'; }
else
echo "ERROR: neither readelf nor objdump is available, so the dependency" >&2
echo " closure of ${#staged_libs[@]} staged libraries cannot be verified." >&2
echo " Install binutils in the build image; refusing to ship an" >&2
echo " unverified package." >&2
exit 1
fi
is_provided() {
case "$1" in
ld-linux*|libc.so.6|libstdc++.so.6|libgcc_s.so.1|libm.so.6|libgomp.so.1) return 0 ;;
libdl.so.2|librt.so.1|libpthread.so.0) return 0 ;;
libcuda*|libcudart*|libcublas*|libcublasLt*|libnvrtc*|libnvidia*) return 0 ;;
libamdhip*|libhsa*|librocm*|libze_*|libOpenCL*|libvulkan*) return 0 ;;
esac
[ -e "$CURDIR/package/lib/$1" ]
}
# Walk until the staged set stops growing. The glob below expands once per
# pass, so each pass advances the closure by exactly one dependency level;
# a copied library can itself pull in new dependencies.
#
# CLOSURE_MAX_PASSES is a runaway guard, not a depth limit. Exhausting it
# means the walk never converged and the package is therefore incomplete,
# which has to fail the build: a fixed pass count that just falls out of the
# loop would silently ship a package missing its deepest libraries, and
# libnemo_speech_asr -> sparrowhawk -> protobuf -> absl already runs several
# levels deep.
CLOSURE_MAX_PASSES="${CLOSURE_MAX_PASSES:-64}"
converged=0
for (( pass=1; pass<=CLOSURE_MAX_PASSES; pass++ )); do
missing=0
for so in "$CURDIR"/package/lib/*.so*; do
[ -f "$so" ] || continue
for need in $(read_needed "$so"); do
# Written as an if rather than "is_provided && continue" so a
# false return cannot trip set -e via the AND-list exit status.
if is_provided "$need"; then
continue
fi
# Resolve against the staging dir first, then the system loader.
src="$(LD_LIBRARY_PATH="$CURDIR:$CURDIR/package/lib:${LD_LIBRARY_PATH:-}" \
ldd "$so" 2>/dev/null | awk -v n="$need" '$1 == n { print $3 }' | head -1)"
if [ -z "$src" ] || [ ! -e "$src" ]; then
echo "ERROR: $(basename "$so") needs $need and it could not be resolved." >&2
echo " The packaged backend would fail to dlopen at runtime." >&2
exit 1
fi
cp -aLvf "$src" "$CURDIR/package/lib/$need"
missing=1
done
done
if [ "$missing" -eq 0 ]; then
converged=1
break
fi
done
if [ "$converged" -ne 1 ]; then
echo "ERROR: the dependency closure was still growing after" >&2
echo " $CLOSURE_MAX_PASSES passes, so the package is incomplete and" >&2
echo " would fail to dlopen at runtime. Refusing to ship it." >&2
exit 1
fi
fi
# Package GPU libraries (CUDA/ROCm/Intel/Vulkan loader + ICDs + drivers)
# based on BUILD_TYPE so the backend can reach the GPU without the runtime
# base image shipping those drivers.
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah "$CURDIR/package/" "$CURDIR/package/lib/"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -e
CURDIR=$(dirname "$(realpath "$0")")
# The runtime splits its C ABI across three shared objects, so each gets its
# own override variable. main.go reads exactly these names.
if [ "$(uname)" = "Darwin" ]; then
export DYLD_LIBRARY_PATH="$CURDIR/lib:"$CURDIR":${DYLD_LIBRARY_PATH:-}"
export NEMO_SPEECH_ASR_LIBRARY="$CURDIR/lib/libnemo_speech_asr_c.dylib"
export NEMO_SPEECH_TTS_LIBRARY="$CURDIR/lib/libnemo_speech_tts.dylib"
export NEMO_SPEECH_NMT_LIBRARY="$CURDIR/lib/libnemo_speech_nmt_c.dylib"
else
export LD_LIBRARY_PATH="$CURDIR/lib:"$CURDIR":${LD_LIBRARY_PATH:-}"
export NEMO_SPEECH_ASR_LIBRARY="$CURDIR/lib/libnemo_speech_asr_c.so"
export NEMO_SPEECH_TTS_LIBRARY="$CURDIR/lib/libnemo_speech_tts.so"
export NEMO_SPEECH_NMT_LIBRARY="$CURDIR/lib/libnemo_speech_nmt_c.so"
fi
# If a self-contained ld.so was packaged, route through it so the
# packaged libc / libstdc++ are used instead of the host's (matches the
# whisper backend's runtime layout). Linux only.
if [ -f "$CURDIR/lib/ld.so" ]; then
echo "Using lib/ld.so"
exec "$CURDIR/lib/ld.so" "$CURDIR/nemo-speech-cpp-grpc" "$@"
fi
exec "$CURDIR/nemo-speech-cpp-grpc" "$@"
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// The status values every C entry point in this backend returns.
//
// There is no single C enum to mirror. asr.h:43-49, tts.h:38-44 and nmt.h:40-45
// each declare their own, and diar.h has none of its own at all: it includes
// asr.h and types every diarization function as nemo_speech_asr_status
// (diar.h:18). The names the three surfaces share carry the same numbers:
//
// value asr.h tts.h nmt.h
// 0 NEMO_SPEECH_ASR_OK NEMO_SPEECH_TTS_OK NEMO_SPEECH_NMT_OK
// 1 NEMO_SPEECH_ASR_ERROR_INVALID_... NEMO_SPEECH_TTS_ERROR_INVALID_... NEMO_SPEECH_NMT_ERROR_INVALID_...
// 2 NEMO_SPEECH_ASR_ERROR_OUT_OF_MEM. NEMO_SPEECH_TTS_ERROR_OUT_OF_MEM. NEMO_SPEECH_NMT_ERROR_OUT_OF_MEM.
// 3 NEMO_SPEECH_ASR_ERROR_RUNTIME NEMO_SPEECH_TTS_ERROR_RUNTIME NEMO_SPEECH_NMT_ERROR_RUNTIME
// 4 NEMO_SPEECH_ASR_ERROR_CANCELLED NEMO_SPEECH_TTS_ERROR_CANCELLED (not declared)
//
// The one divergence is 4, and it is an absence rather than a disagreement. ASR
// and TTS both drive a consumer callback that can ask for the work to stop, and
// cancellation is what they report when it does; nemo_speech_nmt_translate takes
// no callback and returns only when the decode has finished, so the NMT surface
// has no cancellation to name. That is why one table can serve all three: 4 is
// not some other NMT status that would be mislabelled, it is a value the NMT
// surface never produces.
//
// Recheck this table after an upstream pin bump. A status added to one header
// and not the others is exactly the shape of change that would break the single
// mapping, and nothing in the build or the linker can see it: purego binds by
// name, and the return value is a bare int32 on the Go side.
const (
statusOK int32 = 0
statusInvalidArgument int32 = 1
statusOutOfMemory int32 = 2
statusRuntime int32 = 3
statusCancelled int32 = 4
)
// statusCode maps a C status onto the gRPC code the caller should be told.
//
// What the mapping is really carrying is whose mistake the failure was.
// INVALID_ARGUMENT is what every guard in src/{asr,tts,nmt}/c_api.cpp returns
// for a std::invalid_argument from the runtime, and the things that throw it are
// requests: an unknown voice_name (src/tts/synthesizer.cpp), an unsupported
// language pair (src/nmt/translator.cpp), an out-of-range sample rate. Reporting
// those as Internal turns a 400 into a 500 and sends the user hunting for a
// broken model or a broken backend instead of fixing the request.
//
// OUT_OF_MEMORY is a resource limit rather than a defect, which is what
// ResourceExhausted means, and it is the one failure a client can sensibly
// retry later or retry smaller. CANCELLED is the consumer having stopped
// listening, which is not a failure of this backend at all: the streaming sinks
// return false once their client is gone (see ttsDeliverPCM), and the runtime
// turns that into status 4.
//
// RUNTIME, and anything a future pin adds that this table has not been taught,
// stay Internal. An unrecognised status is precisely the case where the backend
// does not know whose fault it was, and Internal is the honest answer.
func statusCode(st int32) codes.Code {
switch st {
case statusOK:
return codes.OK
case statusInvalidArgument:
return codes.InvalidArgument
case statusOutOfMemory:
return codes.ResourceExhausted
case statusCancelled:
return codes.Canceled
case statusRuntime:
// Named rather than folded into the default so this switch reads as the
// whole enum. A status the table has never heard of is a different thing
// from a runtime error even though both answer Internal, and a reader
// checking the mapping against the headers should not have to work out
// which arm RUNTIME lands in.
return codes.Internal
default:
return codes.Internal
}
}
// statusErrorf builds the gRPC error for a failed C call.
//
// Every C call site in this backend goes through this rather than through
// status.Errorf directly, and that is the whole point of it existing: the
// mapping used to be written out at exactly one of sixteen call sites, so the
// same backend answered an unsupported language pair with InvalidArgument and an
// unknown TTS voice, which is the same class of caller mistake against the same
// process, with Internal.
//
// The OK guard is not defensive noise. status.Errorf(codes.OK, ...) returns a
// nil error, so a call site that built its error without first checking the
// status would report a hard C failure as a successful request with no
// diagnostic anywhere. Returning Internal instead keeps that mistake loud.
func statusErrorf(st int32, format string, args ...any) error {
if st == statusOK {
return status.Errorf(codes.Internal, format, args...)
}
return status.Errorf(statusCode(st), format, args...)
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
var _ = Describe("C status mapping", func() {
// The whole enum, so a status that quietly moves to a different code is
// visible here rather than in a bug report about an HTTP 500. The names on
// the left are transcribed from asr.h:43-49, tts.h:38-44 and nmt.h:40-45;
// see the table in status.go for how the three surfaces line up.
DescribeTable("maps each declared C status onto a gRPC code",
func(st int32, want codes.Code) {
Expect(statusCode(st)).To(Equal(want))
},
Entry("OK", statusOK, codes.OK),
Entry("INVALID_ARGUMENT", statusInvalidArgument, codes.InvalidArgument),
Entry("OUT_OF_MEMORY", statusOutOfMemory, codes.ResourceExhausted),
Entry("RUNTIME", statusRuntime, codes.Internal),
Entry("CANCELLED", statusCancelled, codes.Canceled),
)
// A pin bump that adds a status this table has never been taught must not
// guess. Internal is the honest answer when the backend does not know whose
// mistake the failure was.
DescribeTable("reports an unknown status as Internal",
func(st int32) {
Expect(statusCode(st)).To(Equal(codes.Internal))
},
Entry("one past the last declared value", int32(5)),
Entry("far past it", int32(99)),
Entry("negative", int32(-1)),
)
It("carries the mapped code and the formatted message into the error", func() {
err := statusErrorf(statusInvalidArgument, "nemo-speech-cpp: %s: %d", "synthesize", 7)
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(err.Error()).To(ContainSubstring("nemo-speech-cpp: synthesize: 7"))
})
// status.Errorf(codes.OK, ...) returns nil, so a call site that built its
// error without checking the status first would turn a hard C failure into a
// silent success with no diagnostic anywhere.
It("never returns nil, not even for OK", func() {
err := statusErrorf(statusOK, "nemo-speech-cpp: should not happen")
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.Internal))
})
})
// These drive real C statuses out of the real shared objects, one per family,
// rather than asserting the Go mapping against itself.
//
// A NULL handle is the one failure every surface can be provoked into without a
// model: nemo_speech_asr_recognize_f32 and nemo_speech_nmt_translate check the
// handle up front, nemo_speech_tts_synthesize_text does the same, and the
// diarization stream entry points throw std::invalid_argument for a dead stream,
// which src/asr/c_api.cpp's guard maps to the same status. All four are the
// caller's mistake, and the point of the exercise is that all four now come back
// as InvalidArgument instead of Internal.
var _ = Describe("C status mapping at the call sites", func() {
BeforeEach(func() {
if !librariesPresent() {
if requireLibs() {
cwd, _ := os.Getwd()
Fail("NEMO_SPEECH_REQUIRE_LIBS=1 but the shared libraries are not in " + cwd +
": these specs are the ABI defence and must not be skipped." +
" Run make -C backend/go/nemo-speech-cpp stage-libs")
}
Skip("shared libraries not built, run make in backend/go/nemo-speech-cpp")
}
Expect(openLibraries()).To(Succeed())
})
It("reports an ASR INVALID_ARGUMENT as InvalidArgument", func() {
opts := ASRRecognitionOptionsDef()
// Non-empty PCM on purpose: recognizeF32 rejects an empty slice itself,
// which would prove nothing about what the C side returned.
_, err := recognizeF32(0, &opts, []float32{0, 0, 0}, 16000)
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
It("reports a diarization INVALID_ARGUMENT as InvalidArgument", func() {
err := (&cDiarStream{}).finish()
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
It("reports a TTS INVALID_ARGUMENT as InvalidArgument", func() {
s := &cSynthesizer{}
err := s.synthesize(&pb.TTSRequest{Text: "hello"}, "en", func([]byte) bool { return true })
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
It("reports an NMT INVALID_ARGUMENT as InvalidArgument", func() {
_, err := (&cTranslator{}).translate([]string{"hello"}, "en", "de")
Expect(err).To(HaveOccurred())
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
})
})
+600
View File
@@ -0,0 +1,600 @@
package main
import (
"bytes"
"math"
"os"
"runtime"
"strconv"
"sync"
"unsafe"
"github.com/ebitengine/purego"
laudio "github.com/mudler/LocalAI/pkg/audio"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// The backend-preference enum from include/nemo_speech/tts.h. The C type is an
// enum, which this toolchain lays out as int32, so the values are written here
// rather than inferred.
const (
ttsBackendAuto int32 = 0
ttsBackendCPU int32 = 1
)
// maxWAVDataBytes is the largest PCM payload a RIFF WAV can describe.
//
// Both size fields in the header are uint32, so a longer payload would not
// merely be unusual, it would wrap and produce a file whose header disagrees
// with its contents. At 22.05 kHz mono 16-bit that ceiling is about 27 hours of
// speech, so nothing real is being refused.
//
// Typed int64 rather than left untyped so the comparison below is the same one
// on every architecture: an untyped constant this large does not fit in a
// 32-bit int and would not compile there at all.
const maxWAVDataBytes int64 = math.MaxUint32 - laudio.WAVHeaderSize
// wavStreamingSize is the placeholder both size fields carry while the total
// length is still unknown. Players read it as "stream until the socket closes".
const wavStreamingSize = 0xFFFFFFFF
// ttsSink receives one PCM chunk, already copied into Go memory.
//
// It returns false to cancel the synthesis in progress: that is the C
// callback's only way to stop work early, and the runtime turns it into
// NEMO_SPEECH_TTS_ERROR_CANCELLED.
type ttsSink func(pcm []byte) bool
// ttsSinkTable maps the user_data value handed to C back to the Go sink the
// chunk belongs to.
//
// A single "current sink" pointer would be enough for one model, since every
// RPC holds that model's engineMu for its whole body. It is not enough for the
// process: engineMu is per-NemoSpeech, one backend process can hold several
// loaded models, and the callback below is shared by all of them, so two TTS
// models synthesizing at once would overwrite each other's sink. The id is what
// keeps them apart.
//
// The id is an integer and never a Go pointer. user_data crosses into C as a
// void*, which the collector does not trace, so a Go pointer parked there would
// have exactly the lifetime problem cstr documents.
type ttsSinkTable struct {
mu sync.Mutex
next uintptr
sinks map[uintptr]ttsSink
}
var ttsSinks = &ttsSinkTable{sinks: map[uintptr]ttsSink{}}
// register adds sink and returns its id together with the release the caller
// MUST defer. Ids start at 1 so a zeroed or stale user_data cannot resolve to
// somebody else's sink.
func (t *ttsSinkTable) register(sink ttsSink) (uintptr, func()) {
t.mu.Lock()
defer t.mu.Unlock()
t.next++
id := t.next
t.sinks[id] = sink
return id, func() {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.sinks, id)
}
}
// lookup returns the sink for id, or nil once it has been released.
func (t *ttsSinkTable) lookup(id uintptr) ttsSink {
t.mu.Lock()
defer t.mu.Unlock()
return t.sinks[id]
}
var (
ttsCallbackOnce sync.Once
ttsCallbackFn uintptr
)
// ttsPCMCallback returns the C function pointer the runtime drives PCM through,
// compiling it on first use.
//
// Exactly one is ever created per process, and that is a hard requirement
// rather than a tidiness argument. purego.NewCallback writes into a fixed table
// of maxCB = 2000 entries (purego/syscall_sysv.go) and never releases an entry,
// so a callback compiled per request panics the whole backend process with
// "purego: the maximum number of callbacks has been reached" on the 2001st
// synthesis. Per model load is not safe either: a server that swaps models
// reaches the same ceiling, just later and even less predictably. Routing every
// synthesis through one callback plus a user_data id is what keeps the count at
// one for the life of the process.
func ttsPCMCallback() uintptr {
ttsCallbackOnce.Do(func() { ttsCallbackFn = purego.NewCallback(ttsDeliverPCM) })
return ttsCallbackFn
}
// ttsDeliverPCM is the body of that callback: nemo_speech_tts_pcm_callback,
// which the runtime invokes on its own thread for each chunk it produces.
//
// The bytes are copied rather than aliased. The pointer addresses a std::string
// the runtime owns and reuses for the next chunk (src/tts/c_api.cpp
// make_callback), so a slice over it would be rewritten under the consumer as
// soon as this returns.
func ttsDeliverPCM(pcm unsafe.Pointer, nBytes uint64, userData uintptr) bool {
// The table's lock is released before the sink runs, which matters because
// TTSStream's sink blocks on a channel send until its client drains it.
// Holding the lock across that would stall every other model's callback
// behind one slow consumer.
sink := ttsSinks.lookup(userData)
if sink == nil {
// The request that registered this sink has already returned, so there
// is nowhere to put the audio. false cancels rather than letting the
// runtime synthesize to completion into a consumer that stopped
// listening.
return false
}
// c_api.cpp filters empty chunks before calling us, so this is belt and
// braces: unsafe.Slice on a null pointer is what it protects against.
if pcm == nil || nBytes == 0 {
return true
}
buf := make([]byte, nBytes)
// #nosec G103 -- pcm and nBytes are the C-owned buffer and its length from
// one callback invocation, both null/zero-checked above. The slice is read
// only, its length is the length the runtime declared for that buffer, and it
// is copied into Go memory here and never retained past this return.
copy(buf, unsafe.Slice((*byte)(pcm), nBytes))
return sink(buf)
}
// synthesizer is the TTS half of the C API, narrowed to what the two RPCs use.
//
// It is an interface for the same reason asrSession and diarStream are: no
// MagpieTTS GGUF is small enough to keep in the tree, so the logic layered on
// top of the ABI (validation, the WAV framing, chunk ordering) would otherwise
// have no test at all. The seam is at the ABI, not at the model: a fake here
// scripts what the C API emits, it does not pretend to synthesize anything.
type synthesizer interface {
// sampleRate is the rate the PCM chunks arrive at.
sampleRate() int32
// synthesize maps req onto the runtime's per-request options and runs one
// synthesis, handing each chunk to sink as it is produced.
synthesize(req *pb.TTSRequest, defaultLanguage string, sink ttsSink) error
}
// cSynthesizer is the real synthesizer, over one nemo_speech_tts_synthesizer.
type cSynthesizer struct {
handle uintptr
}
func (s *cSynthesizer) sampleRate() int32 { return TTSSampleRate(s.handle) }
func (s *cSynthesizer) synthesize(req *pb.TTSRequest, defaultLanguage string, sink ttsSink) error {
// Started from the runtime's own defaults, not from a zero struct: every
// numeric field here is sentinel-sensitive (speaker/seed < 0, steps/top_k
// <= 0 all mean "use the synthesizer's value"), and a zeroed struct would
// read as speaker 0, seed 0 and zero decoding steps.
opts := TTSSynthesisOptionsDefault()
// A per-request language wins over the model-level default; both may be
// empty, which the runtime resolves to the synthesizer's own default.
language := req.GetLanguage()
if language == "" {
language = defaultLanguage
}
langP, freeLang := cstr(language)
defer freeLang()
opts.LanguageCode = langP
speaker, voiceName := resolveSpeaker(req.GetVoice())
opts.Speaker = speaker
voiceP, freeVoice := cstr(voiceName)
defer freeVoice()
opts.VoiceName = voiceP
applySynthesisParams(&opts, req.GetParams())
id, release := ttsSinks.register(sink)
defer release()
// stats_out is NULL: nemo_speech_tts_synthesis_stats is 300-odd bytes of
// timing detail with nowhere to go on either RPC, and the C API documents
// NULL as the way to decline it.
// #nosec G103 -- opts is a local POD struct borrowed for this call only. Its
// two uintptr members (LanguageCode, VoiceName) are cstr allocations pinned
// by the defers above, and this entry point is synchronous, so it returns
// before those pins are released even though the callbacks run off-thread.
st := TTSSynthesizeText(s.handle, unsafe.Pointer(&opts), req.GetText(), ttsPCMCallback(), id, nil)
if st != 0 {
// An unknown voice_name arrives here as INVALID_ARGUMENT
// (src/tts/synthesizer.cpp throws std::invalid_argument, which
// src/tts/c_api.cpp's guard maps to it), and a consumer that stopped
// reading arrives as CANCELLED. Neither is this backend's failure, so
// neither goes out as Internal.
return statusErrorf(st, "nemo-speech-cpp: synthesize: %s", TTSLastError())
}
return nil
}
// resolveSpeaker splits a request's voice into the two fields the C API has for
// it: a speaker index and a voice name.
//
// nemo_speech_tts_synthesis_options.voice_name is documented as ignored
// whenever speaker >= 0, and src/tts/synthesizer.cpp only calls resolve_speaker
// when options.speaker is negative, so the two are alternatives and never a
// pair. A named voice must therefore leave the index at -1 or the name is
// silently dropped.
//
// The numeric split cannot change what the runtime picks: resolve_speaker parses
// a numeric voice_name itself, so anything this function passes through as a
// name and that happens to be a number lands on the same speaker anyway. What it
// must not do is let a NEGATIVE number through as an index. "-1" is not a
// speaker, it is the sentinel for "use the default", and treating it as an index
// would turn a request naming an invalid voice into one that quietly synthesizes
// in the default voice instead of being rejected.
func resolveSpeaker(voice string) (int32, string) {
if voice == "" {
return -1, ""
}
if idx, err := strconv.ParseInt(voice, 10, 32); err == nil && idx >= 0 {
return int32(idx), ""
}
return -1, voice
}
// applySynthesisParams maps TTSRequest.params onto the runtime's per-request
// options.
//
// Only the five knobs nemo_speech_tts_synthesis_options actually has are read.
// An unset or unparseable value leaves the field alone rather than resetting it:
// the struct arrives carrying the runtime's defaults, and params is documented
// as "unset leaves the backend's configured defaults".
//
// The sentinels are the reason each write is guarded rather than unconditional.
// src/tts/magpietts/runtime.cpp takes the request's seed only when it is >= 0
// and its steps and top_k only when they are > 0, so writing a parsed 0 or a
// negative would not merely be ignored, it would erase the option's meaning for
// a caller who passed "0" expecting something.
//
// temperature and cfg_scale each need their override flag set as well. The
// runtime reads the float only when the flag is true and otherwise falls back to
// the synthesizer's config, so a temperature written without its flag is
// silently discarded.
func applySynthesisParams(o *cTTSSynthesisOptions, params map[string]string) {
if len(params) == 0 {
return
}
if v, ok := parseInt32Param(params["seed"]); ok && v >= 0 {
o.Seed = v
}
if v, ok := parseInt32Param(params["steps"]); ok && v > 0 {
o.Steps = v
}
if v, ok := parseInt32Param(params["top_k"]); ok && v > 0 {
o.TopK = v
}
if v, ok := parseFloat32Param(params["temperature"]); ok {
o.Temperature = v
o.OverrideTemperature = true
}
if v, ok := parseFloat32Param(params["cfg_scale"]); ok {
o.CFGScale = v
o.OverrideCFGScale = true
}
}
// parseInt32Param reads one params entry. ok is false for an absent or
// unparseable value, which the caller reads as "leave the default".
func parseInt32Param(v string) (int32, bool) {
if v == "" {
return 0, false
}
n, err := strconv.ParseInt(v, 10, 32)
if err != nil {
xlog.Warn("nemo-speech-cpp: ignoring unparseable TTS parameter", "value", v)
return 0, false
}
return int32(n), true
}
func parseFloat32Param(v string) (float32, bool) {
if v == "" {
return 0, false
}
f, err := strconv.ParseFloat(v, 32)
if err != nil {
xlog.Warn("nemo-speech-cpp: ignoring unparseable TTS parameter", "value", v)
return 0, false
}
return float32(f), true
}
// ttsModelConfig builds the create-time model config.
//
// Extracted from loadTTS and asserted field by field because three adjacent
// members of nemo_speech_tts_model_config are same-typed paths. Swapping two of
// them changes neither the struct's size nor any field's offset, so the layout
// assertions in abi_test.go cannot see it, and the failure it produces is the
// runtime loading the codec as the acoustic model.
//
// Every argument is a C pointer from cstr, not a Go string, and the caller owns
// the releases. tnDir may be null: text_normalizer_model_dir is optional and an
// empty one leaves the text unchanged.
func ttsModelConfig(magpieModel, codecModel, tokenizerDir, tnDir uintptr) cTTSModelConfig {
return cTTSModelConfig{
Size: unsafe.Sizeof(cTTSModelConfig{}),
MagpieModel: magpieModel,
CodecModel: codecModel,
TokenizerModelDir: tokenizerDir,
TextNormalizerModelDir: tnDir,
}
}
// ttsRuntimeBackend maps the backend's gpu option onto the TTS runtime's
// backend preference.
//
// nemo_speech_tts_runtime_config has no device index at all, only a three-way
// AUTO/CPU/CUDA preference, so a gpu option naming a particular device cannot be
// honoured and AUTO is the honest answer for it. A negative gpu is different: it
// is the option's documented "CPU" across this whole backend (asr.h: "-1 = CPU")
// and it is also the default, so it has to pin the preference rather than leave
// the runtime free to pick CUDA.
func ttsRuntimeBackend(gpu int32) int32 {
if gpu < 0 {
return ttsBackendCPU
}
return ttsBackendAuto
}
// loadTTS creates the MagpieTTS synthesizer.
//
// It runs after discoverTTSAssets, so codecModel and tokenizerDir are already
// resolved and non-empty; tnDir stays optional.
//
// This must not take engineMu: Load is its only caller and already holds it.
func (n *NemoSpeech) loadTTS(modelFile string) error {
// nemo_speech_tts_create deep-copies every const char* into a std::string
// (src/tts/c_api.cpp, via str_or_empty) and keeps no pointer afterwards, so
// pinning across the create call is both necessary and sufficient.
var pinner runtime.Pinner
defer pinner.Unpin()
magpieP, freeMagpie := cstr(modelFile)
defer freeMagpie()
codecP, freeCodec := cstr(n.opts.codecModel)
defer freeCodec()
tokenizerP, freeTokenizer := cstr(n.opts.tokenizerDir)
defer freeTokenizer()
tnP, freeTN := cstr(n.opts.tnDir)
defer freeTN()
model := ttsModelConfig(magpieP, codecP, tokenizerP, tnP)
rt := TTSRuntimeConfigDefault()
backend := ttsRuntimeBackend(n.opts.gpu)
rt.LTBackend = backend
rt.SamplingBackend = backend
// The codec is a separate graph with its own placement, so a CPU-only
// request has to say so here too or it would still try to run on the GPU.
rt.CodecCPU = backend == ttsBackendCPU
langP, freeLang := cstr(n.opts.languageCode)
defer freeLang()
cfg := cTTSSynthesizerConfig{
Size: unsafe.Sizeof(cTTSSynthesizerConfig{}),
Model: pinPtr(&pinner, &model),
Runtime: pinPtr(&pinner, &rt),
DefaultLanguageCode: langP,
}
xlog.Info("nemo-speech-cpp: creating synthesizer",
"gpu", n.opts.gpu,
"codec", n.opts.codecModel,
"tokenizer", n.opts.tokenizerDir,
"text_normalizer", n.opts.tnDir != "")
// Compiled before the handle exists so that a full callback table fails the
// load, where the operator can see it, rather than the first synthesis.
ttsPCMCallback()
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Model
// and Runtime are pinPtr addresses held by the pinner unpinned on return, the
// paths they carry are cstr allocations freed by the defers above, and
// nemo_speech_tts_create deep-copies every string it reads.
if st := TTSCreate(unsafe.Pointer(&cfg), &n.synth); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: tts create: %s", TTSLastError())
}
return nil
}
// validateTTSRequest rejects what the runtime would reject, before anything
// crosses the ABI, and names the fields this backend drops.
//
// Empty text is checked here rather than left to the C side for the error code:
// src/tts/synthesizer.cpp throws "text is required", which arrives as a status
// this layer would otherwise report as Internal, and an empty prompt is a client
// mistake, not a backend failure.
//
// instructions is logged rather than rejected, for the reason the diarization
// path logs its own dropped fields: a caller that asked for an expressive style
// still wants the audio it can have, and a request naming something this backend
// silently ignores should say so where an operator can find it. There is nothing
// to map it onto, because MagpieTTS conditions on a speaker, not on a prose
// style description: nemo_speech_tts_synthesis_options has speaker and
// voice_name and no free-text field at all.
func validateTTSRequest(req *pb.TTSRequest) error {
if req.GetText() == "" {
return status.Error(codes.InvalidArgument, "nemo-speech-cpp: TTSRequest.text is required")
}
if req.GetInstructions() != "" {
xlog.Warn("nemo-speech-cpp: ignoring TTSRequest.instructions, this model has no equivalent")
}
return nil
}
// outputSampleRate reads the rate the synthesizer emits at.
//
// A non-positive rate is refused rather than passed on. nemo_speech_tts_sample_rate
// answers 0 for a null handle, and a WAV header carrying 0 is not a slightly
// wrong file, it is one no player can decode and one whose duration is
// undefined.
func outputSampleRate(s synthesizer) (uint32, error) {
rate := s.sampleRate()
if rate <= 0 {
return 0, status.Error(codes.Internal,
"nemo-speech-cpp: the synthesizer reported no sample rate")
}
return uint32(rate), nil
}
// wavFile frames PCM as a complete WAV: a header with real sizes, then the
// samples.
//
// pcm is little-endian signed 16-bit mono, which is what the runtime's callback
// delivers, and is exactly what pkg/audio's header describes, so nothing is
// converted on the way through.
func wavFile(pcm []byte, sampleRate uint32) ([]byte, error) {
if int64(len(pcm)) > maxWAVDataBytes {
return nil, status.Errorf(codes.Internal,
"nemo-speech-cpp: synthesis produced %d bytes, more than a WAV header can describe", len(pcm))
}
var buf bytes.Buffer
// #nosec G115 -- len(pcm) is checked against maxWAVDataBytes (MaxUint32 minus
// the header) immediately above, so the narrowing to uint32 cannot wrap.
h := laudio.NewWAVHeaderWithRate(uint32(len(pcm)), sampleRate)
if err := h.Write(&buf); err != nil {
return nil, status.Errorf(codes.Internal, "nemo-speech-cpp: write WAV header: %v", err)
}
buf.Write(pcm)
return buf.Bytes(), nil
}
// streamingWAVHeader is the first chunk of a streamed synthesis: the same header
// with both sizes left unknown, since the total length is not known until the
// synthesis ends.
//
// NewWAVHeaderWithRate derives ChunkSize from the payload length, so the RIFF
// size has to be overwritten as well: 36 + 0xFFFFFFFF wraps to 35, which is a
// smaller number than the header itself.
func streamingWAVHeader(sampleRate uint32) []byte {
h := laudio.NewWAVHeaderWithRate(wavStreamingSize, sampleRate)
h.ChunkSize = wavStreamingSize
var buf bytes.Buffer
// Write only fails on the writer, and bytes.Buffer does not fail.
_ = h.Write(&buf)
return buf.Bytes()
}
// synthesizeWAV runs one synthesis and writes the whole result to dst.
func synthesizeWAV(s synthesizer, req *pb.TTSRequest, defaultLanguage string) error {
if err := validateTTSRequest(req); err != nil {
return err
}
if req.GetDst() == "" {
return status.Error(codes.InvalidArgument,
"nemo-speech-cpp: TTSRequest.dst (output path) is required")
}
// Read before the synthesis rather than after: it is what the header is
// built from, and failing on a bad handle here costs nothing, where failing
// after costs the whole synthesis.
rate, err := outputSampleRate(s)
if err != nil {
return err
}
var pcm []byte
err = s.synthesize(req, defaultLanguage, func(chunk []byte) bool {
pcm = append(pcm, chunk...)
return true
})
if err != nil {
return err
}
// A synthesis that returned OK having emitted nothing is a runtime bug, but
// the file it would produce is a valid empty WAV, which reaches the user as
// silence with no error anywhere.
if len(pcm) == 0 {
return status.Error(codes.Internal, "nemo-speech-cpp: synthesis produced no audio")
}
out, err := wavFile(pcm, rate)
if err != nil {
return err
}
if err := os.WriteFile(req.GetDst(), out, 0o600); err != nil {
return status.Errorf(codes.Internal, "nemo-speech-cpp: write %q: %v", req.GetDst(), err)
}
return nil
}
// streamWAV runs one synthesis and emits a WAV header followed by each PCM
// chunk as the runtime produces it.
//
// The header is the backend's job, not the caller's: pkg/grpc/server.go only
// ever sets Reply.Audio on this path and never Reply.Message, and
// core/backend/tts.go's own header branch is keyed on Message, so a backend that
// emitted bare PCM would stream something no client could decode. sherpa-onnx
// and magpie-tts-cpp both do the same.
//
// out is not closed here. TTSStream owns it, and closing it in one of two places
// depending on how far the request got is how a stream ends up half-closed.
func streamWAV(s synthesizer, req *pb.TTSRequest, defaultLanguage string, out chan<- []byte) error {
if err := validateTTSRequest(req); err != nil {
return err
}
rate, err := outputSampleRate(s)
if err != nil {
return err
}
out <- streamingWAVHeader(rate)
return s.synthesize(req, defaultLanguage, func(chunk []byte) bool {
out <- chunk
return true
})
}
// TTS synthesizes req.Text and writes a WAV to req.Dst.
//
// The whole body runs inside withEngine, so the family check and the C calls
// that trust the handle happen under a single acquisition of engineMu. See the
// handoff notes at the bottom of nemospeech.go: Free runs without the backend
// lock, so anything that checks the family and then releases the lock before
// calling C can have the handle destroyed underneath it.
func (n *NemoSpeech) TTS(req *pb.TTSRequest) error {
return n.withEngine(familyTTS, func() error {
return synthesizeWAV(&cSynthesizer{handle: n.synth}, req, n.opts.languageCode)
})
}
// TTSStream synthesizes req.Text and emits the audio on results as it is
// produced.
//
// results is closed on EVERY path, including the family rejection and a
// validation failure, and the close is deferred outside withEngine so that a
// rejected family still closes it. pkg/grpc/server.go drains this channel from a
// goroutine and then blocks on that goroutine finishing, so a channel left open
// does not fail the request, it hangs the RPC and, because the backend lock is
// still held, every request behind it.
//
// Holding engineMu for the whole stream is deliberate and is the consequence
// documented on the locking protocol: an unload waits for the stream to end
// rather than destroying the synthesizer underneath it. There is no unbounded
// wait here, because unlike the ASR streams this one is driven by the runtime
// and ends when the text does, not when a client decides to stop sending.
func (n *NemoSpeech) TTSStream(req *pb.TTSRequest, results chan []byte) error {
defer close(results)
return n.withEngine(familyTTS, func() error {
return streamWAV(&cSynthesizer{handle: n.synth}, req, n.opts.languageCode, results)
})
}
+727
View File
@@ -0,0 +1,727 @@
package main
import (
"encoding/binary"
"errors"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sync"
"unsafe"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
laudio "github.com/mudler/LocalAI/pkg/audio"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// puregoCallbackTableSize is the hard ceiling purego compiles callbacks into:
// maxCB in purego/syscall_sysv.go, which panics rather than growing once it is
// full and never releases an entry. Read off the module source for v0.10.0
// rather than assumed, because the whole point of the specs below is that
// exceeding it kills the process.
const puregoCallbackTableSize = 2000
// fakeSynthesizer scripts what the TTS C API emits for one synthesis.
//
// There is no MagpieTTS GGUF in the tree, so this is the only way the logic on
// top of the ABI (validation, WAV framing, chunk ordering, channel closure)
// gets tested at all. It fakes the C contract, not the model: chunks is
// whatever nemo_speech_tts_synthesize_text would have handed the callback.
type fakeSynthesizer struct {
rate int32
chunks [][]byte
err error
calls int
gotReq *pb.TTSRequest
gotLang string
cancelled bool
}
func (f *fakeSynthesizer) sampleRate() int32 { return f.rate }
func (f *fakeSynthesizer) synthesize(req *pb.TTSRequest, defaultLanguage string, sink ttsSink) error {
f.calls++
f.gotReq = req
f.gotLang = defaultLanguage
for _, c := range f.chunks {
if !sink(c) {
f.cancelled = true
break
}
}
return f.err
}
var _ = Describe("resolveSpeaker", func() {
It("passes a numeric voice through as a speaker index", func() {
idx, name := resolveSpeaker("3")
Expect(idx).To(Equal(int32(3)))
Expect(name).To(BeEmpty())
})
It("passes a named voice through as a name with no index", func() {
// voice_name is ignored whenever speaker >= 0 (tts.h, and
// synthesizer.cpp only calls resolve_speaker for a negative speaker), so
// a named voice must leave the index negative or the name is dropped.
idx, name := resolveSpeaker("Aria")
Expect(idx).To(Equal(int32(-1)))
Expect(name).To(Equal("Aria"))
})
It("leaves both unset for an empty voice so the synthesizer default wins", func() {
idx, name := resolveSpeaker("")
Expect(idx).To(Equal(int32(-1)))
Expect(name).To(BeEmpty())
})
// A negative number is the C API's sentinel for "use the default", not a
// speaker. Passing it through as an index would turn a request naming an
// invalid voice into one that quietly synthesizes in the default voice.
// Handed on as a name instead, resolve_speaker rejects it.
It("does not let a negative number become a speaker index", func() {
idx, name := resolveSpeaker("-1")
Expect(idx).To(Equal(int32(-1)))
Expect(name).To(Equal("-1"))
})
It("treats a non-numeric voice that merely starts with digits as a name", func() {
idx, name := resolveSpeaker("3-alpha")
Expect(idx).To(Equal(int32(-1)))
Expect(name).To(Equal("3-alpha"))
})
It("keeps speaker 0 addressable", func() {
// 0 is a real speaker index, and the only sentinel here is < 0.
idx, name := resolveSpeaker("0")
Expect(idx).To(BeZero())
Expect(name).To(BeEmpty())
})
})
var _ = Describe("applySynthesisParams", func() {
// The struct the runtime hands out: speaker/seed/steps/top_k all -1, the
// overrides off. Written literally rather than taken from
// TTSSynthesisOptionsDefault so the specs run without the shared libraries.
defaults := func() cTTSSynthesisOptions {
return cTTSSynthesisOptions{
Size: unsafe.Sizeof(cTTSSynthesisOptions{}),
Speaker: -1,
Seed: -1,
Steps: -1,
TopK: -1,
}
}
It("leaves every default alone for an absent params map", func() {
o := defaults()
applySynthesisParams(&o, nil)
Expect(o).To(Equal(defaults()))
})
It("leaves every default alone for an empty params map", func() {
o := defaults()
applySynthesisParams(&o, map[string]string{})
Expect(o).To(Equal(defaults()))
})
It("maps the five knobs the C options struct actually has", func() {
o := defaults()
applySynthesisParams(&o, map[string]string{
"seed": "42",
"steps": "12",
"top_k": "80",
"temperature": "0.7",
"cfg_scale": "1.5",
})
Expect(o.Seed).To(Equal(int32(42)))
Expect(o.Steps).To(Equal(int32(12)))
Expect(o.TopK).To(Equal(int32(80)))
Expect(o.Temperature).To(BeNumerically("~", 0.7, 1e-6))
Expect(o.CFGScale).To(BeNumerically("~", 1.5, 1e-6))
})
// magpietts/runtime.cpp reads options.temperature only when
// override_temperature is true and otherwise falls back to the
// synthesizer's config, so a temperature written without its flag is
// silently discarded and the request looks like it was honoured.
It("sets the override flag with the temperature", func() {
o := defaults()
applySynthesisParams(&o, map[string]string{"temperature": "0.4"})
Expect(o.OverrideTemperature).To(BeTrue())
Expect(o.OverrideCFGScale).To(BeFalse(), "cfg_scale was not asked for")
})
It("sets the override flag with the cfg scale", func() {
o := defaults()
applySynthesisParams(&o, map[string]string{"cfg_scale": "2"})
Expect(o.OverrideCFGScale).To(BeTrue())
Expect(o.OverrideTemperature).To(BeFalse(), "temperature was not asked for")
})
It("keeps the defaults when a value cannot be parsed", func() {
o := defaults()
applySynthesisParams(&o, map[string]string{
"seed": "many",
"steps": "",
"top_k": "8.5",
"temperature": "warm",
"cfg_scale": "-",
})
Expect(o).To(Equal(defaults()))
})
// The runtime takes a request's seed only when it is >= 0 and its steps and
// top_k only when they are > 0. Writing a parsed 0 or a negative would not
// be ignored downstream, it would erase the sentinel that means "use the
// synthesizer's value".
It("refuses values that would erase a sentinel", func() {
o := defaults()
applySynthesisParams(&o, map[string]string{
"seed": "-5",
"steps": "0",
"top_k": "0",
})
Expect(o.Seed).To(Equal(int32(-1)))
Expect(o.Steps).To(Equal(int32(-1)))
Expect(o.TopK).To(Equal(int32(-1)))
})
It("keeps seed 0, which is a real seed", func() {
o := defaults()
applySynthesisParams(&o, map[string]string{"seed": "0"})
Expect(o.Seed).To(BeZero())
})
// TTSRequest carries fields with no equivalent in
// nemo_speech_tts_synthesis_options. They must not be smuggled in through a
// param name that happens to match.
It("ignores params the C options struct has no field for", func() {
o := defaults()
applySynthesisParams(&o, map[string]string{
"top_p": "0.9",
"repetition_penalty": "1.1",
"speed": "1.2",
"instructions": "cheerful",
})
Expect(o).To(Equal(defaults()))
})
})
// The PCM callback is the one resource in this backend with a hard, silent,
// process-wide ceiling: purego compiles each into a fixed table of 2000 entries
// and never releases one, so a callback built per request takes the whole
// backend process down with a panic after 2000 syntheses. Nothing about a
// handful of manual calls shows that.
var _ = Describe("ttsPCMCallback", func() {
It("compiles a usable callback", func() {
Expect(ttsPCMCallback()).ToNot(BeZero())
})
It("compiles exactly one callback however many times it is asked", func() {
first := ttsPCMCallback()
// One more than the table holds: a callback compiled per call panics
// with "purego: the maximum number of callbacks has been reached"
// before this loop ends, which is precisely the production failure.
for i := 0; i <= puregoCallbackTableSize; i++ {
Expect(ttsPCMCallback()).To(Equal(first),
"call %d returned a different callback, so a new one was compiled", i)
}
})
// A source-level assertion, deliberately, because the failure it guards
// against is invisible from inside the process: the way a per-request
// callback gets reintroduced is by someone calling purego.NewCallback at the
// synthesis site instead of going through ttsPCMCallback, and no in-process
// spec can reach that call without a MagpieTTS GGUF to synthesize with.
// Funnelling every compile through one accessor is what the whole design
// rests on, so the single call site is the invariant worth pinning.
It("compiles callbacks from exactly one place in the TTS path", func() {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "tts.go", nil, 0)
Expect(err).ToNot(HaveOccurred())
// Counted over the syntax tree rather than by grepping the text: the
// doc comment on ttsPCMCallback names purego.NewCallback too, and a
// spec that cannot tell an explanation from a call would be pinning the
// prose.
var sites []string
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "NewCallback" {
return true
}
if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "purego" {
sites = append(sites, fset.Position(call.Pos()).String())
}
return true
})
Expect(sites).To(HaveLen(1),
"every callback must be compiled through ttsPCMCallback, which memoises it")
})
})
var _ = Describe("the PCM sink table", func() {
It("routes a chunk to the sink registered for that id", func() {
var got []byte
id, release := ttsSinks.register(func(pcm []byte) bool {
got = pcm
return true
})
defer release()
src := []byte{1, 2, 3, 4}
Expect(ttsDeliverPCM(unsafe.Pointer(&src[0]), uint64(len(src)), id)).To(BeTrue())
Expect(got).To(Equal([]byte{1, 2, 3, 4}))
})
// The pointer addresses a std::string the runtime reuses for the next
// chunk, so a slice over it would be rewritten under the consumer.
It("copies the chunk out of the runtime's buffer", func() {
var got []byte
id, release := ttsSinks.register(func(pcm []byte) bool {
got = pcm
return true
})
defer release()
src := []byte{9, 8, 7}
Expect(ttsDeliverPCM(unsafe.Pointer(&src[0]), uint64(len(src)), id)).To(BeTrue())
src[0], src[1], src[2] = 0, 0, 0
Expect(got).To(Equal([]byte{9, 8, 7}))
})
It("gives each registration its own id", func() {
idA, releaseA := ttsSinks.register(func([]byte) bool { return true })
defer releaseA()
idB, releaseB := ttsSinks.register(func([]byte) bool { return true })
defer releaseB()
Expect(idA).ToNot(Equal(idB))
Expect(idA).ToNot(BeZero(), "id 0 is what a zeroed user_data would carry")
Expect(idB).ToNot(BeZero())
})
// Two models synthesizing at once share one callback, and engineMu is
// per-model, so nothing serialises them against each other.
It("keeps concurrent sinks apart", func() {
var mu sync.Mutex
got := map[uintptr][]byte{}
var wg sync.WaitGroup
for i := range 16 {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
src := []byte{byte(i)}
var mine []byte
id, release := ttsSinks.register(func(pcm []byte) bool {
mine = pcm
return true
})
defer release()
Expect(ttsDeliverPCM(unsafe.Pointer(&src[0]), 1, id)).To(BeTrue())
mu.Lock()
defer mu.Unlock()
got[id] = mine
}()
}
wg.Wait()
Expect(got).To(HaveLen(16))
for id, pcm := range got {
Expect(pcm).To(HaveLen(1), "sink %d received the wrong chunk", id)
}
})
// A released id means the request has returned. Answering true would leave
// the runtime synthesizing into nothing while the RPC that owns the lock
// waits for it.
It("cancels the synthesis when the sink is gone", func() {
id, release := ttsSinks.register(func([]byte) bool { return true })
release()
src := []byte{1}
Expect(ttsDeliverPCM(unsafe.Pointer(&src[0]), 1, id)).To(BeFalse())
})
It("cancels for a user_data that was never registered", func() {
src := []byte{1}
Expect(ttsDeliverPCM(unsafe.Pointer(&src[0]), 1, 0)).To(BeFalse())
})
It("accepts an empty chunk without touching the pointer", func() {
id, release := ttsSinks.register(func([]byte) bool {
Fail("an empty chunk must not reach the sink")
return true
})
defer release()
Expect(ttsDeliverPCM(nil, 0, id)).To(BeTrue())
})
It("passes the sink's cancellation back to the runtime", func() {
id, release := ttsSinks.register(func([]byte) bool { return false })
defer release()
src := []byte{1}
Expect(ttsDeliverPCM(unsafe.Pointer(&src[0]), 1, id)).To(BeFalse())
})
})
var _ = Describe("ttsModelConfig", func() {
// Three adjacent same-typed path fields: swapping two changes neither the
// struct size nor any offset, so abi_test.go's layout assertions cannot see
// it and the runtime would load the codec as the acoustic model.
It("assigns each path to its own field", func() {
cfg := ttsModelConfig(1, 2, 3, 4)
Expect(cfg.MagpieModel).To(Equal(uintptr(1)))
Expect(cfg.CodecModel).To(Equal(uintptr(2)))
Expect(cfg.TokenizerModelDir).To(Equal(uintptr(3)))
Expect(cfg.TextNormalizerModelDir).To(Equal(uintptr(4)))
})
// A config sent with the wrong size has every field past it ignored by
// HAS_FIELD, and the model loads with defaults instead of failing.
It("declares the size the runtime validates against", func() {
Expect(ttsModelConfig(1, 2, 3, 4).Size).To(Equal(unsafe.Sizeof(cTTSModelConfig{})))
})
It("leaves an unset text normalizer null", func() {
Expect(ttsModelConfig(1, 2, 3, 0).TextNormalizerModelDir).To(BeZero())
})
})
var _ = Describe("ttsRuntimeBackend", func() {
// -1 is this backend's documented "CPU" everywhere (asr.h: "-1 = CPU") and
// it is also the default, so it has to pin the preference rather than leave
// the runtime free to pick CUDA.
It("pins CPU for a negative gpu option", func() {
Expect(ttsRuntimeBackend(-1)).To(Equal(ttsBackendCPU))
})
// nemo_speech_tts_runtime_config has no device index at all, so a request
// for a particular device cannot be honoured and AUTO is the honest answer.
It("leaves the choice to the runtime when a device was named", func() {
Expect(ttsRuntimeBackend(0)).To(Equal(ttsBackendAuto))
Expect(ttsRuntimeBackend(3)).To(Equal(ttsBackendAuto))
})
})
var _ = Describe("WAV framing", func() {
// 16-bit mono little-endian, the format the runtime's callback delivers.
pcm := []byte{0x01, 0x00, 0xff, 0x7f, 0x00, 0x80}
It("writes a header the audio helpers can read back", func() {
out, err := wavFile(pcm, 22050)
Expect(err).ToNot(HaveOccurred())
body, rate := laudio.ParseWAV(out)
Expect(rate).To(Equal(22050))
Expect(body).To(Equal(pcm))
})
It("describes the payload it actually carries", func() {
out, err := wavFile(pcm, 22050)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(laudio.WAVHeaderSize + len(pcm)))
Expect(string(out[0:4])).To(Equal("RIFF"))
Expect(string(out[8:12])).To(Equal("WAVE"))
Expect(binary.LittleEndian.Uint32(out[4:8])).To(Equal(uint32(36 + len(pcm))))
Expect(binary.LittleEndian.Uint32(out[40:44])).To(Equal(uint32(len(pcm))))
Expect(binary.LittleEndian.Uint16(out[22:24])).To(Equal(uint16(1)), "mono")
Expect(binary.LittleEndian.Uint16(out[34:36])).To(Equal(uint16(16)), "16-bit")
Expect(binary.LittleEndian.Uint32(out[24:28])).To(Equal(uint32(22050)))
// byte rate = sample rate * block align, and a wrong one plays back at
// the wrong speed in players that trust it.
Expect(binary.LittleEndian.Uint32(out[28:32])).To(Equal(uint32(22050 * 2)))
})
It("carries whatever rate the synthesizer reported", func() {
out, err := wavFile(pcm, 44100)
Expect(err).ToNot(HaveOccurred())
_, rate := laudio.ParseWAV(out)
Expect(rate).To(Equal(44100))
})
Describe("the streaming header", func() {
It("is a complete header on its own", func() {
h := streamingWAVHeader(22050)
Expect(h).To(HaveLen(laudio.WAVHeaderSize))
Expect(string(h[0:4])).To(Equal("RIFF"))
Expect(string(h[8:12])).To(Equal("WAVE"))
Expect(binary.LittleEndian.Uint32(h[24:28])).To(Equal(uint32(22050)))
})
// NewWAVHeaderWithRate derives ChunkSize from the payload length, so
// leaving it alone would write 36 + 0xFFFFFFFF, which wraps to 35: a
// RIFF size smaller than the header itself.
It("leaves both sizes unknown rather than wrapping", func() {
h := streamingWAVHeader(22050)
Expect(binary.LittleEndian.Uint32(h[4:8])).To(Equal(uint32(0xFFFFFFFF)))
Expect(binary.LittleEndian.Uint32(h[40:44])).To(Equal(uint32(0xFFFFFFFF)))
})
})
})
var _ = Describe("synthesizeWAV", func() {
var dst string
BeforeEach(func() {
dst = filepath.Join(GinkgoT().TempDir(), "out.wav")
})
It("writes one WAV holding every chunk in order", func() {
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}, {2, 0}, {3, 0}}}
Expect(synthesizeWAV(s, &pb.TTSRequest{Text: "hello", Dst: dst}, "")).To(Succeed())
out, err := os.ReadFile(dst)
Expect(err).ToNot(HaveOccurred())
body, rate := laudio.ParseWAV(out)
Expect(rate).To(Equal(22050))
Expect(body).To(Equal([]byte{1, 0, 2, 0, 3, 0}))
})
It("hands the request and the model default language to the runtime", func() {
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}}}
req := &pb.TTSRequest{Text: "hello", Dst: dst, Voice: "Aria"}
Expect(synthesizeWAV(s, req, "it-IT")).To(Succeed())
Expect(s.gotReq).To(Equal(req))
Expect(s.gotLang).To(Equal("it-IT"))
})
It("rejects an empty text before it reaches the runtime", func() {
s := &fakeSynthesizer{rate: 22050}
err := synthesizeWAV(s, &pb.TTSRequest{Dst: dst}, "")
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(s.calls).To(BeZero())
})
// instructions has no equivalent in nemo_speech_tts_synthesis_options, which
// conditions on a speaker rather than a prose style. Dropping it must not
// fail the request: the caller still wants the audio it can have.
It("synthesizes anyway for a request carrying instructions it cannot honour", func() {
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}}}
instructions := "speak cheerfully"
Expect(synthesizeWAV(s, &pb.TTSRequest{
Text: "hello",
Dst: dst,
Instructions: &instructions,
}, "")).To(Succeed())
Expect(dst).To(BeAnExistingFile())
})
It("rejects a request with no destination", func() {
s := &fakeSynthesizer{rate: 22050}
err := synthesizeWAV(s, &pb.TTSRequest{Text: "hello"}, "")
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(s.calls).To(BeZero())
})
// A zero rate is what a null handle reports. The file it would produce is
// undecodable, and the synthesis that produced it would be wasted.
It("refuses to write a file at an unusable sample rate", func() {
s := &fakeSynthesizer{rate: 0, chunks: [][]byte{{1, 0}}}
err := synthesizeWAV(s, &pb.TTSRequest{Text: "hello", Dst: dst}, "")
Expect(status.Code(err)).To(Equal(codes.Internal))
Expect(s.calls).To(BeZero())
Expect(dst).ToNot(BeAnExistingFile())
})
It("propagates a synthesis failure and writes nothing", func() {
boom := errors.New("boom")
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}}, err: boom}
Expect(synthesizeWAV(s, &pb.TTSRequest{Text: "hello", Dst: dst}, "")).To(MatchError(boom))
Expect(dst).ToNot(BeAnExistingFile())
})
// An empty WAV is a valid file, so this would otherwise reach the user as
// silence with no error anywhere.
It("fails rather than write a silent file when nothing was produced", func() {
s := &fakeSynthesizer{rate: 22050}
err := synthesizeWAV(s, &pb.TTSRequest{Text: "hello", Dst: dst}, "")
Expect(status.Code(err)).To(Equal(codes.Internal))
Expect(dst).ToNot(BeAnExistingFile())
})
It("reports a destination it cannot write", func() {
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}}}
bad := filepath.Join(GinkgoT().TempDir(), "no-such-dir", "out.wav")
err := synthesizeWAV(s, &pb.TTSRequest{Text: "hello", Dst: bad}, "")
Expect(status.Code(err)).To(Equal(codes.Internal))
Expect(err.Error()).To(ContainSubstring("out.wav"))
})
})
var _ = Describe("streamWAV", func() {
// drain collects everything streamWAV emits. The channel is buffered
// because streamWAV sends inline, so an unbuffered one would deadlock the
// spec rather than fail it.
drain := func(s synthesizer, req *pb.TTSRequest) ([][]byte, error) {
out := make(chan []byte, 16)
err := streamWAV(s, req, "", out)
close(out)
var got [][]byte
for c := range out {
got = append(got, c)
}
return got, err
}
It("emits the header first, then each chunk as it arrives", func() {
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}, {2, 0}}}
got, err := drain(s, &pb.TTSRequest{Text: "hello"})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(HaveLen(3))
Expect(got[0]).To(Equal(streamingWAVHeader(22050)))
Expect(got[1]).To(Equal([]byte{1, 0}))
Expect(got[2]).To(Equal([]byte{2, 0}))
})
// pkg/grpc/server.go only ever sets Reply.Audio, so core/backend's own
// header branch (keyed on Reply.Message) never runs and a backend that
// emitted bare PCM would stream something no client could decode.
It("owns the header rather than leaving it to the caller", func() {
s := &fakeSynthesizer{rate: 44100, chunks: [][]byte{{1, 0}}}
got, err := drain(s, &pb.TTSRequest{Text: "hello"})
Expect(err).ToNot(HaveOccurred())
Expect(string(got[0][0:4])).To(Equal("RIFF"))
Expect(binary.LittleEndian.Uint32(got[0][24:28])).To(Equal(uint32(44100)))
})
It("rejects an empty text before emitting anything", func() {
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}}}
got, err := drain(s, &pb.TTSRequest{})
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(got).To(BeEmpty())
Expect(s.calls).To(BeZero())
})
It("emits no header at an unusable sample rate", func() {
s := &fakeSynthesizer{rate: 0, chunks: [][]byte{{1, 0}}}
got, err := drain(s, &pb.TTSRequest{Text: "hello"})
Expect(status.Code(err)).To(Equal(codes.Internal))
Expect(got).To(BeEmpty())
})
It("propagates a synthesis failure after the chunks it did emit", func() {
boom := errors.New("boom")
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}}, err: boom}
got, err := drain(s, &pb.TTSRequest{Text: "hello"})
Expect(err).To(MatchError(boom))
Expect(got).To(HaveLen(2))
})
// streamWAV must not close the channel: TTSStream owns it, and closing in
// one of two places depending on how far the request got is how a stream
// ends up double-closed.
It("leaves the channel open for its caller to close", func() {
s := &fakeSynthesizer{rate: 22050, chunks: [][]byte{{1, 0}}}
out := make(chan []byte, 4)
Expect(streamWAV(s, &pb.TTSRequest{Text: "hello"}, "", out)).To(Succeed())
Expect(func() { close(out) }).ToNot(Panic())
})
})
var _ = Describe("the TTS RPCs", func() {
It("refuses TTS on a model loaded as another family", func() {
n := &NemoSpeech{fam: familyASR}
err := n.TTS(&pb.TTSRequest{Text: "hello", Dst: "/tmp/out.wav"})
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
})
It("refuses TTS on an unloaded model", func() {
n := &NemoSpeech{}
Expect(status.Code(n.TTS(&pb.TTSRequest{Text: "hello", Dst: "/tmp/out.wav"}))).
To(Equal(codes.Unimplemented))
})
It("releases the engine lock after a refusal", func() {
n := &NemoSpeech{fam: familyASR}
Expect(n.TTS(&pb.TTSRequest{Text: "hello", Dst: "/tmp/out.wav"})).ToNot(Succeed())
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
// pkg/grpc/server.go drains this channel from a goroutine and then blocks
// on that goroutine finishing, so a channel left open does not fail the
// request, it hangs the RPC with the backend lock still held. Every exit
// path has to close it.
Describe("TTSStream channel closure", func() {
// streamed runs TTSStream the way the server does and returns once the
// channel has been closed, so a spec that hangs is a real hang.
streamed := func(n *NemoSpeech, req *pb.TTSRequest) ([][]byte, error) {
ch := make(chan []byte, 16)
done := make(chan [][]byte, 1)
go func() {
defer GinkgoRecover()
var got [][]byte
for c := range ch {
got = append(got, c)
}
done <- got
}()
err := n.TTSStream(req, ch)
var got [][]byte
Eventually(done).Should(Receive(&got))
return got, err
}
It("closes the channel when the family does not match", func() {
n := &NemoSpeech{fam: familyASR}
got, err := streamed(n, &pb.TTSRequest{Text: "hello"})
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(got).To(BeEmpty())
})
It("closes the channel when the model was never loaded", func() {
n := &NemoSpeech{}
_, err := streamed(n, &pb.TTSRequest{Text: "hello"})
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
})
// familyTTS with a zero handle: validation has to reject this before
// anything reaches the C entry points, which are nil function values
// until openLibraries has bound them.
It("closes the channel when the request is rejected", func() {
n := &NemoSpeech{fam: familyTTS}
got, err := streamed(n, &pb.TTSRequest{})
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(got).To(BeEmpty())
})
It("releases the engine lock afterwards", func() {
n := &NemoSpeech{fam: familyTTS}
_, err := streamed(n, &pb.TTSRequest{})
Expect(err).To(HaveOccurred())
Expect(n.engineMu.TryLock()).To(BeTrue())
n.engineMu.Unlock()
})
})
// The same guard on the offline path: a rejected request must not reach a
// nil C function through a zero handle.
It("rejects an invalid TTS request without touching the runtime", func() {
n := &NemoSpeech{fam: familyTTS}
Expect(status.Code(n.TTS(&pb.TTSRequest{Dst: "/tmp/out.wav"}))).To(Equal(codes.InvalidArgument))
Expect(status.Code(n.TTS(&pb.TTSRequest{Text: "hello"}))).To(Equal(codes.InvalidArgument))
})
})
+1 -1
View File
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# omnivoice.cpp version
OMNIVOICE_REPO?=https://github.com/ServeurpersoCom/omnivoice.cpp
OMNIVOICE_VERSION?=4f33af825d66e6ef1cb185e87b4589cacf747291
OMNIVOICE_VERSION?=040c8b344d8c670ce1475194751d119b5ef82c78
SO_TARGET?=libgomnivoicecpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
Loaded 100 of 1027 files, more files were not shown because too many files have changed in this diff. Show more