Commit Graph
245 Commits
Author SHA1 Message Date
mudler-agentandEttore Di Giacinto 2cadf319a0 feat(realtime): LFM3-Audio speech-to-speech on llama.cpp, remove liquid-audio (#12958)
* chore(backends): remove the liquid-audio Python backend

The backend targets liquid-audio, which loads LFM2 checkpoints only.
Remove its directory, the gallery file and the LFM2.5-Audio model
entries, the library and generated backend gallery entries, the
backend matrix and CI job, the Makefile targets and the golden test
fixtures that listed it.

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

* refactor(config): drop liquid-audio from capabilities, importers and UI

With the liquid-audio backend gone, remove its capability entry, its
importer, its artifact-gate entry and the realtime_audio heuristic
that only matched that backend. realtime_audio is now set only
through known_usecases. Update the Talk and fine-tuning pages, the
docs and the swagger text that named the backend.

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

* chore(llama-cpp): bump llama.cpp to e486a4b5

Move the llama.cpp pin to e486a4b559617dc88bf79978996f6c670b6bbc9d,
the master head of 2026-10-28. It contains the LFM3-Audio support
(ggml-org/llama.cpp#31257) and the realtime server split
(ggml-org/llama.cpp#31269).

The score task patch is regenerated against the new pin. It applies
with git apply and with patch -p1, with no offset and no fuzz. Its
hunks in server-task.h and server-context.cpp were rebased by hand
next to SERVER_TASK_TYPE_REALTIME and the control dispatch.

The C++ backend was built on CPU for this change: the llama, mtmd and
server-context libraries and the grpc-server binary link.

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

* feat(llama-cpp): compile server-realtime.cpp into grpc-server

server-context.cpp includes server-realtime.h, and its realtime slot
type is defined in server-realtime.cpp. Upstream compiles that file
into the server library. The gRPC server is one translation unit, so
it must include the file too, or the link fails.

The include is guarded by __has_include. Forks that predate the
realtime split do not ship the file, so they keep building as before.

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

* chore(llama-cpp): probe realtime and TTS voice support in prepare.sh

prepare.sh writes llama_compat.h from probes on the checkout. Add two:
LOCALAI_HAS_REALTIME (SERVER_TASK_TYPE_REALTIME in server-task.h) and
LOCALAI_HAS_TTS_VOICE (set_voice in mtmd-helper.h). The probes let the
gRPC server choose the realtime and voice code paths per checkout, so
forks without them keep compiling.

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

* feat(llama-cpp): advertise speech recognition and speech-to-speech

llama-cpp now registers the AudioTranscription and AudioToAudioStream
RPCs, with the transcript and realtime_audio usecases. The default
usecase stays chat, so a plain GGUF is still a chat model. The
backend implements the RPCs in a follow-up change; until then the
calls return UNIMPLEMENTED.

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

* feat(config): add pipeline.any_to_any for speech-to-speech models

A realtime_audio model that serves the whole speech loop itself sets
pipeline.any_to_any. The flag is off by default, so chained pipelines
and existing realtime_audio configs are unchanged. The realtime_audio
heuristic stays explicit: a llama-cpp model is never a Talk-page model
unless known_usecases says so.

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

* feat(config): read the audio encoder and speech-to-speech projector flags

Add HasAudioEncoderProjector for clip.has_audio_encoder and
HasSpeechToSpeechProjector, which requires the LFM3 audio encoder
family together with the gen-audio decoder. Qwen3-TTS sets both encoder
flags too, so the flags alone cannot tell it apart from LFM3.

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

* feat(importer): import LFM3-Audio projectors as speech-to-speech

An mmproj that carries the LFM3 audio encoder and the gen-audio decoder
now imports with known_usecases realtime_audio, chat, transcript and
tts, pipeline.any_to_any: true, the parallel:2 option and the tokenizer
template. Other gen-audio projectors keep the TTS-only result, Qwen3-TTS
included. The probe runs against a local HTTP server in the tests.

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

* feat(llama-cpp): named-voice TTS and the LFM3 ASR prompt

Models without a speaker reference (LFM3-Audio) take a named voice
through their "tts" chat template instead of a reference file. The
request is validated with needs_reference=false, the prompt is rendered
with common_chat_get_tts_prompt(), and the voice is set on the gen-audio
input. Qwen3-TTS Base keeps the reference-file rule. The branch is
guarded by LOCALAI_HAS_TTS_VOICE, so forks without named voices keep the
old behaviour.

Transcription takes its prompt from common_chat_get_asr_prompt(). LFM3
gets a system turn that names the language and an empty user turn.
Qwen3-ASR's preset is unchanged: fixed user text, no system turn, and a
caller prompt is still appended after a newline. The preset call is
guarded by a new LOCALAI_HAS_ASR_PRESET probe.

The pure parts sit in tts_request_options.h and transcription_prompt.h,
with standalone tests run by backend/cpp/run-unit-tests.sh.

Add the test-extra-backend-llama-cpp-lfm3 target. It runs the tts,
ttsstream and transcription specs against LFM3-Audio GGUF weights. It is
a manual run and is not wired into CI.

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

* test(e2e): mock AudioToAudioStream and pending realtime S2S specs

The mock backend serves AudioToAudioStream deterministically: session
created, commit with RMS, a tool call on the first turn with tools, then
transcript pieces and paced 24 kHz tone frames, with response.cancel
ending a reply as cancelled.

The realtime S2S specs cover the mock and real-weights variants. They
skip until the /v1/realtime S2S path lands, so the mock suite stays
green in CI.

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

* feat(llama-cpp): speech-to-speech AudioToAudioStream on the realtime slot

Implement AudioToAudioStream on upstream's realtime slot, so a model
that answers after the audio (LFM3-Audio) can hold a conversation over
the existing duplex RPC. The proto schema does not change: tools travel
in the config, tool calls leave as function_call_arguments.done and tool
results arrive as a function_call_output item.

realtime_session.h keeps what needs no model: the turn text derived
from the chat template (with a tool turn), the conversation state, a
filter that keeps <tool_call> blocks out of the transcript and drops
audio produced inside them, and the translation of slot results into
events. grpc-server.cpp wires it to the slot with control tasks and
parses calls with the parser the chat template selects.

The unit test uses a fake renderer. A ctest-only check renders the real
LFM3 template and compares the slot text with the full render.

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

* feat(gallery): add the LFM3-Audio-2B entries

Two llama-cpp entries for LiquidAI/LFM3-Audio-2B-GGUF, the Q4_K_M
default (lfm3-audio-2b) and the Q8_0 build (lfm3-audio-2b-q8), both
with the Q8_0 audio projector. Each declares pipeline.any_to_any and the
realtime_audio, chat, transcript and tts usecases the importer writes
for an LFM3 projector, with parallel:2 and the tokenizer template. The
Q4 entry lists the Q8 one as a variant.

File sizes and sha256 come from the Hugging Face API for the pinned
repo revision. Speaker reference files and the VAD model are not part
of the entries: VAD stays optional.

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

* docs: describe LFM3-Audio speech-to-speech and its gallery entries

Adds a speech-to-speech section to the Realtime API page (any_to_any
configuration, VAD versus manual commit, tools, transcripts, barge-in,
limits), a pipeline.any_to_any row, a llama.cpp speech-to-speech row in
the compatibility table, and LFM3-Audio mentions on the TTS and ASR
pages. The feature is marked experimental: the behaviour follows the
backend session contract and has not been run with real weights.

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

* feat(realtime): run any-to-any models on AudioToAudioStream

A realtime_audio model with pipeline.any_to_any now opens one duplex
stream to the backend and keeps it for the session, replacing the
"any-to-any models not implemented" branch. The chained pipeline is
unchanged.

The endpoint forwards input audio (with a pre-roll from speech start
when a VAD model is set, otherwise every chunk and a client commit),
sends response.create, and turns the backend's events into the usual
realtime events: audio and transcript deltas, tool calls through the
existing function_call items, and a single response.done. A function
call output is forwarded before the next response. Barge-in sends
response.cancel ahead of the new speech and stops forwarding audio at
once. The user's speech is transcribed on the side and reported on the
user item; a failure there does not touch the response.

Server VAD is on only when pipeline.vad is set. Classifier mode,
compaction, streaming.llm, voice gating, sound detection and
semantic_vad are rejected for these models.

The mock backend now matches the llama.cpp contract (session.updated,
the cancel error when idle) and its VAD reports the loud stretches of
the audio, so the S2S e2e specs run a VAD turn. The specs are enabled.

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

* test(e2e): add the real-weights realtime S2S variant and the gRPC s2s spec

The realtime S2S specs now check reply audio on real models: duration,
loudness, clipping, and keywords found in the model transcript or in a
separate ASR of the output audio. run-realtime-lfm3.sh stages the llama-cpp
and sherpa-onnx backends, the LFM3-Audio files and the sherpa VAD, ASR
and TTS models, then runs the real variant. It is manual and never part of
CI: `make test-realtime` still filters out real-models.

The backend suite gains an s2s capability (spoken turn, tool call, tool
result, spoken answer over AudioToAudioStream) and optional TTS duration
and RMS bounds plus a transcript keyword check, all enabled for
test-extra-backend-llama-cpp-lfm3.

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

* test(e2e): expect the idle-cancel error from the mock S2S

The mock now answers a response.cancel with no reply running with
response_cancel_not_active, as the llama.cpp backend does. The cancel
spec still treated that cancel as a no-op and read the error as extra
data after the stream ended.

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

* feat(talk): show any-to-any models as one speech-to-speech model

/api/pipeline-models lists a pipeline.any_to_any model with any_to_any
set and its optional VAD. A realtime_audio model that is not any-to-any
is listed only when it has the four pipeline slots, since the session
takes the chained path for it.

The Talk page shows a speech-to-speech model as one model: no four-slot
view, the VAD slot only when a VAD model is set, and the female and male
voices offered. Without a VAD model the server does not detect turns, so
the page offers Done speaking, which sends input_audio_buffer.commit and
then response.create. Text-only replies stream through
response.output_text.delta and .done.

self_contained stays in the response for clients built before any_to_any.

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

* fix(realtime): refuse a llama-cpp realtime model without pipeline.any_to_any

llama.cpp has no VAD, transcription or TTS RPCs, so filling the empty
pipeline slots with the model name failed later with UNIMPLEMENTED.
Fail at session start and name the missing setting instead.

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

* fix(realtime): hold manual-commit S2S audio until the commit

In manual-commit mode the endpoint streamed every appended chunk to the
backend, so input_audio_buffer.clear only emptied the Go buffer while the
model kept the audio, and idle audio filled its context between turns.
Keep the audio in the input buffer and send it, behind pending text and
tool-result items, when the client commits or asks for a response.

Frames and controls now go through one ordered write queue, so no gRPC
send happens under the audio buffer lock. A tool result created while a
VAD turn is open is refused with a clear error. When the stream ends
outside a response the session reports it and closes, and a bare EOF
from Send reports the status that Recv holds.

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

* test(e2e): drop the S2S readiness gate and stale comments

The gate was always true. Some comments referred to work items that
readers of the repository cannot see.

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

* docs(realtime): add the liquid-audio migration note and the clear semantics

Say that the Python liquid-audio backend is gone, which gallery entries
replace it and that LFM2.5-Audio has no replacement. Describe what
input_audio_buffer.clear drops for speech-to-speech sessions now that
manual-commit audio is held back until the commit.

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

* fix(talk): do not promise turn detection when no VAD model is set

The idle note said the server notices when you stop talking. A
speech-to-speech model without a VAD model needs Done speaking, so the
note now says that.

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

* test(e2e): make the mock S2S backend keep the llama-cpp session limits

The mock accepted what the real backend refuses and hid two endpoint bugs.
It now treats output_modalities from the config as a fixed session mode and
picks the default per response from the tools set at that time. A fixed text
plus audio mode with tools reads the tool call out loud. After the first
commit or message, session.update of instructions, voice or tools is an
error that does not belong to a response, committing an empty buffer is an
error, and response.create while a reply runs is refused. Errors of a
refused response.create carry response_scoped in their meta.

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

* fix(realtime): leave the S2S output mode to the backend unless the client sets one

The stream config sent text plus audio when the session had no tools yet.
The llama-cpp backend keeps a mode from the config as a fixed session mode,
and tools added later by session.update never cleared it. The model then
read tool calls out loud. Send output_modalities only when the client chose
them. The backend picks the default of each response from the tools it has
when the response starts.

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

* feat(llama-cpp): mark the realtime errors that answer a response.create

The error event now carries response_scoped: true when it ends the request
for a response (a refused response.create, a failed commit made by it, or
a stream failure). Other errors, such as a refused session.update or a
commit of an empty buffer, are not about a response and one that runs
keeps generating. The client needs the difference to know whether a
response.done is still coming.

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

* fix(realtime): end an S2S response only on the errors that belong to it

Every backend error ended the response in flight, although the backend
also reports errors about other things (a refused session.update, a commit
of an empty buffer) and goes on generating. The rest of the reply was lost
and the next response.create met either a refusal or the leftover events of
the old reply. End the response only on an error marked response_scoped,
forward the others to the client and keep waiting for response.done. Drop
the events buffered by an earlier response when a new one starts, and skip
the commit of a turn that forwarded no audio.

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

* fix(realtime): run the S2S input transcription detached from the commit slot

The commit waited for the side transcription of the user audio while it
held the commit slot, and response.create waits for that slot. The ASR
latency was added before every reply, and on a backend with few slots a
busy ASR task could stall the reply. The model already has the audio, so
run the transcription detached under the session context and release the
slot at once. The transcript is still reported on the user item.

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

* test(realtime): do not pin the place of the input transcript in the S2S order

The transcript of the user audio is reported on the side and may land
before or after the first response events. Check the order of the response
events without it.

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

* fix(realtime): stop an S2S reader leak and a cancel that waits on the write queue

The reader waited on a channel it closes itself when the events buffer was
full, so it outlived the session after a turn ended on an error or timed
out. It now also stops when the session closes.

Stopping a response sent the cancel through the write queue and waited for
it before the grace timer started. With backpressure on the stream the wait
never ended and nothing read the events meanwhile. The timer now starts
first and the cancel is only queued.

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

* feat(realtime): let a chained pipeline run without a VAD model

A pipeline that names no pipeline.vad failed to build, because the
VAD slot was always loaded, and a new session still started with
server_vad. The slot is now optional for chained pipelines as it
already is for any-to-any models: the session starts with turn
detection off and the client commits each turn. Turning server_vad on
for such a session is refused, and a switch to a pipeline without a
VAD drops turn detection.

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

* feat(gallery): add LFM3-Audio VAD and chained-pipeline entries

Add lfm3-audio-2b-vad and -q8-vad (any-to-any with silero-vad-ggml as
the turn detector), and lfm3-audio-2b-pipeline, -pipeline-vad and the
-q8 forms (the model chained through its own transcription, chat and
speech slots, with and without the VAD). The gallery has no
dependencies between models, so the VAD entries state that
silero-vad-ggml must be installed first.

Drop the variants list from lfm3-audio-2b: it let the installer pick
the larger Q8 build on any host with enough memory, so a plain install
of the Q4 entry did not get Q4. All entries are now independent. Tests
cover the entry shapes, the single-build install and their acceptance
by the realtime endpoint.

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

* test(e2e): run the realtime specs for any-to-any and chained shapes

The realtime specs covered one any-to-any model with a VAD. They now run
for any-to-any and for a chained pipeline, each with and without a VAD,
on the mock backend in CI. VAD-less shapes also check that turn
detection starts off and that server_vad is refused. The manual
real-weights script writes one config per shape that mirrors the gallery
entries and runs the four shapes in turn (LFM3_MATRIX picks a subset).

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

* docs(realtime): document the LFM3-Audio VAD and chained-pipeline entries

List the eight LFM3-Audio gallery entries, explain that silero-vad-ggml
must be installed before the -vad entries, describe the chained pipeline
on one model and the behaviour of a pipeline without a VAD, and
describe the shape matrix of the e2e specs.

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

* test(realtime): wait for the detached input transcription in the S2S specs

The input transcription runs in its own goroutine, so the transcription
completed and failed events can arrive after the response ends. Poll for
them instead of reading the event list right after the response.

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

* fix(realtime): release the S2S stream before running server-side tool calls

A response held the stream slot until it returned, and it ran the tool
calls from inside that scope. A server-side tool starts a follow-up
response on the same stream, which then waited for the slot its parent
still held, so the client never got response.done. Run the tool calls
once the slot is free; the event order is unchanged.

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

* fix(realtime): end an S2S session whose stream dies, and stop queuing after close

A stream that died during a response left the session open: later
requests failed one by one and VAD frames kept being forwarded. The
response now ends the session through the transport, and enqueue drops
requests once the session is closed so the write queue cannot grow.

The writer is now the only caller of Send and of CloseSend. close()
used to call CloseSend while the writer could be inside Send, which
gRPC does not allow.

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

* fix(realtime): close the open S2S turn when the VAD stops

Switching turn detection to manual in the middle of a VAD turn left the
turn forwarding, and the next client commit sent the whole buffer a
second time. Stopping the VAD now commits the forwarded audio on the
backend and drops it from the input buffer.

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

* fix(realtime): keep a tool result pending while an S2S user turn is open

The check that refuses a tool result during a user turn ran when the
item arrived, but the item was forwarded later, and the result was
marked as sent before the backend took it. If the user spoke in
between, the backend refused it and it was never retried. The result is
now claimed and queued under the audio lock only when no turn is open,
and otherwise stays pending for the next commit or response.

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

* fix(talk): empty the input buffer for models without a VAD

Without voice detection the microphone fills the server's input buffer
for the whole session, so Done speaking committed idle silence and the
echo of the previous reply. Clear the buffer when the session starts and
when a reply ends.

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

* fix(importer): keep an existing parallel option on LFM3-Audio imports

The speech-to-speech import added parallel:2 even when the options
already carried another parallel value. Use the same check as the
hardware defaults, which also covers n_parallel.

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

* fix(talk): list chained pipelines that have no VAD

/api/pipeline-models required a VAD for a chained pipeline, so the
VAD-less LFM3-Audio pipeline entries never showed up on the Talk page.
Any pipeline with transcription, LLM and speech slots now qualifies.
The Talk page treats every model without a VAD as manual-turn, and the
session sheet says so for chained pipelines too.

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

* chore(llama-cpp): bump llama.cpp to 0ac1a7fd

Move the llama.cpp pin to 0ac1a7fd35c127a18c0cbc6c57d12e256a6a385b,
the master head of 2026-10-29. It contains tool calls in the realtime
server (ggml-org/llama.cpp#31287).

The score task patch is regenerated so that it applies with no offset
and no fuzz.

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

* fix(functions): keep a null tool argument null

A tagged parameter whose text was "null" came out as an empty string:
unmarshalling JSON null into a string succeeds and leaves it empty, so
the value was taken for a string. Check the first byte of the JSON
value instead. A parameter with the text "null" now becomes JSON null,
as in llama.cpp.

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

* feat(functions): add an lfm3 XML format preset

The LFM3 chat template writes a call as <tool_call>, <function=NAME>,
one <parameter=KEY> block per argument, then the closing tags. The
qwen3-coder format has the same shape and auto-detection already found
it, but xml_format_preset: lfm3 was not a known name.

Add the preset, with tests for one call, a call without arguments, two
calls, string and non-string values (numbers, booleans, null, arrays,
objects), a call between text, and unfinished input.

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

* feat(realtime): parse tool calls with the chat template, as upstream

Since ggml-org/llama.cpp#31287 the realtime server handles tools with
the parser and grammar of the chat template. Do the same in the
speech-to-speech stream instead of a hard-coded tag filter and a parse
of the whole text at the end.

- Each response gets a common_chat_session with the tools of the
  session and parallel calls. Its grammar and preserved tokens go into
  the sampling of the response, so the slot keeps the call tokens in
  the text and constrains the call.
- The text of the response is fed to the session piece by piece
  (task_result_state::update_chat_msg). Content deltas become the
  transcript; tool-call deltas are collected into one
  function_call_arguments.done per call at the end. Audio after the
  start of a call is dropped.
- A tool_choice of auto, none or required is accepted in the params of
  the config, in session.update and in response.create, with the
  response overriding the session.
- The tool turn is derived from a conversation in which the assistant
  called a tool, like upstream, not from a plain reply.
- response.done carries raw_text, everything the model wrote with the
  call markup, for a caller that parses it another way. If the parser
  throws, the rest of the response is content and the raw text is the
  way out.

ToolCallFilter is no longer used and goes in the next commit.

The unit tests cover the splitter contract (content, call deltas, the
final piece, whole characters only). realtime_chat_check feeds
LFM3-format text through the session in pieces of 1, 7 and all bytes
and checks the call, the transcript and the grammar.

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

* refactor(realtime): drop the tool-call text filter

The parser of the chat template now splits content from tool calls, so
ToolCallFilter, which looked for the literal <tool_call> tags, has no
caller. Remove it with its tests. Every case it covered (a call
between text, split tags, several calls, an unfinished call, text after
a call) is handled by the parser, and realtime_chat_check runs the
LFM3 format through it in pieces of different sizes.

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

* feat(realtime): fall back to the raw text for S2S tool calls

For an any-to-any model, the calls the backend parsed still win. When
there are none and the request had tools, parse the raw text that
response.done now carries the way the chained pipeline parses the text
of its LLM (CleanupLLMResult, ParseFunctionCall, ParseTextContent), so
a call format the backend parser does not know can still be served
with function.xml_format_preset or the other function settings of the
model. The content around the calls replaces the streamed transcript
only if the config captures it, as in the chained path.

Also pass tool_choice to the backend: the session mode with the stream
config and with session.update, and the mode of a response.create
override with that event. A named function cannot be forced, so it is
not sent.

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

* test(e2e): carry raw_text in the mock S2S response.done

Match the llama-cpp session: with tools set, response.done has raw_text,
the call markup of the LFM3 template for a tool turn and the transcript
for any other reply.

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

* fix(functions): parse tool blocks that have text between them

The iterative XML parser stopped at the first scope block that was
preceded by text, so "<tool_call>A</tool_call> then <tool_call>B" gave
one call. Skip text between blocks once a call was found; text before
the first block still ends the parse as before. Blocks that follow each
other, or only whitespace, already worked.

Tests cover three calls back to back and with text between them, with
the lfm3 preset and with auto-detection. They also pin the current
reading of argument values: the parser has no tool schema, so a value
such as 42 or true is read as JSON even when the schema types it as a
string.

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

* feat(gallery): read LFM3-Audio tool calls with the lfm3 preset

Set function.xml_format_preset: lfm3 on the eight LFM3-Audio-2B entries
and in the config the importer writes for a speech-to-speech projector,
so the server parses the call format of the LFM3 chat template when the
backend returns no call (and in the chained pipeline entries, which use
the regular chat path). gallery/index.yaml is regenerated from the
library.

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

* docs(realtime): describe how S2S tool calls are read

Replace the description of the hard-coded tag filter with the parser
and grammar of the chat template, tool_choice, and the raw-text
fallback in the server. Document function.xml_format_preset, and set it
in the real-weights e2e model configs like the gallery entries do.

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

* fix(functions): keep earlier calls when a later block does not parse

After a valid call, text that holds the opening tag again (a prose
mention, a markdown example, a malformed second block) made the parser
treat it as a new block, fail, and return no calls at all. Remember the
state after each complete block and fall back to it when a later one
fails: the calls found so far are kept, parsing stops there and the
remaining text stays in the content.

Text skipped between two valid blocks now goes to the content instead of
being dropped, so StripToolCallMarkup keeps it.

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

* fix(realtime): do not drop audio after a tool call started

In mixed mode the speech for text written before a call can still be
pending when the call starts, so dropping every audio frame after the
first call delta cut the speech while its transcript was sent. The
llama.cpp server never drops audio when it reads a result; do the same.
If a model speaks the call markup aloud, that is model behaviour to
observe on real weights, not something to filter here.

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

* fix(realtime): flush held-back text when a response is cancelled

The parser got is_partial=true on a cancelled response, so text it held
back (a possible start of a tag) never reached the transcript. The llama.cpp
server parses a cancelled response with is_partial=false; do the same. A
cancelled response still makes no tool call. The path where the parser
throws already sends the text as content and needs no change.

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

* test(realtime): stop the fake S2S stream from sending after Close

The barge-in spec failed now and then with a data race and "send on
closed channel": its script pushes audio from a goroutine, and the
session closes the stream while that goroutine still runs. A push now
does nothing once the stream is closed, and Close waits for a push in
flight. The production close order is right: the writer closes the
stream only after it stops sending.

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

* fix(realtime): align the S2S tool-call fallback with the backend

Four gaps in how the Go side reads the tool calls of an any-to-any reply:

- With tool_choice none (the response value wins over the session value)
  the backend builds no tool parser, yet the raw-text fallback still
  turned a call the model wrote into a function_call item. Skip it.
- Without a capture regex the fallback never cleaned the transcript, so the
  stored item and transcript.done kept the raw call markup. When the call
  is XML, use the text left after the markup is cut out; a reply that is
  only a call now has an empty transcript.
- A named function as tool_choice mapped to nothing, so the backend kept
  the previous mode. Refuse it with an error in session.update and
  response.create.
- A response-level tools list changed what the fallback looked for, but
  the backend only has the tools of the session. Use those in both.

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

* docs(realtime): say how S2S tool_choice and response tools behave

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

* fix(e2e): use the real llama-cpp backend in the real realtime run

The suite always registered the mock backend under the name llama-cpp, so
a run with REALTIME_S2S_MODEL set never reached the llama-cpp backend
found in REALTIME_BACKENDS_PATH. Register the mock only when that run is
off.

The real run converts the input audio with ffmpeg. Check for it in the
suite and in run-realtime-lfm3.sh before any download or backend start,
with a message that says what to install, and list it as a prerequisite
in the script header and the Makefile target.

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

* test(realtime): cover the tools path of a client session.update to the S2S backend

A tool call that never came back from an any-to-any model raised the
question whether the tools of the session reach the backend at all. Add a
spec that sends the session.update of the real-weights e2e (flat
Responses API tool, no session type) and checks that the backend gets
the tool in the chat-completions form.

Log the number of tools sent when the stream opens and when a
session.update carries them, so a run log shows it without reading the
wire. The "No tools received" line of the llama-cpp backend does not
show it: the server prints it for the input transcription request, which
has no tools by design.

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

* test(e2e): interrupt the chained pipeline reply while it is in flight

With unary stages, the default of the lfm3 pipeline gallery entries, the
chained pipeline synthesizes the whole reply and sends it as one audio
delta right before response.done. The barge-in spec waited for that
delta and then rendered the interruption with the TTS, so the first
response was already done when the speech started and ended completed
instead of cancelled. Real runs on a GPU showed it with both
quantizations.

For a pipeline, start the interruption at response.created, which comes
before the LLM and TTS stages. Render the interruption audio before the
first utterance is sent, so nothing sits between the reply and the
interruption. The any-to-any shape still waits for the first audio
delta, which that backend streams while it generates.

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

* test(e2e): accept a number word and its homophone in the spoken-answer check

The check that the reply audio says what the transcript says needs a
shared word of 4+ letters. A short answer failed it: the model wrote
"Four" and the ASR heard "for". Compare numbers in one spoken form, so
digits, number words and their homophones match each other. A homophone
only counts when the other text has the number itself, so "for" on both
sides still shares nothing and the check does not pass on a stop word.

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

* fix(llama-cpp): ask LFM3-Audio for function calls when a realtime session has tools

The default system prompt of a realtime session was "Respond with
interleaved text and audio." whatever the tools. Under that prompt
LFM3-Audio answers in words ("I'll check the weather ...") and never
writes the call. The model card, and the llama.cpp realtime example
client, use "Respond with function calls, and interleaved text and
audio." when the session has tools, and the model then calls the tool.

Pick the default by the tools of the session, and treat the plain default
that the LocalAI server sends back as no choice: it follows the tools too,
also when they arrive with a session.update before the first audio. A
prompt the client wrote is kept as it is.

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

* fix(realtime): transcribe the input of an any-to-any turn between responses

The input transcript of a speech-to-speech turn ran at the same time as
the reply, on the same model. llama.cpp serves both from one context: the
realtime slot wants embeddings on, the chat slot of the transcript wants
them off, and each flip makes it reserve its compute buffers again. In a
GB10 run that was two reserves of about 175 ms per step, the reply fell to
2.6 tokens per second and the transcript to 380 ms per token, until the
transcript was done.

When pipeline.transcription is the model of the stream, run the transcript
after the response, or before one that is not started yet, and keep
responses out while it runs. A separate transcription model still runs in
parallel.

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

* fix(realtime): send the input transcript of an any-to-any turn before its reply

The previous change moved the same-model transcript after the response.
A client that reads until response.done then never saw
conversation.item.input_audio_transcription.completed: the transcript
was made (the log shows it) but sent after the end of the window.

Run it in line with the commit instead. A VAD turn transcribes and then
responds. A client response.create already waits for the commit slot, so
it starts once the transcript is out. The transcript event now comes
before any output of the response of its turn, for a manual commit and for
a VAD turn, and never overlaps the response on the llama.cpp context. A
transcription model of its own still runs in parallel.

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

* feat(realtime): transcribe the input of an any-to-any model only when asked

The stream answers from the audio, so the transcript of the input is for
the client only. On the model of the stream it is costly: llama.cpp
serves the transcript request and the realtime stream from one context
and rebuilds its compute buffers on every step while both are active.
On a GB10 that was 26 to 29 reserves of about 175 ms per turn, against 0
to 1 on the llama.cpp server, and the reply ran at a few tokens per
second.

An any-to-any model no longer fills pipeline.transcription with its own
name. Without it the session does not transcribe the input; with another
model it does, in parallel with the reply; with the model itself named it
does, before the reply. The reply transcript always comes from the stream.
The gallery entries set none, so they get the fast path.

The mock configs and run-realtime-lfm3.sh name a transcription model, so
the real-weights spec still covers the input transcript.

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

* fix(realtime): end a response as cancelled when a barge-in lands during the LLM call

The chained pipeline cancels the response context on a barge-in. When that
happens while the model is generating, the backend call fails with the
context error, and the response ended as failed: an error event and no
response.done. A client that waits for the terminal of the interrupted
response then waits forever. On a GB10 the q4 model answered before the
barge-in arrived, so only the slower q8 model showed it: the spec read
events for 30 s and timed out.

When the context is cancelled, a failed Predict call or a failed result is
a cancel, as the TTS step and the streamed LLM path already treat it. The
response ends with response.done{cancelled} and no error.

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

* test(e2e): wait for the input transcript that comes from a model of its own

An any-to-any model now gets its input transcript from a separate ASR
that runs next to the reply. The reply is often over first, so a spec that
stops reading at response.done saw no transcript in the VAD shape. Keep
reading, up to 30 s, until the transcript arrives. The contract is that it
comes, not when.

Add a guard spec for what the input transcription is handed: the whole
turn, in a WAV that says the rate it was recorded at (the session rate for
a client commit, 16 kHz for a VAD turn).

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

* test(e2e): check the errors of Close in the realtime specs

golangci-lint (errcheck) flagged the unchecked Close of the websocket
connections, the response body and the fake stream. Use the
`defer func() { _ = x.Close() }()` form the other e2e specs use.

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

* test(e2e): write the mock S2S specs with Ginkgo and drop the env read

forbidigo only allows Ginkgo/Gomega specs and no os.Getenv outside the
sanctioned places. Convert the mock S2S tests to Describe/It with a suite
bootstrap (the package had none), with the same 11 cases. The mock no
longer reads MOCK_S2S_FRAMES: only these specs set it, so the reply length
is a package variable they change per spec and restore afterwards.

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

---------

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

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

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

Sessions with announce off and response sessions do not change.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:07:02 +02:00
mudler-agentandEttore Di Giacinto 756b019ce4 [httpclient] validate redirect targets and pin dialed addresses for user URLs (#12853)
fix(httpclient): validate redirect targets and pin dialed addresses for user URLs

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

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

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

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

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

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-23 11:54:42 +02:00
mudler-agentandEttore Di Giacinto e45dc5e9f7 feat(addons): installable add-ons foundation and the Tables add-on (#12702)
* feat(addons): add the add-on runtime, navigation and first-party add-ons

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(addons): describe backends in the library

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

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

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

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

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

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

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

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

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

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

This reverts commit 7493cff727.

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

* Revert "docs(addons): describe backends in the library"

This reverts commit 57816ed7e6.

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

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

This reverts commit 49b2b151b7.

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

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

This reverts commit 1d8d3e3421.

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

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

This reverts commit 484813f856.

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

* fix(ui): drop the backend library keys from the locales

Revert the part of 39f77ae29 that added strings for backend libraries:
the kind labels, the backend install texts, the signer identity pattern
and the skipped entries notice. The add-on library keys stay.

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

* feat(ui): add an add-ons control to the chat header

Add-on tools were on or off only through the /addons command, with a toast
and no state a person could see. The chat header now has an Add-ons button
in the style of the MCP dropdown. It lists each running add-on that ships
tools with a switch, and a badge shows how many are on.

The chat keeps the ids that are on (addonToolIds) next to the older
boolean, and both are saved with the chat. A chat that has only the boolean
means every add-on. The request sends addon_tools alone when every add-on is
on, and adds addon_tool_ids when only some are. /addons turns all on or all
off and uses the same state.

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

* feat(addons): let a chat request name the add-ons whose tools it uses

addon_tools=true still offers the tools of every add-on the caller may use.
A new addon_tool_ids metadata key, a comma-separated list of add-on ids,
narrows that to some add-ons. The list counts only with the opt-in, and a
list that names nothing offers no tool rather than all of them.

The executor drops the tools of add-ons that are not listed and refuses a
call to one as an unknown tool, so a model cannot reach a tool the person
turned off. Both keys are consumed so they do not reach the backend.

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

* test(addons): cover the per-add-on tool list and the chat header control

Go tests cover the list form (the list filters the tools offered and the
calls allowed) and the unchanged boolean form. Unit tests cover the chat
add-on rules. Playwright specs cover the header control: listing only
add-ons that ship tools, toggling each one, the count, /addons in step with
the switches, and the request metadata.

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

* test(ui): check each add-on page has one title when not enabled

The shell names an add-on only where the add-on page cannot mount (not
enabled, failed, no UI, bundle not loaded, no such page). Once the page
mounts it draws its own title, including when it says it is not enabled
on this server, and the shell draws none. The shell code already works
this way; this change states the rule beside the unavailable state and
pins it in the specs.

The shell spec now expects exactly one title in the unavailable states.
The navigation spec checks the Face page without a face model (the
add-on title only) and the Face page of a stopped add-on (the shell
title only).

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

* feat(addons): upgrade add-ons on their own when the setting is on

A new auto_upgrade_addons setting (flag LOCALAI_AUTO_UPGRADE_ADDONS,
default off) lets the owner upgrade an installed add-on when its library
lists a newer version. It follows the backend setting: a startup flag, a
runtime setting, and the same schedule as the backend update check (a
first pass shortly after start, then every six hours).

Each pass fetches the library of every installed add-on, and for each
available update builds the same pinned upgrade command an administrator
sends and runs it through the same lifecycle path. The channel rules, the
signer identity, the digest checks, the SDK compatibility check and the
backup before the upgrade all apply unchanged. The only difference is the
gate: the owner acts on its own setting, so no administrator is asked.
A refused or failed upgrade changes nothing and is logged; a candidate
that failed is not retried until the library lists another digest.

With the setting off, nothing runs and upgrades stay manual.

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

* feat(ui): add the Upgrade add-ons automatically toggle to Settings

The toggle sits beside Upgrade backends automatically and is off by
default. It saves the auto_upgrade_addons runtime setting. The English
locale gets the label key, and the Settings test fixture lists the new
field.

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

* test(addons): cover the automatic upgrade setting and document it

The setting defaults to off in the config, the runtime settings and the
flag, and is read back from the saved settings file. With it on, a real
owner upgrades an installed add-on through the manager; with it off,
nothing changes, and a change of the setting at run time takes effect on
the next pass. The manager specs show the upgrade goes through the same
pinned path as the manual one, and that a corrupted package, a failed
signature policy, an incompatible release, a missing update checker and
a caller who is not an administrator each leave the installed version in
place. The new specs are added to the lifecycle case inventory.

The add-ons page describes the setting, its schedule and its default.

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

* refactor(addons): name the library entry identifier id

The library entry of an add-on used addonId while the shared envelope
and the model entries use id. Release now has the field ID, written as
id, and every writer emits it: the index encoder, the package workflow
templates, the catalog generator and gallery/addons/index.yaml. The
digests in the index did not change.

DecodeIndex still reads addonId as a legacy alias for one release, so
an existing library and a library served by an older tool keep
working. An entry that holds both names is refused. The Apps UI reads
id and falls back to addonId.

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

* test(addons): use the id field in the auto-upgrade test release

The auto-upgrade test arrived after the identifier rename and still
named the release field AddonID, so the application tests did not
build.

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

* fix(addons): annotate the auto-upgrade actor label for gosec

The actor label looks like a credential to gosec G101. It is a fixed name for upgrades the owner starts itself.

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

* fix(schema): persist the per-chat add-on tool fields

The chat header persists addonTools and addonToolIds, but Conversation had no fields for them, so the server dropped them on save. Add both and update the generated swagger files by hand because swag is not installed here.

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

* test(ui): count the add-ons auto-upgrade setting in the settings groups

The setting sits in the Backends and galleries group, once. The page now lists 54 settings plus the three branding images, so the spec expects 57.

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

* test(ui): pause the fake clock ahead of now in the clear-all spec

The fake clock runs from install, so pausing at the current time failed with 'Cannot fast-forward to the past' on a slow runner. The save itself is synchronous and was not lost.

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

* test(ui): hold the Studio capabilities reply longer in the skeleton spec

800 ms was shorter than a slow runner needs to render the page, so the skeleton was gone before the check. The product shows it.

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

* feat(i18n): add the French add-ons locale

Master added the fr locale after this branch forked. The add-ons unit test requires every locale to carry the add-ons namespace, so fr needs its own addons.json.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-22 17:57:19 +02:00
Stefan Walcz b56b903c92 feat(config): let model aliases carry an opt-in profile block (system prompt, MCP, client tools) (#12661)
* feat(config): let model aliases carry an opt-in profile block

An alias can now act as a profile of its target through an explicit
`profile:` block carrying system_prompt, mcp (remote/stdio servers, plus
the new mcp.auto) and the new client_tools. ResolveAlias applies only
that block on top of a copy of the target, while Name, Model, Backend
and the persisted revision stay the target's. The loader key is
therefore unchanged and every profile runs on the target's backend
process; today a persona needs a separate model config, which loads the
weights a second time.

The block is opt-in so existing aliases never change: top-level fields
on an alias stay ignored, and an alias without a profile block resolves
to exactly the target. A profile block on a non-alias config is
rejected.

A profile with its own MCP servers caches its sessions under the alias
name (MCPSessionKey), because the cache slot keyed on the target's name
is invalidated whenever its config hash changes and two server sets
would tear each other's sessions down.

mcp.auto enables all configured servers on /v1/chat/completions when the
request does not name any in metadata.mcp_servers, which plain OpenAI
clients cannot send. client_tools: drop discards client tools and
tool_choice before the request is merged (chat completions, responses,
Anthropic messages), so only the model's own MCP tools are offered.
Both keys work on any model and inside an alias's profile block.

Refs #12548

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* fix(openai): only auto-enable model MCP when MCP is enabled and permitted

mcp.auto turns a plain /v1/chat/completions request into a server-side
MCP request. It now applies the same controls as the dedicated MCP
routes before any server is selected: the instance switch
(LOCALAI_DISABLE_MCP) and, with auth on, the caller's "mcp" feature,
checked with auth.HasFeatureAccess against the auth database that the
chat route now passes to ChatEndpoint.

A denied request is answered as a plain chat, without contacting a
server or adding tools, rather than rejected: the client did not ask
for MCP, it only reached a model that offers it.

Endpoint specs drive ChatEndpoint against a counting MCP fixture server:
MCP disabled and a user with chat but without mcp see no tools and no
connection; MCP enabled and a user with mcp get the fixture tool. The
user specs need the auth build tag (SQLite auth database).

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* fix(mcp): resolve profile aliases in the MCP discovery endpoints

GET /v1/mcp/servers/:model, /v1/mcp/prompts/:model and
/v1/mcp/resources/:model looked the model up with GetModelConfig, which
returns the alias stub. A profile alias carries its servers in its
profile block, so the stub has none of its own and the endpoints
reported an empty list for an alias whose chat requests do use tools.

Resolve the alias the way the request middleware does before reading the
MCP config, and open the sessions under MCPSessionKey so discovery shares
the profile's session cache slot instead of tearing down the target's.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* feat(config): opt-in evidence check for profile aliases

A profile with server-side MCP tools (profile.mcp, mcp.auto) runs its
tools on the server and returns only the answer text. On the next turn
the client sends the history back without those tool calls and results,
and the model then tends to skip the tool and answer from nothing. In a
measurement on a follow-up question, 0 of 12 turns called the tool and
the answers carried invented numbers (e.g. "0" where the true value was
9); with the tool trail kept in the history the tool was called 12 of
12 times. A prompt instruction does not reliably prevent this, so the
check sits at the output.

New profile keys:

  profile:
    evidence_check:
      numbers: true   # enables the check
      note: "..."     # optional, defaults to an English note

Rule, per request and stateless: when the check is on, no MCP tool call
succeeded on the server during this request (a call that returned an
error delivers no evidence and does not count), and the final answer contains a
number (\b\d+([.,]\d+)?\b) that occurs in none of the request's input
(the profile's system prompt and every message as received, before
compression), the note is appended after a blank line. "3,5" and "3.5"
count as the same number. In a streamed response the note is one extra
content delta before the finish chunk. Choices that hand tool calls to
the client are not checked. The setting is only read from a profile
block, so plain models and aliases behave as before; a note without
numbers: true is rejected at validation.

Tests (Ginkgo): the number rule (invented number, number from the input,
no numbers, comma/dot decimals, system prompt as evidence, tool ran),
the overlay into the resolved config with plain alias, target and a
top-level key as counter-checks, validation, and ChatEndpoint specs on a
stubbed backend for both paths: note on an invented number, none for a
number from the request, none for a profile without the check, none
after a successful MCP tool call against an in-process MCP server, and
the note again after a failed tool call (arguments that are not JSON).

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

---------

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-10-19 23:42:40 +02:00
mudler-agentandEttore Di Giacinto f85087f992 feat(tts): stream Qwen text and add assistant preset (#12770)
* feat(tts): stream Qwen text through audio pipelines

Feed cleaned LLM deltas into one persistent Qwen synthesis session.
Return audio before input ends and preserve the live RPC through model
cache, staging and in-flight wrappers. Stop generation on sink failures
or cancellation without replaying speech through a fallback.

Pin the incremental native fork and verify early audio, late text,
finalization and cancellation with lifecycle and real-model checks.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* build(tts): use the organization Qwen fork

Fetch the reviewed incremental synthesis commit from localai-org so
backend builds use the organization-maintained fork.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): add a Qwen streaming assistant preset

Expose an English pipeline using existing VAD, ASR, LLM and named-voice
TTS models. Enable incremental text and audio delivery, and document
separate component installs because the gallery has no dependency
installer.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): use Parakeet TDT v3 for the assistant

Select the existing Parakeet TDT 0.6B v3 transcription entry and update
its prerequisite install command. Keep the preset's English voice and
incremental Qwen synthesis configuration.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(kokoros): reject unsupported backend RPCs

Implement the MotionStream and SlotCache methods required by the shared
protobuf service. Return Unimplemented so Kokoros compiles without
advertising support for either operation.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(tts): mark cleanup and logging results ignored

Explicitly ignore connection shutdown, test server shutdown and
Ginkgo diagnostic writer errors. These operations do not replace the
stream's inference or sink errors, and errcheck requires their return
values to be acknowledged.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(tts): stream buffered clauses in realtime pipelines

Start clause synthesis while LLM text is still arriving when both streaming flags are enabled. Keep explicit clause opt-out and native Qwen sessions, bound automatic queued work, and choose streaming support on resolved failover targets.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(tts): bound synthetic voice clone generation

The sine-wave reference checks speaker conditioning and WAV output, but does not provide a meaningful speech EOS target. Limit that smoke request to 128 frames and verify the request limit reaches native synthesis. Leave production sampling defaults unchanged.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(tts): require explicit clause buffering opt-in

Keep whole-reply synthesis for nonnative engines when clause_chunking is unset or false. Use the bounded cancellable worker when explicitly enabled, with unary clauses when TTS streaming is disabled. Preserve native Qwen precedence for every clause setting.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): add VITS and Piper streaming assistants

Provide comparable full pipeline configurations with shared VAD, Parakeet transcription, and Qwen chat components. Enable clause buffering explicitly and document the separate prerequisite model installs and Talk selection.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-19 12:30:52 +02:00
Ettore Di Giacinto 008f9fdf81 fix(realtime): end a client response cleanly when its commit fails
A client response.create waits for the in-flight commit before the LLM
starts. Two exits left the client without a usable outcome:

- When the wait was cancelled (response.cancel, a newer response or
  teardown) the response returned with no event at all.
- When the commit produced no user item (transcription error, empty
  transcript, voice gate rejection) the LLM ran on a history that
  lacked the turn.

Send response.created and response.done with status cancelled for the
first case. For the second, do not run the LLM: send response.done with
status failed and an input_not_committed error. The commit still
reports its own cause. A response.create with no commit in flight is
unchanged. Add specs for STT error, empty transcript, cancel and
supersede during the wait.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-13 08:44:23 +00:00
Ettore Di Giacinto 3a9a67aeb9 refactor(realtime): gate client responses on the commit tail
A client response.create waited on a separate mutex-guarded channel
that only the last client commit recorded, so it ignored in-flight VAD
commits. The commit chain already ends in session.commitTail, whose
done channel closes after every earlier commit has finished.

Read the tail under commitOrderMu when the response is issued and wait
on it. This drops the extra mutex, the clientCommitDone field and the
slotSink callback of issueCommit, and the response is now ordered
against both commit producers. Fix the commitSlot.done comment: it
closes when the commit function returns, not when the user item is
appended. Add a spec for commit, commit, response ordering.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-13 08:44:22 +00:00
localai-org-maint-bot 2aa8821876 test(realtime): sample pending response events
Poll the event count during the held transcription instead of checking\na captured value. Document the client commit and response ordering.

Assisted-by: Codex:gpt-6
Signed-off-by: nexxtmobile.de <kai@nexxtmobile.de>
(cherry picked from commit 034086d3b0)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-13 08:41:00 +00:00
kai c27848c8c7 fix(realtime): wait for the client commit's STT before the response LLM starts
With client-driven turns (turn_detection null, e.g. a client VAD / speaker
gate) the client sends input_audio_buffer.commit and response.create
back-to-back. The response.create triggered the LLM immediately while the
commit's transcription was still in flight, so the LLM (fast) won the race
and answered an empty transcript; the response on the real transcript was
then discarded ("skipping response: turn context cancelled during
transcription").

Record the in-flight client commit's slot done channel (closed once the
commit has transcribed and appended its user item) and make the client
response.wait for it before the LLM starts, so the LLM only runs after the
STT is complete.

- issueCommit gains a slotSink callback (slot claim + issue stay under one
  lock); the VAD commit path passes nil.
- The client commit / response.create handler cores are extracted to
  issueClientCommit / issueClientResponse so the real path is unit-testable.
- Regression specs drive the real issue path with a held transcription: the
  LLM must not start until the STT completes, and it must see the user's
  transcript.

Signed-off-by: nexxtmobile.de <kai@nexxtmobile.de>
(cherry picked from commit 10555084bf)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-13 08:41:00 +00:00
Ettore Di Giacinto 28c70be87e fix(realtime): keep sound detection for skipped turns
A VAD commit that is skipped because interrupt_response is false runs
with an already cancelled response context. Sound detection used that
context, so the model call failed and the turn never got its
sound-detection event. Run it under the session context, like the
transcription and the voice gate, so overlapping turns keep their
sound events.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-13 08:40:25 +00:00
localai-org-maint-bot f22fd45e21 fix(realtime): honor interrupt_response
Keep active responses running when VAD interruption is disabled. Skip
an overlapping automatic response while preserving its transcript and
conversation history. Default interruption and explicit client actions
keep their existing behavior.

Add regression coverage for both VAD modes and document skipped turns.

Assisted-by: Codex:gpt-6
(cherry picked from commit f54023a2f9)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-13 08:38:31 +00:00
mudler-agentandEttore Di Giacinto 0177e4988c feat(images): add ESRGAN upscaling and Studio workflow (#12620)
* feat(images): add upscale model capabilities

* fix(config): keep SD GGML upscale opt-in

* feat(stablediffusion): add lazy ESRGAN upscale bridge

Keep native image ownership in C++ and serialize context creation, inference and teardown. Use upscale_scale in backend options as the explicit upscale-only marker because ModelOptions does not carry usecases.

Assisted-by: Codex:gpt-5

* fix(stablediffusion): validate ESRGAN settings

* fix(upscale): forward typed settings and contain resolved model paths

Typed settings replace legacy options without duplicates. Resolve model and trusted root symlinks before caching the model path, rejecting missing files and escapes.

Assisted-by: Codex:gpt-5

* fix(upscale): validate scale and clean failed HTTP outputs

Require an explicit scale bounded to 1-16, check backend results, and keep destination cleanup owned by the allocating endpoint. Cover validation, result failures, cleanup, and successful URL responses.

Assisted-by: Codex:gpt-5

* feat(studio): add inline image upscale workflow

Assisted-by: OpenAI:gpt-5

* test(studio): serve upscale capability fixtures

* fix(stablediffusion): default ESRGAN tiling to full frame

* feat(gallery): add RealESRGAN anime upscaler

* fix(stablediffusion): allow full-frame ESRGAN tiles

* fix(routes): select explicit upscale models

Give image upscaling dedicated usecase-filtered middleware and keep ordinary image models out of the endpoint. Mark and verify the legacy Diffusers x4 gallery model as an explicit upscaler.

Assisted-by: OpenAI:gpt-5 [shell]

* refactor(upscale): keep settings in backend options

* test(config): keep upscale options backend-specific

* fix: keep upscale routing metadata out of backend options

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-11 00:30:09 +02:00
localai-org-maint-botandlocalai-org-maint-bot c525ad16e8 fix(audio): record transcription usage and traces (#12546)
* fix(audio): record transcription usage and traces

Count successful transcription requests by model, including streams.
Keep token counts at zero because transcription exposes no token usage.

Capture multipart API trace metadata without reading uploaded audio.
Do not record failed transcription or client writes as successful usage.

Assisted-by: Codex:gpt-6-astra

* fix(audio): preserve aliases in streaming usage

Streaming transcription records the resolved target as its usage model.
Pass the requested name so JSON and SSE requests share the alias bucket.

Assisted-by: Codex:gpt-6-astra

* test(http): check multipart reader close errors

Assert successful reader cleanup to satisfy the errcheck CI gate.

Assisted-by: Codex:gpt-6

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-10-07 16:57:23 +02:00
localai-org-maint-botandEttore Di Giacinto 6b794651a4 feat(diarization): return sound events with include_sounds (#12544)
* feat(diarization): return sound events with include_sounds

A client that wants text, speakers, voice prints and sound events had to
make a diarization call and a separate sound call. Add an include_sounds
request field to /v1/audio/diarization that adds a sounds array of closed
events {start, end, label, confidence}, in seconds.

The parakeet-cpp backend runs a tagger-only scene stream over the clip,
the same stream and thresholds the live path uses, so a clip gives the
same events offline and live. A model with no sound_model companion, or a
backend that does not report sound events, fails with 501 and the stable
code include_sounds_unsupported instead of an empty list. The proto
carries sounds_included so an empty list still means "nothing heard".

The localai-proxy backend forwards the field. Swagger, docs and the
e2e mock backend are updated.

Assisted-by: Claude:claude-sonnet-5-5 [protoc swag go]

* feat(gallery): add parakeet-cpp-multilingual-diarization-speakers-sounds

Same as parakeet-cpp-multilingual-diarization-speakers (TDT 0.6B v3,
Nemotron-3-Diarization, WeSpeaker) plus a CED-Tiny sound_model, so one
model name serves /v1/audio/diarization with include_text,
include_speaker_profiles and include_sounds. It declares the
sound_classification usecase like the realtime scene entries.

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

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-07 14:10:45 +02:00
Ettore Di Giacinto fa25493c9e feat(config): let parakeet-cpp declare the speaker_recognition usecase
parakeet-cpp now implements VoiceEmbed and VoiceVerify, so list them in
its backend capabilities together with the speaker_recognition usecase.
The usecase is not guessed, because most parakeet models have no
speaker encoder: a bundle declares it in known_usecases, which is
enough for /v1/voice/* and for voice_recognition.model.

Add a realtime pipeline test where one bundle model names the vad,
transcription, sound_detection and voice_recognition stages and the
gate resolves the speaker through the backend's VoiceEmbed.

Assisted-by: Claude Code:claude-sonnet-5-5
2026-10-05 23:46:06 +00:00
Ettore Di Giacinto 1d117292b1 feat(voice): name registered speakers through a bundle speaker component
Naming read only speaker_model:, so a parakeet-cpp bundle that sets
speaker_component:voice never received the registered voices, in live
transcription or in diarization.

Resolve speaker_component: when speaker_model: is not set. A component
is not an encoder file, so no file-name tag is derived for it: voices
with a hash or family identity and untagged voices reach the backend,
and a voice with only a file-name tag matches through the new optional
speaker_tag: alias. speaker_model: still wins, including when it points
at the bundle file.

The bundle gallery entries do not declare speaker_recognition: that
usecase means the VoiceEmbed and VoiceVerify RPCs, which the backend
does not serve, and it selects the default model for /v1/voice/*. The
pinning test now says so.

Assisted-by: Claude Code:claude-sonnet-5-5 go golangci-lint
2026-10-05 23:45:14 +00:00
nexxtmobile.deandnexxtmobile.de 4d681b7f6d fix(realtime): detach VAD-commit transcription from barge-in cancellation (#12446)
* fix(realtime): keep VAD-commit transcription alive across barge-in, cancel it at teardown, order commits

Barge-in (new speech onset) cancels the turn's SourceVAD response context
(realtime_turncoord.go: respSink.cancel(SourceVAD)). The VAD commit body
runs under that same context, so an in-flight Whisper STT call was aborted
with 'context canceled' whenever the caller kept talking while the first
chunk was transcribing. The user's turn was lost: no transcript, no
LLM/TTS response.

v1 of this fix ran the transcription with context.WithoutCancel(ctx). The
review correctly pointed out two correctness gaps:

1. Teardown lost its cancellation. WithoutCancel detaches from every
   cancellation, so a transcription in flight at session close outlived
   the session and blocked respSink.shutdown (which joins the response
   goroutines) until the backend finished the job.
2. Out-of-order commits. Consecutive commits run in parallel goroutines,
   so a fast second transcription could append its user item before a
   slow first one: the conversation became [second, first] and the second
   response saw only [second].

Changes (core/http/endpoints/openai/):
- Session gains a session-lifetime context (sessionCtx), cancelled by
  conncoord's Teardown BEFORE respSink.shutdown joins the response
  goroutines. The transcription (and the voice-gate resolution) run under
  it: they survive barge-in (which cancels only the per-response context)
  but are cancelled with the session.
- Commit slots order the user-item appends in speech order:
  Session.nextCommitSlot() is claimed at commit issue time (VAD CommitTurn
  / client commit), a commit's item append waits on the previous slot's
  done (aborts on the session context), and every exit closes the slot so
  a failed or torn-down commit never blocks the next. Transcriptions stay
  parallel; only the appends are ordered.
- If the turn's response context was cancelled while the (detached)
  transcription ran — barge-in, superseded by a newer commit — the user
  item still commits (appendUserItem, split out of generateResponse) so
  the LLM context keeps the full user input, but no response is generated
  for the superseded turn; the newer speech triggers its own response on
  the complete history.
- Regression tests (realtime_commit_order_test.go) cover both review
  schedules — teardown during an in-flight transcription, and
  held-first/finished-second out-of-order completion — plus the
  barge-in-during-transcription item survival, driving the real commit
  path with a transcription double that honours context cancellation.
- docs/design/realtime-state-machines.md: implementation-status entry for
  the committed-turn pipeline (transcription lifetime + commit order).

Fixes #12445

Validated: builds, go vet clean, all openai specs + respcoord/turncoord/
conncoord suites pass under -race (incl. the 3 new regression specs).
Production A/B (call-center voice agent, SIP, silero-vad +
whisper-large-turbo + LLM + TTS, server_vad ~600 ms) on LocalAI v4.11.0:
unpatched — 'transcription_failed: context canceled', first part of the
utterance lost, agent answers only the remainder; patched — full
transcript committed, agent answers the complete utterance, barge-in
still cancels the in-flight assistant TTS response as intended, and
teardown cancels the in-flight transcription instead of waiting for the
backend.

Signed-off-by: nexxtmobile.de <kai@nexxtmobile.de>

* fix(realtime): release commit slots in order on every exit; share slot+issue boundary

Follow-up to the review of c8f991be (issue #12445): two schedules still
broke commit ordering.

1. A failed middle commit released later turns before earlier turns
   finished. slot.done closed on every return, but the wait on
   slot.prevDone happened only on the successful nonempty-transcript
   path. Hold transcription A, let B fail (or return an empty
   transcript / be rejected by the voice gate), then complete C: B
   closed its channel without waiting for A, so C appended and started
   its response without A (response history [third], final history
   [third first]). Fix: the slot now releases (done closes) only AFTER
   the predecessor has finished — on EVERY exit path, including errors,
   empty transcripts, gate rejections and teardown (the session context
   can still stop the wait, so teardown never blocks on a
   never-finishing predecessor). The success path keeps its append gate
   (wait before appending the user item); the deferred release gate
   enforces the same order on every other exit.

2. Slot order and response issue order could disagree between the two
   producers. The VAD CommitTurn and the client
   input_audio_buffer.commit reserved the slot and called
   respSink.issue separately; a pause between the two let the other
   producer reserve AND issue first, so the later issue superseded the
   EARLIER turn's response (response history [first], final history
   [first second], second turn un-answered). Fix: both producers now go
   through Session.issueCommit, which claims the slot and issues the
   body under one lock (commitOrderMu) — slot order == issue order.
   respSink.issue is non-blocking, so the lock never stalls
   VAD/barge-in handling.

Regression tests (realtime_commit_order_test.go) now drive the REAL
issue path — Session.issueCommit into the real responseSink/respcoord,
so coordinator supersession and the spawned response goroutines are
exercised — and cover: teardown during an in-flight transcription;
held-first/finished-second out-of-order completion; barge-in
(respSink.cancel) item survival; a FAILED middle commit; an EMPTY
middle commit; interleaved VAD/client producers in both directions.
The failed/empty middle specs fail deterministically without the
release gate (verified against the pre-fix code).

docs/design/realtime-state-machines.md: implementation-status entry
updated (append gate + release gate + shared issue boundary).

Validated: builds, go vet clean, all openai specs + respcoord/turncoord/
conncoord suites pass under -race (476 specs, incl. the 4 new ones).

Fixes #12445

Signed-off-by: nexxtmobile.de <kai@nexxtmobile.de>

---------

Signed-off-by: nexxtmobile.de <kai@nexxtmobile.de>
Co-authored-by: nexxtmobile.de <kai@nexxtmobile.de>
2026-10-05 01:27:40 +02:00
mudler-agentandEttore Di Giacinto 99043b442c feat(router): route with native decision models (#12449)
* fix(schema): preserve SystemOne image inputs

Assisted-by: OpenAI

* test(schema): follow Ginkgo conventions for decision inputs

Assisted-by: OpenAI

* feat(llama-cpp): dispatch native decisions through Score

Upgrade the stock dependency and reconcile Score/TTS patches. Reuse native decision parsing, tasks, formatting and response-reader cleanup; preserve ordinary scoring admission and guard older dependencies.

Assisted-by: OpenAI

* refactor(systemone): share request and model validation

Assisted-by: OpenAI:gpt-5

* fix(systemone): preserve HTTP wire-byte validation limit

Keep structural validation separate from the serialized internal request bound so HTML escaping cannot reject valid HTTP payloads.

Assisted-by: OpenAI:gpt-5

* feat(systemone): bound images and account native decisions

Preserve public wire limits independently from router serialization. Reject unsupported NER images, map native request/capability errors, and stamp explicit usage once. Advertise decisions for stock llama-cpp.

Assisted-by: OpenAI

* fix(systemone): record usage on registered native route

Exercise real registration and billing with a mock native backend. Reject empty native responses, malformed image URLs, trailing JSON, and wire overflow including whitespace.

Assisted-by: OpenAI

* feat(router): add lazy native decision transport

Bind named models through internal ModelSystemOne calls with shared validation and bounded abandoned operations. Remove request and echoed-error contents from decision traces.

Assisted-by: OpenAI:gpt-5

* feat(router): classify overlapping policies with native decisions

Ask independent noul questions, validate probabilities and preserve first-superset routing. Wire the central factory with config-sensitive invalidation and cancellation-safe resolution. Document native framing and bounded operation limits.

Assisted-by: OpenAI:gpt-5

* feat(gallery): add pinned Julia-1 native decision model

Add a separate text-only llama-cpp Q8 entry with pinned Apache-2.0 source provenance and checksum. Installed using the gallery installer and exercised choice, score and noul on CPU.

Assisted-by: OpenAI

* test(router): verify native decisions through central factory

Add an opt-in real-model Ginkgo integration covering the native Go loader and C++ transport, token usage, independent overlapping labels, and candidate selection. Document owned-server execution and the intentionally non-quality threshold.

Assisted-by: Codex:gpt-5

* fix(llama-cpp): align upstream pin and preserve decision signatures

Advance to bed0a856 without losing the automated upstream bump. Detect full-request fill_task support at compile time and forward every question for Nimble framing while retaining the earlier native signature. Preserve reconciled SCORE/TTS patches; add standalone compatibility coverage.

Assisted-by: Codex:gpt-5

* feat(gallery): add native decision family defaults

Pin Laya, Kev-4B, lev, OpenJev and Nimble artifacts. Verify Laya/Kev/lev gallery installs and CPU contracts on both native pins; clearly mark OpenJev/Nimble runtime validation pending and their noncommercial licenses.

Assisted-by: OpenAI

* docs(decisions): clarify integrated Nimble prerequisite

Record the exact combined backend pin while retaining pending OpenJev and Nimble installation/runtime validation status.

Assisted-by: Codex:gpt-5

* fix(gallery): indent native decision model sequences

Match repository yamllint indentation for Laya, Kev, lev and OpenJev list fields. Parsed gallery data is unchanged; reproduce CI gallery lint failure before the whitespace-only fix and pass the same command afterward.

Assisted-by: Codex:gpt-5

* docs(decisions): record OpenJev and Nimble CPU validation

Record gallery installation, checksum/metadata verification and multiquestion native smoke results on bed0a856. Retain noncommercial and text-only limitations without accuracy or deterministic-output claims.

Assisted-by: OpenAI

* fix(ui): expose native Decisions router classifiers

Select classifier models using metadata-driven capability routing, retain tuned thresholds, and validate native decision selections before saving. Cover both native backends and create/save/reopen in the real React editor.

Assisted-by: Codex:gpt-5

* fix(router): exclude aliases from native decision discovery

Check the originally named config before advertising native Decisions eligibility. Retain target capability inheritance for ordinary generation aliases. Exercise the actual capabilities endpoint with native models on both backends, aliases, and disabled models.

Assisted-by: Codex:gpt-5

* feat(systemone): share bounded multimodal input validation

Preserve text wire limits while admitting bounded PNG/JPEG decision input. Share collection and header validation across internal and public callers and keep the native runner response budget independent.

Assisted-by: OpenAI:API-assistant

* fix(systemone): bound admission lifetimes and validate complete images

Retain shared admission leases through actual work completion, including abandoned internal operations. Decode bounded image pixels, cap public native responses before usage stamping, and preserve oversized malformed text status precedence.

Assisted-by: OpenAI:API-assistant

* fix(router): classify images before media fetching

Preserve ordered structured probes for native decisions. Defer OpenAI
media preparation until routing selects the served model, so rejected
decision URLs cannot trigger downloads before shared validation.

Guard direct image collection with context-aware shared admission.
Keep text classifiers and embedding caches from discarding image input.
Retain fail-closed classifier configuration and runtime fallback policy.

Add middleware, typed-content, admission, cancellation and cache tests.

Assisted-by: OpenAI:API-assistant

* fix(router): bound extraction before serialization

Check probe budgets before copying text or marshaling message state.
Count JSON escaping so oversized internal inputs fail before allocation.

Preserve typed Anthropic blocks through selected-model conversion and
fallback. Keep retry coverage in Ginkgo without global test registration.

Assisted-by: OpenAI

* fix(router): bound supported probe serialization

Arbitrary structs can bypass the probe budget through pointer marshalers,
string tags, and promoted fields. Accept concrete chat schema types and
plain JSON values instead of emulating arbitrary struct serialization.

Budget escaped direct prompts before marshaling so raw length cannot hide
serialized expansion. Preserve runtime fallback and reject oversized
input before invoking the decision runner.

Add Ginkgo allocation, boundary, and marshaler invocation regressions.
Six-package tests, three-package race tests, and full-T2 delta lint pass.

Assisted-by: OpenAI:GPT-5 golangci-lint

* feat(decisions): enable bounded OpenJev images

Validate native decision images before permissive media parsing and pixel
allocation. Require both decision image support and a vision projector;
missing or audio-only projectors cannot silently become text decisions.

Pin the OpenJev Q8 projector and document its license and disk footprint.
Add native safety tests, canonical limit parity, gallery and load-option
checks, and a reproducible CPU direct-RPC contrasting-image smoke.

Assisted-by: OpenAI:GPT-5

* fix(decisions): reject incomplete image streams

stb accepts corrupt PNG Adler checksums and truncated JPEG scans.
Use bounded zlib validation and strict libjpeg decoding before parsing.
Keep dimension and aggregate pixel checks ahead of decoder allocations.

Wire decoder dependencies into native builds and runtime packaging.
Add regressions for appended EOI and embedded marker bypasses.

Assisted-by: OpenAI:GPT-5

* fix(ci): gate native decision image validation

Run the decoder security tests outside the stdlib-only native suite.
Fetch vendor headers at the backend pin and provision decoder dependencies.
Gate Go limit parity and production CMake wiring without model downloads.

Assisted-by: OpenAI:GPT-5

* test(decisions): cover multimodal public API paths

Exercise shared image contracts through the registered HTTP routes and
external mock backend. Add opt-in cached gallery installation and real
OpenJev image decisions through SystemOne and both routing APIs.

Assisted-by: Codex:gpt-5

* test(decisions): assert isolation and cache bypass

Observe external RPC calls and compare complete classifier history.
Winner-only and cache-miss checks could hide dropped history or cache use.

Give real inference its own application and model directory so shared
backend mappings and loaded processes cannot affect mixed suite order.

Assisted-by: OpenAI:ChatGPT

* test(decisions): isolate fixture globals

Disable optional global services in the isolated HTTP fixture and register
cleanup before setup assertions. Verify meter provider identity survives
fixture creation and destruction.

Snapshot observed usage before assertions so failures cannot retain the
mutex. Require a successful usage stamp before checking error responses.

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

* fix(application): honor optional telemetry controls

Skip failover gauge registration when metrics are disabled. Register
against the application meter rather than looking up the global provider.

Allow embedders to retain the bounded routing log without billing stats.
Keep the existing default when stats are disabled. The isolated HTTP
fixture uses this option without losing its native router assertions.

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

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-04 09:34:21 +02:00
mudler-agentandEttore Di Giacinto e4fa051ee3 refactor(distributed): put the NATS-only paths behind interfaces (#12395)
* feat(messaging): add shared subject rules

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

* test(messaging): cover BroadcastRoots, ControlRoots and SubjectRoot

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

* feat(messaging): add Broadcaster and enforce subject rules in every carrier

Broadcaster is the fan-out half of MessagingClient. The NATS client and
the in-memory FakeBus now refuse a subject outside the served roots and any
wildcard other than a whole single token, and FakeBus shares MatchSubject
instead of its own copy. FakeBus Unsubscribe now removes its own
subscription instead of the first one with the same subject.

A shared conformance suite in messagingtest runs against both carriers.
The distributed e2e specs that used invented test.* subjects, and the one
that subscribed with a > filter, now use subjects from subjects.go.

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

* refactor: depend on Broadcaster where only publish and subscribe are used

Narrowed to messaging.Broadcaster: nodes/staging_progress.go,
nodes/install_progress_publisher.go, galleryop/operation.go,
galleryop/service.go, agentpool/user_services.go, agentpool/agent_jobs.go,
openresponses/store.go, openresponses/sync.go, syncstate/syncstate.go,
finetune/service.go, quantization/service.go and
failover/distsync/distsync.go. SubscribeJSON now takes a Broadcaster
because it only calls Subscribe, which lets the narrowed consumers use it.

Stayed wide: worker/supervisor.go, because its client field also serves
the SubscribeReply handlers in worker/lifecycle.go. The request/reply,
queue and wiring files (nodes/unloader.go, nodes/file_stager_s3.go,
jobs/dispatcher.go, agents/dispatcher.go, agents/events.go,
worker/file_staging.go, cli/agent_worker.go, http/app.go) are unchanged by
design.

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

* refactor(nodes): name the no-route condition and confine the carrier error

Consumers matched nats.ErrNoResponders, which names an absence, to demote a node. They now match ErrNoRoute, the control path maps the carrier's failure onto it, and timeouts and worker refusals are pinned as not being no-route.

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

* docs(nodes): state which FileStager implementations return ErrNoRoute

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

* refactor(nodes): build backend clients through one node-aware seam

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

* docs: describe the distributed transport seams

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

* docs: correct comments that overclaim after the seams refactor

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

* fix(agent-worker): refuse an unserved LOCALAI_AGENT_SUBJECT at startup

The messaging client now refuses a subject whose root no carrier serves.
An agent worker started with a custom LOCALAI_AGENT_SUBJECT such as
tenant-a.agent.execute used to start and then wait on a subject the
frontend never publishes to. After the subject rules landed it exited
at subscribe time with an error that did not name the setting.

Behaviour change: the worker now checks LOCALAI_AGENT_SUBJECT before it
registers or connects, and exits with an error that names the variable
and says to use a served subject under the agent root, for example
agent.execute. The served roots are not widened: a custom root was
never delivered by the frontend, and a wider set would reopen the
drift the subject rules exist to close. The flag help and the agent
worker docs state the constraint.

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

* test(nodes): pin the reactions to ErrNoRoute

Three callers react to ErrNoRoute and had no spec: the reconciler's
upgrade drain falls back to the legacy forced install, the reconciler
marks the node unhealthy when a pending op has no route, and the
backend-op fan-out marks the node unhealthy. Each spec drives the real
caller with a scripted no-responders reply and reads the result from
the registry or the recorded requests.

A fourth spec pins the other side: a pending op that times out leaves
the node healthy and only counts the attempt, so mapping timeouts onto
ErrNoRoute would fail here.

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

* test(messaging): pin client subject checks and fail the carrier suite in CI

Add specs that call Publish, Request, Subscribe, QueueSubscribe,
SubscribeReply and QueueSubscribeReply on a client with no connection.
Each call must return ErrUnservedSubject for bogus.thing and
ErrUnsupportedWildcard for jobs.>. This proves that the subject check
runs before the connection is used, and needs no server.

The NATS conformance suite is the only check that runs the subject rules
against a real carrier. Before this change it skipped without output
when Docker was missing. Now it fails when CI is set, so a Linux runner
without Docker cannot hide it. It still skips on local runs and on macOS
CI, which has no Docker.

Add SubjectNodeBackendInstallProgress to the list of constructors that
must build served subjects, and ask contributors to extend the list.

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

* docs: state what ErrNoRoute may change, and group the distributed guides

The seams note said MarkUnhealthy was the only state change allowed
on ErrNoRoute. A pending backend op still records the failed attempt,
counts toward the reconciler's retry limit and is dead-lettered after
the maximum attempts. The note now says that MarkUnhealthy is the only
change to the node's own state, and that the per-op accounting is not
a verdict about the node.

The note also documents that the NATS conformance run fails under CI
when Docker is missing. The distributed-seams row moves next to the
distributed-state row in the topics table. The liveness ping spec
header now says no route is a reason to skip the worker, not proof
that the worker is gone.

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

* refactor(nodes): give the backend client factory the node id

Mechanical: the method gains a nodeID parameter and the eight test fakes are updated. No behaviour change.

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

* refactor(nodes): drop the optional node-aware factory

The node id is now in the main method, so the optional interface and its helper had no behaviour of their own.

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

* refactor(nodes): dial backend probes through the client factory

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

* refactor(nodes): dial workers' file servers through a per-node dialer

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

* refactor(http): proxy backend logs through the per-node worker dialer

The admin backend-logs proxy (list, lines and the WebSocket stream) now reaches a worker through the same per-node dialer as the HTTP file stager, so every frontend-to-worker dial goes through one seam. The shared direct dialer keeps alive for 15s where the proxy used 30s. Harmless for requests bounded at 15s.

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

* fix(http): keep the backend-logs proxy independent of the admin connection

The proxy request had no context before the dialer change and is bounded only by its 15s timeout. Keep it that way.

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

* refactor: move the worker control payloads to workerctl

Mechanical move of the request and reply structs, the install progress event and the file payloads out of messaging. The verbs no longer belong to one carrier. No alias is left behind.

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

* refactor(worker): serve the lifecycle verbs through a controlServer

The worker registers one handler per verb and a NATS server maps each verb to its subject. Registration errors now name the verb. node.stop is served with SubscribeReply, which is identical on the wire because the handler never replies.

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

* refactor(worker): report install progress through the control sink

Install and upgrade now emit download progress through the sink the control server hands them. The debounce and the terminal flush stay in the handler path, built over that sink by the new nodes.NewDebouncedInstallProgressSink, which replaces NewDebouncedInstallProgressPublisher. The subject and payload on the wire are unchanged. The supervisor no longer holds the bus, and installFn and upgradeFn let specs drive both verbs without a gallery.

The malformed-request log lines are restored for install, upgrade, backend.delete, model.unload, model.stop and model.delete, with the reply bytes unchanged. The signal adapter is renamed noReply, which also lets worker.go import os/signal without an alias again.

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

* refactor(worker): serve the file-staging verbs through a controlServer

An empty list-dir answer is now {} rather than {"files":null}, because the typed reply omits an empty Files slice. The frontend decodes both to a nil slice in nodes/file_stager_s3.go.

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

* feat(messaging): add WorkQueue and the NATS producer

This is the producer side of the competing-consumer seam. The work kinds map one to one to today's subjects and queue groups: task to jobs.new and mcp-ci to jobs.mcp-ci.new (both in group workers), agent-run to agent.execute (group agent-workers). Enqueue publishes the payload as Publish does today, with one JSON marshal. FakeBus now records queue groups and keeps reply handlers so later specs can pin and drive them.

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

* feat(messaging): add the NATS WorkConsumer

An in-flight limit of one runs the handler inline on the delivery goroutine, as the MCP CI consumer does today. Any other limit spawns per delivery, as the agent consumer does. Queue groups are unchanged.

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

* refactor: publish queued work through WorkQueue

The job dispatcher, the agent pool and the agent scheduler enqueue through messaging.WorkQueue; the NATS implementation publishes to the same subjects as before. DistributedServices builds the queue next to the NATS client and hands it to the dispatcher and the agent pool, whose distributed mode switch now reads a non-nil WorkQueue. The unused AgentPoolService.SetNATSClient is removed.

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

* refactor: consume queued work through WorkConsumer

The agent dispatcher and the MCP CI consumer register through messaging.WorkConsumer. The NATS implementation keeps the inline one-at-a-time model for MCP CI and the per-delivery model for agent runs. handleMCPCIJob reports on the events publisher the carrier hands it instead of a captured client.

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

* refactor: delete the consumers nothing in production reached

jobs.new has a producer and no production consumer, and the agent dispatcher's Dispatch was only called from tests. Publishing jobs.new is unchanged.

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

* refactor(mcp): send MCP requests to agent workers through AgentControl

Timeouts still honour only the deadline, not cancellation, exactly as today. The NATS no-responders error maps to ErrNoRoute and a timeout does not.

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

* refactor(agent-worker): serve MCP requests and backend.stop through agentRPCServer

The agent worker's MCP tool and discovery reply subscriptions and its backend stop listener move behind an unexported agentRPCServer interface, served on NATS by nodes.NATSAgentRPCServer. The handlers become typed mcp.ToolHandler and mcp.DiscoveryHandler values that answer every failure with a reply carrying Error.

Queue group (agent-workers), inline execution on the delivery goroutine, the background handler context, the unmarshal error reply texts and the reply-less backend stop subscription are unchanged. The backend stop handler takes the decoded backend name, so it can still close that backend's MCP sessions.

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

* refactor(messaging): remove helpers that only tests used

BroadcastRoots, ControlRoots and SubjectRoot had no production caller. The roots spec now asserts every served root through ValidateSubject instead. MatchSubject moves back into the test support package, the only place that used it, with its table. NATSAgentRPCServer drops the subscription list it stored and never read, and NewNATSAgentRPCServer gets a doc comment.

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

* test(mcp): round trip the agent RPC server over a real NATS server

One spec sends a tool request and a discovery request through NATSAgentControl to NATSAgentRPCServer and checks that the handlers see the decoded requests and the replies come back. It also puts an undecodable body on the tool subject and checks the server answers with an unmarshal error instead of leaving the requester to time out.

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

* docs: describe the distributed transport seams

The developer note now lists the final seams: fan-out, queues, both halves of the control verbs and of agent RPC, and the dial. It records the open items a second carrier has to handle.

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

* test: pin the in-flight limit each queue consumer asks for

The work queue specs pin what Consume does for a given limit, but nothing
pinned which limit each production consumer passes. Changing the agent
worker's MCP CI limit from 1 to 0 would have let MCP CI jobs run
concurrently on each worker with every test green.

Move the MCP CI Consume call into startMCPCIConsumer with the same wiring
and pin that it asks for (WorkMCPCI, 1). Pin that NATSDispatcher.Start asks
for (WorkAgentRun, maxConcurrent) for several limits.

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

* refactor: remove helpers the branch left without a caller

SubjectJobCancelWildcard lost its last subscriber when the frontend
stopped listening on jobs.*.cancel; the NATS permissions and conformance
suite spell the subject out, so nothing reads the constant.

decodeBackendStopRequest returned a stopAll flag that production dropped
and only a test read. decodeBackendStop is now the single decoder with the
same semantics: an empty body is stop-all, an empty Backend is stop-all,
malformed JSON is an error. stopBackends still derives stop-all from
Backend, so no reply changes.

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

* fix(messaging): keep an explicitly empty agent queue a plain subscription

Before the work queue seam the agent worker passed LOCALAI_AGENT_QUEUE
straight to QueueSubscribe, so an explicitly empty value made a plain
subscription and every agent worker ran every agent run.
WithAgentRunRoute replaced an empty queue with agent-workers, which
silently changed that.

Keep the queue as given once the option is applied. An empty subject still
falls back to agent.execute, since it never had a meaning of its own. The
flag default stays agent-workers, so only an explicitly empty value
reaches this.

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

* docs: correct comments and record the PR B notes

Fix the recordingFactory comment (it also records the parallel flag),
document that a negative maxInFlight is unbounded and that Unsubscribe from
a handler deadlocks, and say a permanently undecodable payload returns nil.

Record controlHandler's undecodable return as a kept exception, and add the
second carrier notes to the developer note: the reconciler has no
ClientFactory option, the logs proxy honours HTTP_PROXY, verbs one carrier
serves need an opt-out, terminal replies come from the result event, and
agent runs publish through the NATS-bound EventBridge, which is not an
additive change.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-02 23:58:41 +02:00
mudler-agentandEttore Di Giacinto 9eb5a9e61d feat(audio): remember speakers from diarization (#12414)
* feat(schema): validate portable speaker profiles

Add the versioned profile schema for explicit speaker enrollment.
Validate compatibility against separately supplied loaded-encoder metadata.
Reject unusable speakers, invalid vectors, and inconsistent clean spans.

This slice does not change HTTP routes, backend integration, or the UI.

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

* feat(parakeet): export profiles with transcripts

Export opt-in speaker profiles and trusted encoder metadata.
Replay registrations by ID so duplicate display names keep independent
vectors.

Use one profile-capable diarization for slots, names, and clean spans.
Assign timestamped ASR words to those slots without a second diarization.
Preserve legacy opt-out and no-ASR behavior, and propagate failures.

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

* feat(audio): enroll portable speaker profiles

Gate profile exports with voice-recognition permission and validate
registration against metadata from the loaded encoder. Preserve audio
enrollment and independent registrations with duplicate display names.

Exclude diarization and registration exchanges before API trace capture
so persisted traces cannot retain profile vectors or JSON audio.

Defer candidate dimensions to trusted loaded metadata. Sort candidates
by registration ID so incompatible profiles cannot suppress legacy voices
through registry iteration order. Keep portable identity checks closed
when trusted metadata is unavailable.

Test persisted traces, explicit slot zero, and selection through offline
and live transport. Document privacy and the ephemeral registry lifecycle.

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

* feat(ui): remember speakers from diarization

Add a Studio page for diarization and opt-in speaker profiles. Preview
clean intervals from the original recording before explicit registration.

Join profiles by raw speaker labels, preserve duplicate names, and relabel
turns only after a successful save. Discard stale results when the model
or recording changes. Share registration metadata with voice management
without storing vectors or recordings from this flow.

Document permissions and the global, ephemeral registry. Cover enrollment,
permissions, previews, and asynchronous races with mocked Playwright tests.

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

* docs: clarify HTTP speaker enrollment support

Replace the stale enrollment limitation with the current HTTP workflow.
Distinguish native transport from explicit registration and link its docs.

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

* chore(parakeet): pin merged speaker profile support

Use the merged commit from mudler/parakeet.cpp#80.
Its tree matches the previously accepted native pin.

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

* docs: add diarization enrollment setup example

Connect the existing gallery modes to the speaker enrollment workflow.
Show installation, private profile export, explicit raw-slot registration,
and later recognition without another export.

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

* docs(blog): explain diarization speaker profiles

Put the diarization walkthrough on the LocalAI website in the feature PR.
Cover the three gallery modes, explicit enrollment, and privacy limits.
Link setup instructions and keep availability conditional on feature support.

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

* docs(blog): focus diarization on everyday use

Explain what users can do with recordings before the setup steps.
Replace the technical walkthrough with a short Studio guide and link
readers to the existing reference for model names and developer use.

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

* docs(blog): lead with speaker capabilities

Present speaker recognition through everyday uses and a short UI flow.
Keep technical reference details in the existing documentation.

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

* fix(diarization): satisfy Go lint checks

Avoid copying protobuf message state when extending backend status, check the multipart reader close result, and document the focused testing.T lint exemptions.

Assisted-by: nib:gpt-5.6-sol

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-02 08:14:00 +02:00
localai-org-maint-botandEttore Di Giacinto 2ae6cae70d feat(parakeet-cpp): name speakers from the shared voice registry (#12382)
* feat(voice): list registered voices and record which encoder made them

The voice registry could register, identify and forget but not list, and
it did not remember which speaker encoder produced an embedding. Add
Metadata.Model and Registry.List, answered from the index the store
registry already keeps for Forget. Needed so a backend can be given the
registered voices that match its own speaker encoder.

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

* feat(voice): store the encoder model with a registered voice

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

* feat(voice): pick the registered voices that match a speaker model

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

* feat(proto): carry known voices and speaker names on diarize and live messages

Assisted-by: Claude:claude-haiku-4-5 [Claude Code]

* feat(diarization): name speakers from the voice registry

When a diarization model has a speaker_model option, the endpoint sends
the registered voices made by that encoder to the backend. The backend's
name and name_score come back as extra fields next to the normalized
SPEAKER_NN speaker, and the speakers summary carries the first name seen
for each speaker. RTTM output and results without names are unchanged.

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

* feat(live): pass registered voices to a live session and surface speaker names

Live sessions now send the registered voices that match the model's
speaker_model to the backend, and each speaker segment carries the name
the backend matched. The realtime segment event gains an optional
speaker_name field.

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

* feat(parakeet-cpp): load a speaker model and build per-request voice registries

Adds the speaker bindings (ABI v9 and v10, probed separately), the
speaker_model, speaker_threshold and speaker_margin options, and a
per-request registry builder over the known voices.

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

* feat(parakeet-cpp): name the speakers in Diarize from the known voices

Diarize builds a per-request speaker registry from the known voices when a
speaker model is loaded, calls the named C functions, and puts each slot's
registered name and score on the segments. The registry is freed on every
path. A library without ABI 10 reports Unimplemented instead of dropping
the names.

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

* feat(parakeet-cpp): name speakers in the live scene stream

The live scene stream now begins with a known-voice registry when a
speaker model is loaded and the live config carries voices, and each
closed speaker segment takes its slot's current name from the feed's
names map. A segment that closes before its slot is identified has an
empty name. The registry is freed after the stream, on every path.

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

* feat(gallery): speaker naming entries and docs for parakeet-cpp

Add three gallery entries that load the WeSpeaker ResNet34 speaker model
next to the diarization or realtime scene models, and document speaker
names in the voice recognition, diarization, audio to text and realtime
pages.

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

* fix(parakeet-cpp): skip an unusable registered voice instead of failing the request

A registered voice with the wrong embedding size, or one the C side
refused, failed the whole diarization request, so one legacy voice broke
the model for every user. Skip such voices with a warning that does not
carry the voice name, and take the plain path when none is left.

Also map an exact 0 speaker threshold or margin to a tiny positive value,
since the C side reads 0 as "use the default", and fix a stale comment
about which contexts Free() walks.

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

* fix(diarization): warn once per model about voices from another encoder; document the privacy limit

The different-encoder warning fired on every request. Log it once per
feature and speaker model, then at debug level. Document that the global
voice registry lets any caller of a speaker_model model learn matching
names, and that skipped wrong-sized voices are logged.

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

* chore(parakeet-cpp): bump parakeet.cpp to 8c8cec0 (C-API v10) and check speaker naming against the real library

The pin moves from 623a968 to 8c8cec0, which brings in everything merged
in parakeet.cpp since: the voice identification change (C-API v9, #78) and
raw-embedding enroll plus diarize-only speaker naming (C-API v10, #79).

New real-library specs (gated on PARAKEET_BACKEND_TEST_SPEAKER_MODEL,
_DIAR_MODEL, _WAV and, for the live path, _STREAM_MODEL) name the two
speakers of two_speakers.wav from a committed pair of WeSpeaker embeddings,
with the voices passed in reversed order. They also check that the float32
threshold reaches C through purego. The shared test loader now registers
the v9/v10 and scene symbols as main.go does.

The rebase onto origin/master had no conflicts.

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

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-01 08:25:52 +02:00
2fa36e7147 feat(parakeet-cpp): speaker diarization, sound detection and live scene events (#12335)
* feat(parakeet-cpp): load diarization and CED models and companions

Repin PARAKEET_VERSION to parakeet.cpp PR #75's head, which adds
parakeet_capi_model_kind (ABI v8). Bind the new diarization, sound
event and combined scene stream C symbols through the same
purego.Dlsym probe pattern already used for the batched JSON entry
point, so the backend still loads against an older libparakeet.so.

Load now classifies the loaded GGUF by role (ASR, diarization or
sound) via parakeet_capi_model_kind and can load up to two companion
models from Options[] (asr_model:, diarization_model:, sound_model:,
paths resolved against opts.ModelPath), verifying each companion's
kind and freeing every context opened so far on any failure. Free
releases the primary and every companion. AudioTranscription now
names the loaded role when it is not ASR instead of a generic model
not loaded error. The dynamic batcher starts only when an ASR context
ends up loaded, primary or companion.

This is groundwork only: the Diarize and SoundDetection RPCs and the
live scene stream that actually use these new roles land in later
commits.

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

* fix(parakeet-cpp): reset role fields on a failed companion load

loadRoles' freeLoaded only released the C contexts it had opened; it
left ctxPtr/diarCtx/tagCtx and companions pointing at those now-freed
contexts, so a later Free() on the same instance would double-free.
Zero all four alongside the CppFree calls.

Also route AudioTranscriptionStream and AudioTranscriptionLive through
notASRError when ctxPtr is unset but a diarization or sound model is
loaded, matching AudioTranscription: both used to return the generic
model-not-loaded error instead of naming the loaded role.

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

* feat(parakeet-cpp): add speaker diarization

Implement the Diarize RPC for the parakeet-cpp Go backend, wired to
Nemotron-3-Diarization through libparakeet.so's diarization C-API.

Plain diarization uses parakeet_capi_diarize_pcm; when include_text is
set and an ASR companion is loaded, parakeet_capi_transcribe_and_
diarize_json fills each segment's text instead. Speaker labels are the
decimal index, or "unknown" for -1 (no diarized speaker overlaps).
min_duration_off merges same-speaker segments across a short gap
before min_duration_on drops the segments still too short, then ids
are renumbered. num_speakers/min_speakers/max_speakers/clustering_
threshold have no Sortformer equivalent and are logged at debug
instead of rejected.

Verified against the real Nemotron-3-Diarization + parakeet-tdt_ctc-
110m checkpoints on the two_speakers.wav fixture: correct A-B-A-B
speaker segmentation and matching speaker-attributed transcripts.

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

* feat(parakeet-cpp): add sound event detection

Wire the SoundDetection RPC to the CED tagger context (p.tagCtx)
loaded by Task 1's role classification. It runs the whole clip
through a one-shot parakeet_capi_sound_stream_* session (window
10s, hop 10s, top_k set to the tagger's class count so every
drained window carries a full score list), averages each class's
score across the drained windows, sorts descending, then applies
the request's threshold and top_k (0 keeps every class).

No tagCtx returns FailedPrecondition; a libparakeet.so missing the
sound_stream symbols returns Unimplemented. Every C call runs under
engineMu, and the stream is always freed, even when a feed or drain
call fails partway through.

Verified against a real ced-tiny-q8_0.gguf on the rooster.wav demo
clip: "Chicken, rooster" tops the list at score 0.91.

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

* fix(parakeet-cpp): cancel sound detection mid-feed, shrink the lock

SoundDetection now checks ctx before each 10 s feed slice (mirroring
driver.go's feedSlices) and returns Canceled if the caller gave up,
so a long clip can be interrupted instead of feeding to completion
regardless. The stream is still freed on every path, cancellation
included.

Also narrow engineMu to the C calls: the drained JSON document is
now decoded after the lock is released, splitting soundStreamScores
into a locked soundStreamDrain (opts, begin, feed, drain, free) and
an unlocked json.Unmarshal.

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

* feat(parakeet-cpp): stream speaker and sound events during live transcription

Add two additive proto fields, LiveSpeakerSegment and LiveSoundEvent,
repeated on TranscriptLiveResponse. When a diarization or sound
companion model is loaded, AudioTranscriptionLive now runs a no-ASR
scene stream (parakeet_capi_scene_stream_begin) beside the ASR
streaming session, feeding it the same PCM slices and forwarding any
closed speaker or sound events alongside the matching ASR delta, or
on their own when a slice has no ASR output.

The scene stream is freed and reopened on a mid-stream Config reset,
flushed with is_last before the closing FinalResult, and degrades
gracefully (a warning, not an error) when begin or a later feed call
fails, so live transcription keeps working ASR-only. Existing live
behavior is unchanged when no companion is configured, and no scene
C call is made in that case.

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

* fix(parakeet-cpp): keep scene events off the ASR critical path in live

Emit each slice's ASR result right after the ASR feed, before the
scene feed for that slice runs, so a companion diarization/sound
model never adds scene compute latency in front of the delta or
<EOU> that drives realtime turn detection. Closed speakers/sounds go
out afterward as their own response, so a slice with both now
produces two responses, ASR first. The live feed log line now
reports ASR and scene wall time separately.

Re-check the diarization/sound contexts a scene stream was begun
with against the live contexts before every feed, under the same
lock: Free() can race between an ASR feed and the matching scene
feed and free the model the stream borrows. A mismatch now returns
without touching the C side. Freeing the stream itself stays
unconditional; the scene stream's destructor only releases its own
buffers and never touches the borrowed contexts.

Also recover a panicking stub inside the live test goroutine instead
of crashing the test binary, and reset the live decode-lag tracker on
a mid-stream config reset, matching what its own comment already
promised.

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

* feat(realtime): surface live speaker and sound events

Carry the backend's closed speaker segments and sound events
(TranscriptLiveResponse fields 7/8) through LiveTranscriptionEvent
as LiveSpeakerSegment/LiveSoundEvent (nanoseconds mapped to
seconds), and forward them from the semantic_vad live path.

Each speaker segment emits
conversation.item.input_audio_transcription.segment with speaker,
start, end and empty text under the turn's item id. Each sound
event emits conversation.item.sound_detection with one tag
(label, score = peak, index) and the event's new optional
start/end seconds fields, omitted when unset so the existing
unary/windowed sound-detection path is unaffected.

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

* fix(realtime): keep start/end on a zero-second transcription segment

ConversationItemInputAudioTranscriptionSegmentEvent.Start/End used
omitempty, so a speaker segment starting at 0.0s dropped its
"start" key. Nothing emitted this event before the live scene-event
path, so drop omitempty: the segment always carries real times.

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

* chore(gallery): add parakeet-cpp diarization, CED and realtime scene models

Add gallery entries for the new parakeet-cpp capabilities: standalone
Nemotron-3-Diarization, the same paired with the Parakeet TDT+CTC
110M ASR model for speaker-attributed text, CED-Tiny and CED-Base
sound classifiers, and a realtime scene bundle combining the
streaming EOU ASR model with diarization and sound companions.

SHA256 taken from the Hub API; licenses from each model card
(openmdw-1.1 for Nemotron-3-Diarization, apache-2.0 for CED,
cc-by-4.0 for the Parakeet ASR models).

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

* docs: document parakeet-cpp diarization, sound detection and live scene events

Cover the new parakeet-cpp capabilities across the feature pages:
Nemotron-3-Diarization as a diarization backend (with and without
speaker text, the ignored speaker-count hints, the Sortformer
voice-like-sound quirk), CED as a sound classification backend, the
asr_model/diarization_model/sound_model/diarization_latency companion
options, and the realtime live speaker/sound events (event shapes,
the speech-turn-only limitation, and using this or
pipeline.sound_detection but not both).

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

* fix(gallery): correct the realtime-scene license and wording nits

parakeet-cpp-realtime-scene mistakenly copied cc-by-4.0 from the
existing realtime_eou_120m-v1 entry; the model card lists the NVIDIA
open model license instead. Switch to the gallery's usual spelling
for that license and keep the diarization/CED licenses called out in
the description.

Also: audio-diarization.md now says getting per-segment text needs
both an asr_model companion and include_text=true on the request, and
audio-to-text.md's option table reads "Use on" (a pairing the loader
does not enforce) instead of "Allowed on".

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

* fix(parakeet-cpp): reject a companion role that duplicates the primary's

loadRoles let a companion option (asr_model:/diarization_model:/
sound_model:) assign into a role field the primary already occupied,
for example asr_model: on an already-ASR primary. The companion's
context silently overwrote ctxPtr/diarCtx/tagCtx, and Free() only
walks those three fields, so the original primary context was never
freed again.

Reject a companion whose role the primary already holds before its
GGUF is even loaded, freeing everything loadRoles opened so far, the
same way a wrong-kind companion is already rejected.

Also warn, rather than silently fall through, when
parakeet_capi_model_kind reports PARAKEET_MODEL_KIND_NONE for a
successfully loaded primary; the primary is still treated as ASR,
matching today's behavior.

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

* fix(parakeet-cpp): cap live scene sound score retention

sceneBegin started the live diarization/sound companion stream with
the C API's default sound options, whose top_k keeps 5 scores per
window forever until drained. The live scene path never drains sound
scores (only the offline SoundDetection RPC does, with its own fresh
stream), so this window queue on the C side grew for the whole
session's lifetime.

Set opts.Sound.TopK = 0 before starting the scene stream: this
disables score retention while leaving sound event detection (onset/
offset), which the live path actually consumes, unaffected.

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

* fix(parakeet-cpp): merge diarization segments per speaker, harden Diarize

mergeCloseSegments only compared neighbors in the single start-sorted
segment list, so two same-speaker segments never merged once another
speaker's turn fell between them (A, B, A): the short B segment broke
the adjacency the merge relied on. Group segments by speaker first,
merge within each speaker's own start-ordered run, then re-sort the
result by start so interleaved speakers come back out in timeline
order.

Also harden Diarize's entry points the same way streamFeedDoc/
sceneFeed already are: diarizeCall re-checks p.diarCtx (and, on the
include_text path, p.ctxPtr) under engineMu right before the C call,
so a Free() racing between Diarize's own checks and the lock can no
longer reach the C side with a freed context. When the include_text
call returns NULL, last_error is now read from both contexts and
whichever came back non-empty is reported, since either side of the
pairing can be the one that failed. A WAV decode failure is reported
as InvalidArgument instead of an unwrapped/untyped error.

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

* fix(parakeet-cpp): harden SoundDetection's engine checks

soundStreamDrain ran every C call under engineMu but never re-checked
p.tagCtx there, so a Free() racing between SoundDetection's own
tagCtx==0 check and this lock could still reach the C side with a
freed context. Re-check p.tagCtx under the lock and return
ModelNotLoaded when it was cleared, mirroring diarizeCall's own
re-check. A WAV decode failure is now reported as InvalidArgument
instead of an unwrapped/untyped error.

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

* test(parakeet-cpp): cover a mid-session scene feed failure

feedSlicesScene already degrades gracefully when a scene feed call
fails mid-session: it frees the broken stream and carries the ASR-only
session forward. Add a spec covering that path end to end: the scene
stream is freed exactly once, later audio slices still produce ASR
responses, and no speaker/sound events appear before or after the
failure.

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

* docs: fix the parakeet-cpp companion role table and realtime scene docs

audio-to-text.md's companion option table read "Use on" with a note
that the loader did not enforce the pairing; it now rejects a
companion whose role duplicates the primary's, so restore the
"Allowed on" wording and describe the real enforcement.

openai-realtime.md's live speaker/sound section claimed a mid-stream
session.update resets the companion stream and that it flushes on
session close; neither happens, since the realtime core opens one
live stream (and so one scene stream) per speech turn and closes it
at that turn's commit, with no mid-stream Config in between. Document
that lifecycle instead, state precisely that start/end are seconds
from the start of the turn's own audio, and note that the diarization
model starts a fresh session every turn, so a speaker index is only
meaningful within one turn. The example sound tag ("Rooster", index
17) did not match any real CED label; index 17 in ced-tiny-q8_0.gguf
is "Baby laughter". Replaced with "Chicken, rooster" at its real
index, 99.

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

* fix(parakeet-cpp): use CED's real index for Chicken, rooster

The scene feed comment and the live test's canned document gave
"Chicken, rooster" index 365. In CED's AudioSet label list it is 99,
which is also what the realtime docs show.

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

* fix(realtime): call the test event accessor

The scene-event tests range over a method instead of its returned slice.
Call the synchronized accessor so the OpenAI test package compiles.

Assisted-by: Codex:gpt-6
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(parakeet-cpp): pin parakeet.cpp master with sound events

mudler/parakeet.cpp#75 (sound events, scene stream, model kinds) and
#74 (the missing <algorithm> include that broke the image builds) are
on master now. Pin 6dea76a instead of the #75 PR head, and update the
header comment the bump bot reads.

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

* chore(parakeet-cpp): pin parakeet.cpp with ced.cpp on main

parakeet.cpp #76 moved its ced.cpp submodule from the head of
localai-org/ced.cpp#3 (a branch-only commit) to ced.cpp main, where
#3 landed with an identical tree. Pin 623a968 so the image builds no
longer depend on that branch.

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

* feat(transcription): carry speaker labels on words and streamed segments

A diarizing backend could label transcript segments, but two paths
dropped the label: TranscriptWord had no speaker field, so live
transcription words and word-level timestamps could not carry one, and
the stream=true transcript.text.done event left the speaker out of
its segments.

TranscriptWord gains an optional speaker (proto field 4, additive).
It flows through the live event and result mapping, the JSON word
output of the endpoint and the CLI, and transcript.text.done now
includes a segment's speaker when there is one. Empty labels are
omitted, so responses without diarization are unchanged.

Assisted-by: Claude:claude-opus-5-5 [Claude Code]
(cherry picked from commit 2f0049f979)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(importers): detect the parakeet.cpp diarization GGUF

The Nemotron-3-Diarization GGUFs are published in
mudler/parakeet-cpp-gguf as nemotron-3-diarization-<quant>.gguf. The
parakeet-cpp importer did not recognise that name, so a direct
`local-ai models import` of the file fell through to another importer.

A direct URL to the file now imports with the diarization usecase. A
repo import still picks ASR weights when the repo also ships the
diarization model, and falls back to the diarization weights only
when there are no others.

Ported from #12323.

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

* fix(config): advertise diarization and sound detection for parakeet-cpp

The capability table listed parakeet-cpp as transcription only, though
the backend now answers Diarize (Nemotron-3-Diarization) and
SoundDetection (CED) depending on the model kind it loads.

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

* feat(parakeet-cpp): label transcript segments with the diarization companion

A diarization_model companion only fed live speaker events and
Diarize; /v1/audio/transcriptions ignored it.

With the companion attached and diarize=true (the OpenAI endpoint's
default), unary transcription now labels each segment with its
speaker and splits segments at speaker turns; with word timestamps
each word carries its speaker. The stream=true final result labels
each utterance with the speaker who said most of it. Both use the
checkpoint's own diarization over the whole clip, as NeMo's diarize()
does. Words take the speaker whose segments overlap them most, or the
nearest segment within 0.5 s, the same rule as parakeet.cpp's
speaker-attributed ASR.

Docs: the diarization_model row and a paragraph on transcript
speakers; Nemotron-3-Diarization handles up to 8 speakers.

Ported from #12323.

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

* feat(realtime): speaker segments from committed-turn transcription

Speaker events reached a realtime session only from the live
semantic_vad path, which needs a cache-aware streaming transcription
model. Committed-turn transcription (server_vad, or any offline
model) always asked the backend for diarize=false and dropped the
segments' speakers.

pipeline.diarization (off by default) asks the transcription model for
speaker labels on each committed turn and emits every labelled segment
as a conversation.item.input_audio_transcription.segment event, with
its text, before the turn's completed event. It is opt-in because some
backends fail a diarization request they cannot serve.

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

* feat(gallery): add parakeet-cpp-realtime-scene-tdt

parakeet-cpp-realtime-scene pairs the streaming EOU model with the
diarization and CED companions; its speaker and sound events need a
cache-aware streaming model. This entry does the same with Parakeet
TDT 0.6B v3 (multilingual, offline) for realtime under server_vad:
set it as both transcription and sound_detection and turn on
pipeline.diarization, and each committed turn gets speaker segments
and sound tags from one parakeet-cpp backend.

Files and sha256 match the Hub and are shared with the existing TDT v3,
diarization and CED-Tiny entries. A real-model spec checks the
combination on a clip with two speakers and a rooster: A-B-A-B speaker
turns, and "Chicken, rooster" among the sound tags. The test loader
now binds the sound entry points like main.go.

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

* feat(gallery): add CED-Base variants of the parakeet-cpp scene models

parakeet-cpp-realtime-scene and parakeet-cpp-realtime-scene-tdt ship
with CED-Tiny. The -base variants use CED-Base (86M), which tags sounds
more confidently (on the rooster clip "Crowing" 0.65 against 0.49 for
Tiny).

Measured on CPU over a 37 s clip: the live diarization + sound stream
runs at 0.125 of real time with CED-Base against 0.103 with CED-Tiny,
because diarization dominates; sound detection per committed turn costs
0.031 against 0.005. The realtime docs list both and note that any CED
size works as sound_model.

Files and sha256 match the Hub and are shared with the existing
parakeet-cpp-ced-base entry. The TDT variant passes the real-model
scene spec with CED-Base (A-B-A-B speakers, "Chicken, rooster" found).

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

* fix(config): register pipeline.diarization in the config metadata

TestAllFieldsHaveRegistryEntries fails on the branch because the new
pipeline.diarization field has no registry entry. Add one so the model
editor shows it as a toggle next to the sound detection options.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-29 23:58:17 +02:00
Ettore Di Giacinto 9c156656bd Merge PR #12285: feat(failover): serve a model name from a chain of local and remote targets
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-28 15:32:30 +00:00
f82efdb43b fix(models): fallback to application config default context size in /v1/models/capabilities (#12202) (#12216)
* fix(models): fallback to application config default context size (#12202)

Honor appConfig.ContextSize in /v1/models/capabilities when model context_size is unset.

* docs(models): explain context size fallback

Describe the application default used by capability discovery and
preserve the distinction between total context and per-request limits.

Assisted-by: Codex:GPT-6

* fix(models): apply the default context size only when context_size is unset

The request path applies the application default context size only
when a model leaves context_size unset. An explicit 0 or -1 falls
through to the backend fallback. The capabilities endpoint now does
the same, so it reports the value the backend uses.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-28 00:52:56 +02:00
Tai Anandlocalai-org-maint-bot eec532704c fix(functions): honor function_arguments_key when building the tool grammar (#11677)
* fix(functions): honor function_arguments_key when building the tool grammar

All four call sites of `Functions.ToJSONStructure(name, args string)` pass
`FunctionsConfig.FunctionNameKey` as *both* arguments, so
`FunctionArgumentsKey` never reaches the grammar generator.

`ToJSONStructure` writes the two properties into the same map:

    property[nameKey] = FunctionName{Const: function.Name}
    property[argsKey] = Argument{...}

When `nameKey == argsKey` the second assignment overwrites the first, so a
model configured with `function_name_key` gets a grammar carrying only the
arguments object -- the `{"const": "<function name>"}` constraint is gone and
the grammar can no longer express which function was called.

With `function_name_key: function`, the generated property set collapses from

    {"function": {"const": "get_weather"}, "arguments": {...}}

to

    {"function": {"type": "object", "properties": {...}}}

Setting only `function_arguments_key` is equally broken in the other
direction: the grammar keeps emitting `arguments` while `ParseFunctionCall`
(pkg/functions/parse.go) looks up the configured key, so the parsed call comes
back with its arguments empty.

The default configuration is unaffected -- with both keys empty
`ToJSONStructure` falls back to `name`/`arguments` for both parameters, which
is why this went unnoticed.

The existing `ToJSONStructure()` unit test already calls the helper with two
distinct keys, so only the call sites were wrong. Extend that test with a case
that keeps both custom keys distinct and asserts the two properties survive.

Signed-off-by: Anai-Guo <antai12232931@outlook.com>

* test(functions): cover configured grammar keys

Route grammar construction through FunctionsConfig so the regression test
covers the key wiring used by every endpoint.

Assisted-by: Codex:gpt-5

* chore: empty commit to trigger workflow approval

Signed-off-by: Tai An <antai12232931@outlook.com>

---------

Signed-off-by: Anai-Guo <antai12232931@outlook.com>
Signed-off-by: Tai An <antai12232931@outlook.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-28 00:52:51 +02:00
Ettore Di Giacinto f20cc16033 fix(openai): reject an unknown audio response_format as a 400 before the backend runs
Transcription and diarization checked response_format only after the
backend had run, and returned a plain error for an unknown value.
Failover counts a plain error as a target failure, so one request with
a bad response_format ran the backend on every target of a chain and
tripped all of them.

Check the format before the backend runs and answer 400.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5-5 [Claude Code]
2026-09-27 20:17:06 +00:00
Ettore Di Giacinto 27ceda46b0 refactor: name the realtime pipeline stages with constants
Review asked for constants instead of the stage literals ("vad",
"transcription", "llm", "tts", "sound_detection") passed to resolveStage,
stageCall and isChainStage, so the uses can be cross-checked. Add
PipelineStage* constants next to the Pipeline type in core/config: the
names match its yaml keys, and core/backend (preload roles) and the openai
realtime endpoint both need them.

Use them in realtime_model.go (stage routing and preload roles),
realtime.go (the voice_recognition preload role) and core/backend
preload.go. model_failover events take their stage from the stageChains
keys, so they now carry the constants too. The failover tests use the
constants for inputs and keep literal wire values in their event
assertions.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 07:42:21 +00:00
Ettore Di Giacinto 14c692098e fix(openai): answer 400 for a missing or malformed audio upload
TranscriptEndpoint, DiarizationEndpoint and SoundClassificationEndpoint
returned the raw c.FormFile("file") error. Echo turns a non-HTTPError into
a 500, so a request with no multipart boundary or no file field looked like
a server fault. This became visible in tests/e2e once the suite registered
a transcription-capable model (lp-transcription) at runtime: the
default-model middleware then resolves a model, the request reaches the
handler, and "should return mocked transcription" got a 500 in some spec
orders (seed 1790493709).

Read the upload through a small uploadedFile helper that maps any
FormFile failure to 400 with the field name and parser reason. Server-side
failures after that (temp dir, file create, copy) stay 500. The image and
LocalAI upload endpoints already return 400 here.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 07:42:21 +00:00
Ettore Di Giacinto 132bb216a1 fix(failover): resolve chains in transcription and sound-only sessions
Transcription-only and sound-detection-only realtime sessions passed a
chain config straight to the model loader. It has no backend, so the
loader fell back to greedy backend auto-detection: slow, and ending in
an unhelpful error. Sound-only sessions are a main use of chains.

The stage routing of the full pipeline moves into a stageRouter that
both realtime model kinds embed. Every stage resolves to the chain's
active target at build time and goes through the failover plan per
call. The session sends failover events for any model with chain
stages, and restarts them when a transcription session.update swaps
the model.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 07:42:20 +00:00
Ettore Di Giacinto 2ecbaab010 fix(failover): send remote targets their own upstream model
A chain request reached a cloud-proxy target with the client's model,
the chain name, whenever the target set no upstream_model: passthrough
forwards the body's model and translate falls back to it. The upstream
answered 404, which neither retries nor trips, while the liveness
probe, which checks the target's own name, kept passing.

PrepareTarget now sets the upstream model of a remote target to
proxy.upstream_model or the target name, the same name the probe uses.
The request pipeline and realtime chain stages both call it.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 07:42:20 +00:00
Ettore Di Giacinto ea55e2ffc3 feat(failover): switch realtime pipeline stages per call
A stage that names a chain is resolved on every call, so a switch keeps
the session and its conversation. Clients get localai.model.failover
events at session start and on every switch.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 07:42:20 +00:00
636c43baa5 fix(openai): return HTTP errors for pre-stream failures and report per-slot context (#12204)
* fix(capabilities): report the per-request context with split KV slots

With parallel slots and kv_unified:false, llama.cpp gives each slot
n_ctx/n_parallel, padded up to a multiple of 256. /models/capabilities
still reported the full n_ctx. A client that budgets a request against
context_size then overflows at a fraction of it.

EffectiveRequestContextSize returns the per-slot size in that case and
the full context otherwise. With the unified KV cache, the grpc-server
default, one request may use all of n_ctx. The capabilities endpoint
and the router's prompt trimmer now use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(openai): return an HTTP error when a stream fails before any chunk

A streamed chat request set the SSE headers, then waited for the
backend. When the backend failed before the first token, LocalAI sent
a 200 with a `data: {"error":...}` chunk and [DONE]. Clients that do
not parse error chunks saw an empty reply. cogito's LocalAI client was
one of them: nib users got "streaming decision produced no content"
instead of the context overflow that caused it.

Nothing has been written at that point, so the handler now returns the
error as a normal HTTP response. A failure after the first chunk keeps
the in-stream error chunk.

A prompt that exceeds the context is now a 400 on both paths, as in
the OpenAI API and llama-server, and no longer a 500. The message is
kept whole, because clients read the token counts from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(e2e): check the error from closing the response body

golangci-lint's errcheck flags the unchecked resp.Body.Close in the
new pre-stream error helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:19:58 +02:00
36d800ebd1 fix(api): report an alias's target in /v1/models/capabilities (#12183)
An alias config is a pure redirect with no backend of its own, so the
capabilities listing described it from its stub: no capabilities, no
modalities, and the default 4096 context_size. Clients that size their
context budget from this endpoint (nib, for one) then compacted every
turn against a model that really serves 100k.

Resolve the alias and report the target's capabilities, modalities and
context_size under the alias's id. A dangling or chained alias now
reports no enrichment instead of defaults no model runs with.

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:51:17 +02:00
lqp dbee3db07f feat(openai): add negative_prompt field to image generation request (#12031)
Expose a negative_prompt string parameter on the image endpoints,
matching Stable Diffusion WebUI / vLLM-Omni conventions. When both the
negative_prompt parameter and a '|'-suffixed negative prompt in the
main prompt are present, they are joined with a comma so callers can
keep a global negative prompt in negative_prompt and add per-image
negative tags after '|'.


Assisted-by: Pi: DeepSeek V4 Pro

Signed-off-by: Fedor Zuev <Fedor.Zuev@gmail.com>
2026-09-18 23:28:14 +02:00
Richard Palethorpe 2facfc0d88 feat: Add kimodo.cpp and 3D animation API/UI (#12095)
* fix(vulkan): preserve host ICD discovery for packaged backends

Add bundled Mesa manifests through VK_ADD_DRIVER_FILES instead of replacing the system driver list. Merge inherited and model-specific additive paths while preserving explicit operator overrides, with regression coverage.

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

Assisted-by: Codex:gpt-5.6-sol
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(3d): add Kimodo CPU and Vulkan animation backend

Introduce a distinct animation capability and model-described 3D operations, with a typed /3d/animate API, RPC transport, distributed media staging, permissions, and tracing.

Add a persistent kimodo.cpp adapter, skeleton GLB export, CPU/Vulkan packages, model and backend galleries, importer support, CI builds, and documentation. Adapt Studio inputs to each model and provide real-time skeleton playback, seeking, and history.

Cover backend validation, packaging, API behavior, importer inventories, distributed staging, and Studio workflows. Validate real-model CPU/Vulkan generation and deploy the integration to the local QA instance.

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

Assisted-by: Codex:gpt-5.6-sol
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(kimodocpp): adopt monolithic encoders and resident inference

Update upstream for resident weights, packed execution paths, and cached motion graphs. Default to all 32 text layers while retaining configurable streaming and legacy bundle support.

Use monolithic Q8_0 encoders by default and offer all six published quantizations through the gallery and importer. Refresh pinned hashes, tests, and documentation; remove the obsolete thread patch and ensure cached source checkouts follow the upstream pin.

Validated CPU and Vulkan generation, lower-bit streaming, gallery/importer suites, packaging, lint, and cold/warm Studio generation on localai-dev.

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

Assisted-by: Codex:gpt-5.6-sol
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-09-18 06:12:03 +01:00
localai-org-maint-botandEttore Di Giacinto 2d30d28f32 feat: expose context_size in /v1/models/capabilities (#12083)
* fix(ui): restore node operation controls

The node restructure hid backend logs and split related controls
across inconsistent layouts.

Restore contextual log actions and align the detail page with the
fleet dashboard. Make multi-node selection clear and accessible.

Assisted-by: Codex:gpt-5 Playwright ESLint

* feat: expose context_size in /v1/models/capabilities

The /v1/models/capabilities endpoint already enriches the OpenAI models
listing with capabilities and modalities. Add context_size to that
enrichment so clients (e.g. nib) can size their context budget for
auto-compaction and pruning without guessing.

The value comes from backend.EffectiveContextSize, which resolves the
configured context_size or falls back to DefaultContextSize (4096) —
the same number the backend itself runs with. It is omitempty so a
loose file with no config surfaces 0 (unknown) rather than a misleading
default, and existing clients that don't know the field see no change.

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-16 14:40:35 +00:00
Ettore Di Giacinto db12122329 fix(reasoning): ignore preclosed prompt markers
Do not seed streaming reasoning state when the latest prompt thinking marker is already followed by its matching closing marker. This keeps direct Gemma 4 output in content when its template disables thinking with a preclosed channel.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 22:44:39 +00:00
Stefan Walcz 109244a76a [chat] feat: template.system_messages_after_first — merge or forward late system turns (#11906)
* feat(chat): template.system_messages_after_first — merge or forward late system turns

Tokenizer chat templates such as Qwen3.8 / Qwen3.8-Flash-Next raise
'System message must be at the beginning' for system-role messages that
appear after the leading system block, while agent frameworks (cogito tool
selection and adjustment prompts) legitimately append system instructions
mid-conversation. Every such request failed with a 500 (48 errors in one
10-task agent run).

New per-model option template.system_messages_after_first:
  merge  fold late system turns into the leading system message
  user   forward them as user-role turns at their original position
Default (unset) keeps the current pass-through behaviour.

Fixes #11876

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* docs(model-config): document template.system_messages_after_first

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* fix(config/meta): register template.system_messages_after_first in the field registry

TestAllFieldsHaveRegistryEntries requires every model-config field to have
a registry entry. Adds the entry (templates section, select component) and
the option list for the new field so the coverage gate passes.

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

---------

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-09-09 22:21:32 +02:00
localai-org-maint-botandEttore Di Giacinto f12bcfac9a fix(realtime): support voice profile switching (#11948)
* fix(realtime): support session voice profile switching

Keep the active resolved voice binding on the realtime session so updates can atomically replace model, voice, and profile parameters while releasing leases at the correct lifecycle boundaries.

Assisted-by: Codex:gpt-5

* docs(realtime): explain voice profile switching

Document the session.update payload for selecting a Voice Library URI and clarify precedence when changing the model in the same event.\n\nAssisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-09 10:38:23 +02:00
localai-org-maint-botandEttore Di Giacinto 8c718441f6 fix(realtime): resolve pipeline voice profiles (#11942)
* fix(realtime): resolve saved voice profiles

Realtime pipelines now validate saved voices against the selected TTS model and retain leased audio until session teardown. Each synthesis request receives its own transcript parameter map.

Assisted-by: Codex:GPT-5

* docs(tts): document realtime voice defaults

Show how a realtime pipeline selects a saved Voice Library profile at session start. Clarify which session voice updates remain supported.

Assisted-by: Codex:GPT-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-08 23:42:31 +02:00
localai-org-maint-botandEttore Di Giacinto 780e458f11 fix(realtime): skip responses for empty transcripts (#11940)
Realtime turns could invoke the LLM and TTS even when speech transcription returned only whitespace. This let ambient noise produce unsolicited assistant output and polluted conversation history with an empty user turn.

Require non-blank transcript text before automatic response generation while preserving the completed transcription event.

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

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-08 23:12:57 +02: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-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
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
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
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 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 133c546c3f feat(api): add text moderation endpoint (#11316)
* feat(api): add text moderation endpoint

Add an OpenAI-compatible /v1/moderations endpoint backed by constrained local text generation. Register its auth and discovery surfaces, document the text-only MVP, and cover response shaping and access control.

Assisted-by: Codex:gpt-5

* test(mcp): update assistant client stub

Keep the LocalAI Assistant holder test stub aligned with the scheduling methods added to LocalAIClient so repository-wide type checking succeeds.\n\nAssisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 18:03:46 +02:00
Peteandlocalai-org-maint-bot 8a68f3571c feat(api): add POST /v1/images/upscale endpoint (#10227)
* feat(api): add POST /v1/images/upscale endpoint

Add a new image upscaling endpoint that accepts a source image and
returns an upscaled version. Supports selectable upscaler models
(e.g. realesrgan) and a configurable scale factor (2x or 4x).

- backend.proto: add UpscaleImage RPC and UpscaleImageRequest message
- pkg/grpc: implement UpscaleImage in Backend interface, client, server
  and embed shim
- core/backend/upscale.go: new backend helper (mirrors ImageGeneration)
- core/http/endpoints/openai/upscale.go: new multipart/form-data handler
- core/http/routes/openai.go: register POST /v1/images/upscale
- core/http/auth/features.go: gate upscale routes under FeatureImages
- backend/python/diffusers/backend.py: implement UpscaleImage — uses
  diffusers upscale pipeline when loaded, falls back to Lanczos resize

* fix(grpc): add UpscaleImage stub to Base backend

All Go backends embedding Base now satisfy the AIModel interface
without needing to implement UpscaleImage explicitly.

* fix(images): complete upscale endpoint integration

Store generated upscales under the served images directory, validate scale factors, document and advertise the endpoint, and add a functional Stable Diffusion x4 gallery model.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 15:27:22 +02:00