Files
LocalAI/core/config/model_config.go
T
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

2871 lines
128 KiB
Go

package config
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"text/template"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/piipattern"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/modelartifacts"
"github.com/mudler/LocalAI/pkg/reasoning"
"github.com/mudler/cogito"
"github.com/mudler/xlog"
"gopkg.in/yaml.v3"
)
const (
RAND_SEED = -1
)
// @Description TTS configuration
type TTSConfig struct {
// Voice wav path or id
Voice string `yaml:"voice,omitempty" json:"voice,omitempty"`
AudioPath string `yaml:"audio_path,omitempty" json:"audio_path,omitempty"`
// VoiceCloning overrides saved-profile capability detection for this model.
// A pointer preserves the distinction between an explicit false and the
// default automatic behavior.
VoiceCloning *bool `yaml:"voice_cloning,omitempty" json:"voice_cloning,omitempty"`
// Voices describes named voices accepted by this model. Backends with a
// built-in catalog supply defaults when this list is empty.
Voices []TTSVoice `yaml:"voices,omitempty" json:"voices,omitempty"`
}
// TTSVoice describes one named voice accepted by a text-to-speech model.
type TTSVoice struct {
Name string `yaml:"name" json:"name"`
Language string `yaml:"language,omitempty" json:"language,omitempty"`
Gender string `yaml:"gender,omitempty" json:"gender,omitempty"`
}
// @Description ModelConfig represents a model configuration
type ModelConfig struct {
modelConfigFile string `yaml:"-" json:"-"`
modelTemplate string `yaml:"-" json:"-"`
// evidenceCheck is set only from a profile alias's block, so a plain
// model config cannot switch it on by a top-level key.
evidenceCheck EvidenceCheckConfig `yaml:"-" json:"-"`
// persistedConfigRevision is the revision of this model's persisted
// configuration, stamped when the loader materializes it and therefore
// before any per-request override is merged in. The request pipeline
// mutates its copy of a ModelConfig with the caller's sampling parameters
// (temperature, top_p, stop, ...), so hashing the config at load time is
// the only way the controller sees one revision per configuration rather
// than one per request body. Unexported, so it never enters the hash it
// describes and never reaches YAML or JSON.
persistedConfigRevision string `yaml:"-" json:"-"`
// mcpSessionKey keys the MCP session cache when it must differ from Name.
// A profile alias that brings its own MCP servers resolves to its target's
// config (so Name, and with it the backend process, stays the target's),
// but its sessions must not share a cache slot with the target's: the
// slot is invalidated on every config change, so two server sets under one
// key would tear each other's sessions down. Unexported: never persisted.
mcpSessionKey string `yaml:"-" json:"-"`
schema.PredictionOptions `yaml:"parameters,omitempty" json:"parameters,omitempty"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Artifacts []modelartifacts.Spec `yaml:"artifacts,omitempty" json:"artifacts,omitempty"`
// Alias, when set, makes this config a pure redirect: every request for
// Name is served by the model named here. All other fields are ignored,
// except the opt-in Profile block. The target must be an existing,
// non-alias model (enforced at load and at create/swap time). See
// docs/content for Model Aliases.
Alias string `yaml:"alias,omitempty" json:"alias,omitempty"`
// Profile is only valid on an alias. Its fields are applied on top of the
// target's config for requests to the alias, while the request still runs
// on the target's backend process. Explicit block rather than honouring
// top-level fields so existing aliases keep behaving as pure redirects.
Profile ProfileConfig `yaml:"profile,omitempty" json:"profile,omitempty"`
// Failover makes this config a failover chain over other models. Like an
// alias it has no backend of its own.
Failover *FailoverConfig `yaml:"failover,omitempty" json:"failover,omitempty"`
F16 *bool `yaml:"f16,omitempty" json:"f16,omitempty"`
Threads *int `yaml:"threads,omitempty" json:"threads,omitempty"`
Debug *bool `yaml:"debug,omitempty" json:"debug,omitempty"`
Roles map[string]string `yaml:"roles,omitempty" json:"roles,omitempty"`
Embeddings *bool `yaml:"embeddings,omitempty" json:"embeddings,omitempty"`
Backend string `yaml:"backend,omitempty" json:"backend,omitempty"`
TemplateConfig TemplateConfig `yaml:"template,omitempty" json:"template,omitempty"`
KnownUsecaseStrings []string `yaml:"known_usecases,omitempty" json:"known_usecases,omitempty"`
KnownUsecases *ModelConfigUsecase `yaml:"-" json:"-"`
// KnownInputModalities and KnownOutputModalities describe model-specific I/O
// that usecases alone cannot express, such as image- or audio-conditioned video.
KnownInputModalities []string `yaml:"known_input_modalities,omitempty" json:"known_input_modalities,omitempty"`
KnownOutputModalities []string `yaml:"known_output_modalities,omitempty" json:"known_output_modalities,omitempty"`
Pipeline Pipeline `yaml:"pipeline,omitempty" json:"pipeline,omitempty"`
PromptStrings, InputStrings []string `yaml:"-" json:"-"`
InputToken [][]int `yaml:"-" json:"-"`
functionCallString, functionCallNameString string `yaml:"-" json:"-"`
ResponseFormat string `yaml:"-" json:"-"`
ResponseFormatMap map[string]any `yaml:"-" json:"-"`
// MediaMarker is the runtime-discovered multimodal marker the backend expects
// in the prompt (e.g. "<__media__>" or a random "<__media_<rand>__>" picked by
// llama.cpp). Populated on first successful ModelMetadata call. Empty until
// then — callers must fall back to templates.DefaultMultiMediaMarker.
MediaMarker string `yaml:"-" json:"-"`
FunctionsConfig functions.FunctionsConfig `yaml:"function,omitempty" json:"function,omitempty"`
ReasoningConfig reasoning.Config `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
Compression CompressionConfig `yaml:"compression,omitempty" json:"compression,omitempty"`
// ReasoningEffort is the default reasoning effort (none|minimal|low|medium|high)
// for this model. A per-request reasoning_effort overrides it. It is forwarded
// to the backend as the reasoning_effort chat_template_kwarg (see
// gRPCPredictOpts), so jinja-templated models that key on it — e.g. gpt-oss
// (Harmony) or LFM2.5 — honor it; "none" also toggles enable_thinking off.
ReasoningEffort string `yaml:"reasoning_effort,omitempty" json:"reasoning_effort,omitempty"`
// ChatTemplateKwargs are arbitrary key/values forwarded to the backend's jinja
// chat template via chat_template_kwargs (e.g. preserve_thinking: true). The
// server-derived reasoning levers (enable_thinking / reasoning_effort) and any
// per-request metadata overrides layer on top. See gRPCPredictOpts.
ChatTemplateKwargs map[string]any `yaml:"chat_template_kwargs,omitempty" json:"chat_template_kwargs,omitempty"`
// RequestMetadata holds the raw client request `metadata` map for the current
// request. The request middleware stamps it; gRPCPredictOpts merges it into the
// backend gRPC metadata (overriding the server-derived enable_thinking /
// reasoning_effort) and folds it, coerced, into the chat_template_kwargs blob.
// Never persisted to YAML.
RequestMetadata map[string]string `yaml:"-" json:"-"`
FeatureFlag FeatureFlag `yaml:"feature_flags,omitempty" json:"feature_flags,omitempty"` // Feature Flag registry. We move fast, and features may break on a per model/backend basis. Registry for (usually temporary) flags that indicate aborting something early.
// LLM configs (GPT4ALL, Llama.cpp, ...)
LLMConfig `yaml:",inline" json:",inline"`
// Diffusers
Diffusers Diffusers `yaml:"diffusers,omitempty" json:"diffusers,omitempty"`
Step int `yaml:"step,omitempty" json:"step,omitempty"`
// GRPC Options
GRPC GRPC `yaml:"grpc,omitempty" json:"grpc,omitempty"`
// TTS specifics
TTSConfig `yaml:"tts,omitempty" json:"tts,omitempty"`
// CUDA
// Explicitly enable CUDA or not (some backends might need it)
CUDA bool `yaml:"cuda,omitempty" json:"cuda,omitempty"`
DownloadFiles []File `yaml:"download_files,omitempty" json:"download_files,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Usage string `yaml:"usage,omitempty" json:"usage,omitempty"`
Disabled *bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
Pinned *bool `yaml:"pinned,omitempty" json:"pinned,omitempty"`
// ConcurrencyGroups declares per-node mutual-exclusion groups: the model
// cannot be loaded alongside another model that shares any group name.
// See docs/content/advanced/vram-management.md for usage.
ConcurrencyGroups []string `yaml:"concurrency_groups,omitempty" json:"concurrency_groups,omitempty"`
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
Overrides []string `yaml:"overrides,omitempty" json:"overrides,omitempty"`
MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp,omitempty"`
Agent AgentConfig `yaml:"agent,omitempty" json:"agent,omitempty"`
// ClientTools controls tools and functions sent by the client. Empty keeps
// them (default). "drop" discards them, together with tool_choice, before
// the request is merged, so only the model's own MCP tools are offered.
ClientTools string `yaml:"client_tools,omitempty" json:"client_tools,omitempty"`
PII PIIConfig `yaml:"pii,omitempty" json:"pii,omitempty"`
// PIIDetection is the detection policy when THIS model is used as a
// PII detector (a token_classify model named in another model's
// pii.detectors). Ignored on models that aren't referenced as
// detectors.
PIIDetection PIIDetectionConfig `yaml:"pii_detection,omitempty" json:"pii_detection,omitempty"`
Router RouterConfig `yaml:"router,omitempty" json:"router,omitempty"`
Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"`
MITM MITMModelConfig `yaml:"mitm,omitempty" json:"mitm,omitempty"`
Limits LimitsConfig `yaml:"limits,omitempty" json:"limits,omitempty"`
// Environment variables to set when starting the backend process
Environment map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
}
// CompressionConfig controls opt-in compression of chat history before inference.
// The request middleware consumes this configuration; keeping it on ModelConfig
// lets operators select a policy per context window and model workload.
type CompressionConfig struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
TriggerAtRatio float64 `yaml:"trigger_at_ratio,omitempty" json:"trigger_at_ratio,omitempty"`
KeepTailTokens int `yaml:"keep_tail_tokens,omitempty" json:"keep_tail_tokens,omitempty"`
MaxSummaryTokens int `yaml:"max_summary_tokens,omitempty" json:"max_summary_tokens,omitempty"`
CompressorModel string `yaml:"compressor_model,omitempty" json:"compressor_model,omitempty"`
OnPostCompressionOverflow string `yaml:"on_post_compression_overflow,omitempty" json:"on_post_compression_overflow,omitempty"`
}
// @Description Admission-control limits applied per request. The
// admission middleware enforces these before invoking the handler;
// requests that exceed a limit get 503 with a Retry-After hint so
// clients back off rather than pile on. Per-model so cloud passthroughs
// can have a stricter ceiling than local models.
type LimitsConfig struct {
// MaxConcurrent caps simultaneous in-flight requests for this
// model. 0 = unlimited (default). Useful for cloud-passthrough
// configs where the upstream rate-limits aggressively, or for
// local backends whose memory budget tops out before LocalAI's
// queue depth would.
MaxConcurrent int `yaml:"max_concurrent,omitempty" json:"max_concurrent,omitempty"`
// RetryAfterSeconds advises clients how long to wait before
// retrying when admission rejects. 0 defaults to 1s — enough to
// let an in-flight request finish on a busy local model. The
// value is sent verbatim in the Retry-After response header.
RetryAfterSeconds int `yaml:"retry_after_seconds,omitempty" json:"retry_after_seconds,omitempty"`
}
// @Description MITM intercept binding for the model. When the cloudproxy
// MITM listener is enabled and any host listed here appears in a CONNECT,
// the proxy uses THIS model config's pii: settings to filter the
// intercepted body. Strict 1-to-1: a host claimed by two configs is a
// configuration error and disables the MITM listener until resolved.
//
// Lets an admin pair a host (api.anthropic.com) with the model's
// PII overrides without maintaining a parallel per-host map.
type MITMModelConfig struct {
// Hosts is the list of hostnames this model claims for MITM
// interception. Each entry must be unique across all model configs.
Hosts []string `yaml:"hosts,omitempty" json:"hosts,omitempty"`
}
// @Description Cloud proxy configuration. The cloud-proxy backend
// forwards a model's traffic to an external provider. Two modes:
//
// - mode: passthrough — client and upstream must speak the same wire
// format; the backend ships the raw request body to the upstream
// URL and streams the response back untouched. The streaming PII
// filter still runs because it operates on extracted token text.
//
// - mode: translate — the backend converts LocalAI's internal proto
// to the provider's wire format and back. Unlocks cross-provider
// routing (OpenAI client → Anthropic upstream, etc.) at the cost
// of dropping provider-specific extensions that the internal proto
// doesn't model.
type ProxyConfig struct {
// UpstreamURL is the full POST endpoint, e.g.
// https://api.openai.com/v1/chat/completions or
// https://api.anthropic.com/v1/messages. Required.
UpstreamURL string `yaml:"upstream_url,omitempty" json:"upstream_url,omitempty"`
// Mode selects passthrough (wire-perfect) or translate (full
// control via internal proto). Empty defaults to passthrough.
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// Provider identifies the upstream's wire format for translate
// mode (openai, anthropic). Ignored in passthrough mode — the
// wire format there is whatever the client sent.
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
// APIKey is a plaintext administrator-managed upstream credential.
// Mutually exclusive with APIKeyEnv and APIKeyFile.
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
// APIKeyEnv names the environment variable holding the upstream
// API key. Mutually exclusive with APIKey and APIKeyFile. All empty is
// allowed (no-auth upstreams).
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"api_key_env,omitempty"`
// APIKeyFile is a path to a file whose contents are the upstream
// API key. Trailing whitespace is trimmed. Mutually exclusive
// with APIKey and APIKeyEnv. The integration point for K8s secret mounts,
// Vault agent files, and similar external-secret workflows.
APIKeyFile string `yaml:"api_key_file,omitempty" json:"api_key_file,omitempty"`
// UpstreamModel overrides the model name sent to the upstream.
// Useful when the LocalAI-facing model alias differs from the
// upstream's canonical name (e.g. local "claude-strict" maps to
// upstream "claude-3-5-sonnet-20241022"). Empty means forward
// the client's model field unchanged.
UpstreamModel string `yaml:"upstream_model,omitempty" json:"upstream_model,omitempty"`
// RequestTimeoutSeconds caps the upstream request duration. 0
// means no per-request timeout (only the request context, which
// is bound to the client connection, applies).
RequestTimeoutSeconds int `yaml:"request_timeout_seconds,omitempty" json:"request_timeout_seconds,omitempty"`
// CachePrompt enables automatic Anthropic prompt-cache breakpoints
// (cache_control: ephemeral) on the stable prefix — system prompt,
// tools, and the last message block — when mode=translate and
// provider=anthropic. Anthropic then serves the repeated prefix at
// the cache-read rate (0.1x input), which sharply cuts cost on
// agentic/multi-turn workloads that re-send a large stable prefix.
// No effect for passthrough mode or non-Anthropic providers.
CachePrompt bool `yaml:"cache_prompt,omitempty" json:"cache_prompt,omitempty"`
}
// Proxy mode names. Validate() normalises an empty Mode to
// ProxyModePassthrough so downstream code only sees concrete values.
const (
ProxyModePassthrough = "passthrough"
ProxyModeTranslate = "translate"
)
// Proxy provider names. Only meaningful in translate mode, where the
// cloud-proxy backend picks the wire format to use against the
// upstream URL.
const (
ProxyProviderOpenAI = "openai"
ProxyProviderAnthropic = "anthropic"
)
// LogValue uses an allowlist so future credential fields cannot leak through config logs.
func (c ModelConfig) LogValue() slog.Value {
return slog.GroupValue(slog.String("name", c.Name), slog.String("backend", c.Backend))
}
// LogValue deliberately omits credentials and potentially credential-bearing URLs.
func (p ProxyConfig) LogValue() slog.Value {
return slog.GroupValue(slog.String("mode", p.Mode), slog.String("provider", p.Provider))
}
func (p ProxyConfig) validateCredentialSources() error {
sources := 0
for _, value := range []string{p.APIKey, p.APIKeyEnv, p.APIKeyFile} {
if value != "" {
sources++
}
}
if sources > 1 {
return fmt.Errorf("proxy: api_key, api_key_env and api_key_file are mutually exclusive")
}
return nil
}
// ResolveAPIKey returns the upstream key from api_key, api_key_env or api_key_file, or
// "" when neither is set. Mirrored (not imported, to keep backends independent
// of core's package layout) by resolveAPIKey in backend/go/cloud-proxy/proxy.go
// and backend/go/localai-proxy/proxy.go — keep all copies in sync.
func (p ProxyConfig) ResolveAPIKey(envLookup func(string) string) (string, error) {
if err := p.validateCredentialSources(); err != nil {
return "", err
}
switch {
case p.APIKey != "":
return p.APIKey, nil
case p.APIKeyEnv != "":
var v string
if envLookup != nil {
v = envLookup(p.APIKeyEnv)
}
if v == "" {
return "", fmt.Errorf("proxy api_key_env %q is unset", p.APIKeyEnv)
}
return v, nil
case p.APIKeyFile != "":
b, err := os.ReadFile(p.APIKeyFile)
if err != nil {
return "", fmt.Errorf("proxy api_key_file %q: %w", p.APIKeyFile, err)
}
return strings.TrimSpace(string(b)), nil
}
return "", nil
}
// IsCloudProxyBackendPassthrough reports whether this model uses the
// cloud-proxy gRPC backend in passthrough mode. Empty Mode counts as
// passthrough (SetDefaults normalises it, but Validate accepts empty
// too — handlers should not rely on a particular call order).
func (c *ModelConfig) IsCloudProxyBackendPassthrough() bool {
if c.Backend != model.CloudProxyBackend {
return false
}
return c.Proxy.Mode == "" || c.Proxy.Mode == ProxyModePassthrough
}
// @Description Intelligent routing configuration. When a model declares
// a Router block, requests addressed to it are reclassified at runtime
// and dispatched to one of the named candidates. The router rewrites
// input.Model in-place, then the standard model-resolution path picks
// up the resolved config — meaning ACL checks, disabled-state, and
// per-model PII still run against the chosen target.
//
// Depth-1 invariant: candidates must NOT themselves carry a Router
// block. The router's "smart-router → claude-strict → cloud-proxy"
// chain is fine, but "router-A → router-B → claude" is rejected at
// config load to keep the dispatch graph acyclic and predictable. The
// middleware also asserts depth ≤ 1 at runtime as a defensive check.
type RouterConfig struct {
// Classifier selects score, colbert, knn, or native decisions. For score:
// it asks the classifier model to score every Policy label as a
// continuation of the routing prompt and reads off the
// distribution. Empty defaults to "score".
Classifier string `yaml:"classifier,omitempty" json:"classifier,omitempty"`
// Policies is the label vocabulary the classifier scores over.
// Each policy carries a natural-language description that ends up
// in the system prompt the classifier model sees — short, action-
// oriented sentences work best ("writing or debugging code",
// "small talk", ...). The Score classifier picks the subset of
// labels whose softmax probability passes ActivationThreshold.
Policies []RouterPolicy `yaml:"policies,omitempty" json:"policies,omitempty"`
// Candidates is the routing table — each entry binds a downstream
// model to a set of labels it can serve. The middleware picks the
// FIRST candidate whose Labels are a superset of the active label
// set from the classifier. Admins order this list smallest →
// largest so a query that needs one label routes to the smallest
// capable model, while a query that needs multiple falls to a
// bigger candidate that covers them all.
Candidates []RouterCandidate `yaml:"candidates,omitempty" json:"candidates,omitempty"`
// Fallback is the model used when no candidate matches the active
// label set, or when the classifier returns nothing above
// threshold. Empty fallback means router failures bubble up as
// 500 — fail-fast, not silent-bypass.
Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"`
// ClassifierModel names the model the Score classifier scores
// against (Arch-Router-1.5B is the canonical choice).
ClassifierModel string `yaml:"classifier_model,omitempty" json:"classifier_model,omitempty"`
// ClassifierCacheSize bounds the per-prompt memo cache that
// amortises the classifier round-trip across repeat probes.
// 0 disables the cache. Default 1024.
ClassifierCacheSize int `yaml:"classifier_cache_size,omitempty" json:"classifier_cache_size,omitempty"`
// For decisions, ActivationThreshold is an independent P(true) floor;
// zero selects the default 0.5.
// ActivationThreshold is the softmax-probability floor a policy
// must clear to be considered "active" for the request. 0
// defaults to a sensible value (~0.15) inside the classifier.
// Higher → narrower routes (single-label dominant); lower →
// more multi-label activations.
ActivationThreshold float64 `yaml:"activation_threshold,omitempty" json:"activation_threshold,omitempty"`
// ClassifierSystemTemplate overrides the routing system prompt
// the score classifier feeds to its classifier_model. Go
// text/template + Sprig, executed with `.Policies []ScorePolicy`
// (Label + Description fields). Empty falls back to the built-in
// Arch-Router-shaped template (route-listing block + JSON output
// schema). Override when the classifier model was trained on a
// different schema (e.g. bare label output, XML route block) or
// when the routing instructions need to be in a different
// language. The candidate format scored against the model is
// fixed at `{"route": "<label>"}` and IS NOT templated — keep
// your override's output schema instruction matching that, or
// the per-candidate scores degenerate.
ClassifierSystemTemplate string `yaml:"classifier_system_template,omitempty" json:"classifier_system_template,omitempty"`
// ScoreNormalization picks how the score classifier collapses
// per-candidate joint log-probs into the softmax input.
// - ""/"raw": use joint log-prob as-is (default). Matches the
// distribution the classifier model was trained against — the
// route the model would actually emit if decoded freely.
// - "mean": divide by candidate token count. Fairer to long
// labels (their joint log-prob is mechanically smaller because
// it sums more negatives), but off-distribution for models
// trained to emit fixed-format outputs like Arch-Router's
// {"route": "name"}.
// Future modes (e.g. "weighted_mean") will land here too.
ScoreNormalization string `yaml:"score_normalization,omitempty" json:"score_normalization,omitempty"`
// EmbeddingCache configures the L2 cache that maps prompt
// embeddings to past decisions, so semantically-similar prompts
// reuse a classification instead of re-running the classifier
// model. Omit the block to disable. See router/embedding_cache.go.
// Ignored (with a warning) for the knn classifier — that IS a
// KNN lookup already; wrapping it in another would embed twice
// for no additional information.
EmbeddingCache *EmbeddingCacheConfig `yaml:"embedding_cache,omitempty" json:"embedding_cache,omitempty"`
// KNN configures the "knn" classifier: nearest-neighbour voting
// over a curated corpus of labelled example prompts. Required when
// classifier is "knn", ignored otherwise. The corpus is seeded and
// curated through the router corpus API (never through the UI);
// see router/knn.go for the decision semantics.
KNN *RouterKNNConfig `yaml:"knn,omitempty" json:"knn,omitempty"`
}
// EmbeddingCacheConfig configures the L2 embedding-similarity decision
// cache. Pairs naturally with a larger / slower classifier model: the
// classifier round-trip is amortised across paraphrases of the same
// intent. The cache uses the standard /v1/embeddings backend for
// vector generation and the local-store gRPC surface for KNN search.
type EmbeddingCacheConfig struct {
// EmbeddingModel names the loaded LocalAI model used to embed
// router prompts. Required when the cache is enabled. Any model
// that supports the Embeddings gRPC primitive works;
// nomic-embed-text-v1.5 is the recommended default.
EmbeddingModel string `yaml:"embedding_model" json:"embedding_model"`
// SimilarityThreshold is the cosine-similarity floor a cache
// candidate must clear to be treated as a hit. 0 picks the
// package default (0.80). Higher → fewer false hits, higher miss
// rate; lower → more aggressive sharing across paraphrases.
SimilarityThreshold float64 `yaml:"similarity_threshold,omitempty" json:"similarity_threshold,omitempty"`
// ConfidenceThreshold is the minimum classifier top-label
// probability for a decision to be inserted into the cache. 0
// picks the package default (0.60). Uncertain decisions are not
// cached so they can't poison future paraphrases.
ConfidenceThreshold float64 `yaml:"confidence_threshold,omitempty" json:"confidence_threshold,omitempty"`
// StoreName overrides the local-store collection name used for
// this router's cache. Empty defaults to "router-cache-<router>"
// where <router> is the parent model name. Useful when two
// router models should share a cache (rare).
StoreName string `yaml:"store_name,omitempty" json:"store_name,omitempty"`
}
// RouterKNNMaxK bounds the local priority queue and the number of neighbors
// returned across the store boundary for one routing decision.
const RouterKNNMaxK = 1024
// RouterKNNConfig configures the knn classifier. It shares the
// embedding + local-store plumbing with EmbeddingCacheConfig but the
// two are deliberately separate blocks: the cache stores another
// classifier's decisions opportunistically, while the KNN corpus is
// explicit labelled ground truth — different lifecycle, different
// store namespace, different failure story.
type RouterKNNConfig struct {
// EmbeddingModel names the loaded LocalAI model used to embed
// both corpus entries and incoming probes. Required. Changing it
// invalidates the stored vectors — the corpus loader re-embeds
// entries recorded under a different embedder fingerprint.
EmbeddingModel string `yaml:"embedding_model" json:"embedding_model"`
// EmbeddingRevision is an operator-controlled identity suffix for
// embedding backends whose underlying weights can change without a
// stable local checksum or config change. Bump it when replacing such
// a model in place so persisted corpus vectors are re-embedded.
EmbeddingRevision string `yaml:"embedding_revision,omitempty" json:"embedding_revision,omitempty"`
// K is how many nearest corpus entries vote on a probe. 0 picks
// the package default (3). K=1 reproduces exact nearest-entry
// routing; larger K tolerates mislabelled exemplars at the cost
// of needing denser corpus coverage per label region.
K int `yaml:"k,omitempty" json:"k,omitempty"`
// SimilarityThreshold is the epistemic gate: corpus entries less
// similar than this to the probe cannot vote, and when none clear
// it the router uses the fallback model — a probe unlike all
// labelled experience is undecidable, not a guess. 0 picks the
// package default (0.80).
SimilarityThreshold float64 `yaml:"similarity_threshold,omitempty" json:"similarity_threshold,omitempty"`
// VoteThreshold is the similarity-weighted vote share a label
// needs to activate. 0 picks the package default (0.5, a weighted
// majority). Lower values let minority-label neighbours activate
// additional labels (multi-label routing); higher values demand
// near-unanimous neighbourhoods.
VoteThreshold float64 `yaml:"vote_threshold,omitempty" json:"vote_threshold,omitempty"`
// StoreName overrides the local-store collection holding the
// corpus vectors. Empty defaults to "router-corpus-<router>".
StoreName string `yaml:"store_name,omitempty" json:"store_name,omitempty"`
}
// Validate rejects values that would make weighted cosine voting invalid or
// allow one request to allocate an unbounded neighbor result. Zero retains the
// documented default sentinel for backward compatibility.
func (k *RouterKNNConfig) Validate() error {
if k.K < 0 || k.K > RouterKNNMaxK {
return fmt.Errorf("router.knn.k must be 0 (default) or between 1 and %d, got %d", RouterKNNMaxK, k.K)
}
if math.IsNaN(k.SimilarityThreshold) || math.IsInf(k.SimilarityThreshold, 0) {
return fmt.Errorf("router.knn.similarity_threshold must be finite, got %v", k.SimilarityThreshold)
}
if k.SimilarityThreshold < 0 || k.SimilarityThreshold > 1 {
return fmt.Errorf("router.knn.similarity_threshold must be 0 (default) or greater than 0 and at most 1, got %v", k.SimilarityThreshold)
}
if math.IsNaN(k.VoteThreshold) || math.IsInf(k.VoteThreshold, 0) {
return fmt.Errorf("router.knn.vote_threshold must be finite, got %v", k.VoteThreshold)
}
if k.VoteThreshold < 0 || k.VoteThreshold > 1 {
return fmt.Errorf("router.knn.vote_threshold must be 0 (default) or greater than 0 and at most 1, got %v", k.VoteThreshold)
}
return nil
}
// ResolvedStoreName is the local-store collection this router's corpus
// actually lives in. The single source of the "router-corpus-<router>"
// default — the classifier build, the corpus API, the status page, and
// the assistant MCP tools all resolve through here so they cannot
// desync on which store they touch.
func (k *RouterKNNConfig) ResolvedStoreName(routerName string) string {
if k.StoreName != "" {
return k.StoreName
}
return "router-corpus-" + routerName
}
// ResolvedEmbeddingFingerprint binds the resolved embedding-model
// fingerprint to the optional operator revision. Keeping this combination
// in config makes the classifier build path and corpus management surfaces
// derive exactly the same persisted identity.
func (k *RouterKNNConfig) ResolvedEmbeddingFingerprint(modelFingerprint string) string {
sum := sha256.Sum256([]byte(modelFingerprint + "\x00" + k.EmbeddingRevision))
return hex.EncodeToString(sum[:])
}
// RouterPolicy is one entry in the label vocabulary. The label string
// is what the classifier model emits and what candidates reference in
// their Labels field; the description is the natural-language hint
// fed to the classifier so it can match user intent against the label
// space.
type RouterPolicy struct {
Label string `yaml:"label" json:"label"`
Description string `yaml:"description" json:"description"`
}
// RouterCandidate names a downstream model and the policy labels it
// is willing to serve. Labels are matched as a set: the middleware
// picks the first candidate whose Labels is a superset of the
// classifier's active set.
type RouterCandidate struct {
Model string `yaml:"model" json:"model"`
Labels []string `yaml:"labels" json:"labels"`
}
// HasRouter returns true when the model declares a router config with
// at least one candidate. Used by the RouteModel middleware to decide
// whether to engage the classifier.
func (c *ModelConfig) HasRouter() bool {
return len(c.Router.Candidates) > 0
}
// IsAlias reports whether this config is a pure redirect to another model.
// Value receiver so it is callable on non-addressable config values too.
func (c ModelConfig) IsAlias() bool { return c.Alias != "" }
// ClientToolsDrop is the client_tools value that discards client-sent tools.
const ClientToolsDrop = "drop"
// DropsClientTools reports whether tools sent by the client are discarded.
func (c ModelConfig) DropsClientTools() bool { return c.ClientTools == ClientToolsDrop }
// MCPSessionKey is the name the MCP session cache uses for this config. It is
// Name unless a profile alias replaced the MCP servers of its target.
func (c ModelConfig) MCPSessionKey() string {
if c.mcpSessionKey != "" {
return c.mcpSessionKey
}
return c.Name
}
// @Description Profile overlay of a model alias
type ProfileConfig struct {
// SystemPrompt replaces the target's system prompt.
SystemPrompt string `yaml:"system_prompt,omitempty" json:"system_prompt,omitempty"`
// MCP replaces the target's MCP servers when it lists any; MCP.Auto
// enables automatic activation either way.
MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp,omitempty"`
// ClientTools overrides the target's client_tools policy.
ClientTools string `yaml:"client_tools,omitempty" json:"client_tools,omitempty"`
// EvidenceCheck marks answers whose numbers have no evidence in the request.
EvidenceCheck EvidenceCheckConfig `yaml:"evidence_check,omitempty" json:"evidence_check,omitempty"`
}
// DefaultEvidenceNote is appended when evidence_check.note is empty.
const DefaultEvidenceNote = "_Note: this answer contains numbers that no tool returned in this turn — not verified._"
// @Description Output check of a profile alias
type EvidenceCheckConfig struct {
// Numbers enables the check for numbers in the final answer.
Numbers bool `yaml:"numbers,omitempty" json:"numbers,omitempty"`
// Note replaces DefaultEvidenceNote.
Note string `yaml:"note,omitempty" json:"note,omitempty"`
}
// EvidenceCheck returns the evidence check a profile alias applied to this
// resolved config; it is zero for every other config.
func (c ModelConfig) EvidenceCheck() EvidenceCheckConfig { return c.evidenceCheck }
// EvidenceNote returns the configured note or the default one.
func (e EvidenceCheckConfig) EvidenceNote() string {
if e.Note != "" {
return e.Note
}
return DefaultEvidenceNote
}
// IsZero reports whether the block sets nothing.
func (p ProfileConfig) IsZero() bool { return p == ProfileConfig{} }
// applyProfileOverlay merges an alias's profile block into a copy of its
// target. Name, Model, Backend and the persisted revision stay the target's:
// they form the loader key, so the alias keeps sharing the target's backend
// process instead of loading the weights a second time. Only the profile block
// is read; any other field on the alias stays ignored, as for a plain alias.
func (c *ModelConfig) applyProfileOverlay(alias *ModelConfig) {
p := alias.Profile
if p.SystemPrompt != "" {
c.SystemPrompt = p.SystemPrompt
}
if p.MCP.HasMCPServers() {
c.MCP.Servers = p.MCP.Servers
c.MCP.Stdio = p.MCP.Stdio
c.mcpSessionKey = alias.Name
}
if p.MCP.Auto {
c.MCP.Auto = true
}
if p.ClientTools != "" {
c.ClientTools = p.ClientTools
}
c.evidenceCheck = p.EvidenceCheck
}
// @Description PII filtering configuration. PII redaction is per-model so
// that local models don't pay the latency or behaviour change of regex
// scanning, while cloud-bound traffic (cloud-proxy backend) can default to
// on. Setting Enabled explicitly always wins over the backend default.
type PIIConfig struct {
// Enabled toggles redaction for this model. When unset (zero value),
// the resolved default depends on Backend: cloud-proxy defaults to
// true, everything else to false. A pointer is used so the absence of
// the YAML key is distinguishable from explicit false.
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// Detectors lists the token-classification (NER) models whose
// detections drive PII redaction for this model. The detection policy
// (min score, per-entity actions, default action) lives on each named
// detector model's own pii_detection block, not here — a consuming
// model just opts in by listing detectors. Multiple detectors union
// their hits; overlapping spans resolve to the strongest action.
Detectors []string `yaml:"detectors,omitempty" json:"detectors,omitempty"`
// ReversibleRedactions replaces request PII with stable, request-scoped
// tokens and restores those values when the wrapped tokens appear in the response.
ReversibleRedactions bool `yaml:"reversible_redactions,omitempty" json:"reversible_redactions,omitempty"`
ReversibleTokenPrefix string `yaml:"reversible_token_prefix,omitempty" json:"reversible_token_prefix,omitempty"`
ReversibleTokenSuffix string `yaml:"reversible_token_suffix,omitempty" json:"reversible_token_suffix,omitempty"`
// BlockUnscannedMedia rejects requests that carry content the text
// detectors cannot inspect (images, audio, video, files) instead of
// forwarding it unchecked. Only applies while PII is enabled for the
// model. Meant for egress policies where "not inspectable" must mean
// "not sent", e.g. cloud-proxy models.
BlockUnscannedMedia bool `yaml:"block_unscanned_media,omitempty" json:"block_unscanned_media,omitempty"`
}
func (c ModelConfig) PIIReversibleRedactions() bool { return c.PII.ReversibleRedactions }
func (c ModelConfig) PIIReversibleTokenPrefix() string { return c.PII.ReversibleTokenPrefix }
func (c ModelConfig) PIIReversibleTokenSuffix() string { return c.PII.ReversibleTokenSuffix }
func (c ModelConfig) PIIBlockUnscannedMedia() bool { return c.PII.BlockUnscannedMedia }
// @Description Detection policy for a token-classification (NER) model
// used as a PII detector. Lives on the detector model's own config so the
// model is a self-describing policy unit: consuming models reference it by
// name (via pii.detectors) and inherit this policy with no per-consumer
// overrides.
type PIIDetectionConfig struct {
// MinScore drops detections the model scores below this confidence
// before they are acted on. 0 keeps every detection.
MinScore float32 `yaml:"min_score,omitempty" json:"min_score,omitempty"`
// DefaultAction (mask | block | allow) applies to detected entity
// groups with no explicit EntityActions entry. Empty defaults to
// "mask" — the safe-by-default policy for a PII filter.
DefaultAction string `yaml:"default_action,omitempty" json:"default_action,omitempty"`
// EntityActions maps an entity group the model emits (e.g. "EMAIL",
// "PASSWORD") to an action, overriding DefaultAction for that group.
// This is where an operator says which PII to block vs mask vs
// allow-log.
EntityActions map[string]string `yaml:"entity_actions,omitempty" json:"entity_actions,omitempty"`
// Builtins names the built-in pattern groups this (pattern) detector
// enables, e.g. "anthropic_api_key", "github_token". Pattern detectors
// match high-entropy structured secrets the NER tier can't; see
// core/services/routing/piipattern.
Builtins []string `yaml:"builtins,omitempty" json:"builtins,omitempty"`
// Patterns lists operator-defined secret patterns in the restricted-regex
// subset (validated at load). Each match is reported under its Name as the
// entity group, so EntityActions/DefaultAction apply by Name.
Patterns []PIIPattern `yaml:"patterns,omitempty" json:"patterns,omitempty"`
// ExtendToNextWord lists entity groups (e.g. "ZIPCODE") whose accepted
// detections also cover the word right after them on the same line,
// with the same action - for a neighbour the model scores unreliably,
// such as the town after a postal code.
ExtendToNextWord []string `yaml:"extend_to_next_word,omitempty" json:"extend_to_next_word,omitempty"`
// ExtendToPreviousWord lists entity groups (e.g. "LASTNAME") whose accepted
// detections also cover the word right before them on the same line, when
// that word consists of letters only - for a neighbour the model scores
// unreliably, such as the first name before a recognised surname.
ExtendToPreviousWord []string `yaml:"extend_to_previous_word,omitempty" json:"extend_to_previous_word,omitempty"`
// ProtectedTerms are values that must pass the filter unchanged even
// when the model tags them (e.g. known business names read as
// surnames). They are hidden from the detector behind a placeholder and
// restored afterwards. Matched whole-word and case-insensitively.
ProtectedTerms []string `yaml:"protected_terms,omitempty" json:"protected_terms,omitempty"`
// ProtectedTermsFiles adds terms from files (one per line, '#' comments),
// resolved inside the models path and re-read when they change, so an
// external process can keep the list current without a restart.
ProtectedTermsFiles []string `yaml:"protected_terms_files,omitempty" json:"protected_terms_files,omitempty"`
}
// PIIPattern is one operator-defined pattern on a pattern detector model. Name
// is the entity group reported for matches (and the EntityActions key). Match
// is the restricted-regex source. Action optionally overrides DefaultAction for
// this pattern. MinLen drops matches shorter than N bytes (0 = no floor).
type PIIPattern struct {
Name string `yaml:"name" json:"name"`
Match string `yaml:"match" json:"match"`
Action string `yaml:"action,omitempty" json:"action,omitempty"`
MinLen int `yaml:"min_len,omitempty" json:"min_len,omitempty"`
}
// PIIIsEnabled returns the resolved PII state for this model. Single
// source of truth for the gating decision so the middleware and the
// /api/middleware/status admin view agree.
func (c *ModelConfig) PIIIsEnabled() bool {
if c.PII.Enabled != nil {
return *c.PII.Enabled
}
return c.Backend == model.CloudProxyBackend
}
// PIIDetectors returns the names of the token-classification models that
// drive PII redaction for this (consuming) model. Read via the
// ModelPIIConfig interface in core/services/routing/pii/middleware.go.
func (c *ModelConfig) PIIDetectors() []string {
if len(c.PII.Detectors) == 0 {
return nil
}
out := make([]string, len(c.PII.Detectors))
copy(out, c.PII.Detectors)
return out
}
// piiCoverableUsecases lists the model usecases whose serving API has a
// request-side PII filter wired (a piiadapter + the pii middleware). It scopes
// the Middleware admin list (PIIFilterApplies). Grow it as adapters are added
// for new endpoints. cloud-proxy carries no usecase flag but is always covered
// (via the MITM / proxy chat path), so PIIFilterApplies handles it separately.
var piiCoverableUsecases = []ModelConfigUsecase{FLAG_CHAT, FLAG_COMPLETION, FLAG_EDIT, FLAG_EMBEDDINGS}
// PIIFilterApplies reports whether request-side PII filtering can apply to
// this model at all — i.e. it is reachable through a text-accepting endpoint
// that has a PII adapter wired. Used to scope the Middleware admin view so it
// lists only models PII could protect, not every config (VAD, STT,
// embedding-only, image, or the token_classify detector models themselves,
// which are the filters rather than consumers). Detector/score models return
// false naturally: HasUsecases short-circuits to false for any usecase a
// declared score/token_classify model did not itself declare.
func (c *ModelConfig) PIIFilterApplies() bool {
if c.Backend == model.CloudProxyBackend {
return true
}
return slices.ContainsFunc(piiCoverableUsecases, c.HasUsecases)
}
// PIIDetectionMinScore returns the confidence floor this model applies
// when used as a PII detector.
func (c *ModelConfig) PIIDetectionMinScore() float32 { return c.PIIDetection.MinScore }
// PIIDetectionDefaultAction returns the raw default-action string applied
// to detected entity groups without an explicit override. The pii package
// validates it and applies the "mask" fallback.
func (c *ModelConfig) PIIDetectionDefaultAction() string { return c.PIIDetection.DefaultAction }
// PIIDetectionEntityActions returns the per-entity-group action policy as
// a fresh map of raw action strings (validated by the pii package).
func (c *ModelConfig) PIIDetectionEntityActions() map[string]string {
if len(c.PIIDetection.EntityActions) == 0 {
return nil
}
out := make(map[string]string, len(c.PIIDetection.EntityActions))
for k, v := range c.PIIDetection.EntityActions {
out[k] = v
}
return out
}
// PIIDetectionExtendToNextWord returns the entity groups whose detections
// are stretched over the following word (a fresh copy).
func (c *ModelConfig) PIIDetectionExtendToNextWord() []string {
if len(c.PIIDetection.ExtendToNextWord) == 0 {
return nil
}
return append([]string(nil), c.PIIDetection.ExtendToNextWord...)
}
// PIIDetectionExtendToPreviousWord returns the entity groups whose detections
// are stretched over the preceding word (a fresh copy).
func (c *ModelConfig) PIIDetectionExtendToPreviousWord() []string {
if len(c.PIIDetection.ExtendToPreviousWord) == 0 {
return nil
}
return append([]string(nil), c.PIIDetection.ExtendToPreviousWord...)
}
// PIIDetectionProtectedTerms returns the inline protected terms and the
// configured term files (fresh copies). The files are read by the pii
// package (pii.LoadProtectedTerms), relative to the models path.
func (c *ModelConfig) PIIDetectionProtectedTerms() (terms, files []string) {
return slices.Clone(c.PIIDetection.ProtectedTerms), slices.Clone(c.PIIDetection.ProtectedTermsFiles)
}
// IsPatternDetector reports whether this detector model matches secrets with
// regex patterns (built-in and/or operator-defined) rather than a neural NER
// model. Such a model runs entirely in-process (no backend / GGUF / VRAM); the
// PII resolver builds an in-process pattern matcher for it instead of loading a
// gRPC token-classifier.
func (c *ModelConfig) IsPatternDetector() bool {
return len(c.PIIDetection.Builtins) > 0 || len(c.PIIDetection.Patterns) > 0
}
// @Description MCP configuration
type MCPConfig struct {
Servers string `yaml:"remote,omitempty" json:"remote,omitempty"`
Stdio string `yaml:"stdio,omitempty" json:"stdio,omitempty"`
// Auto enables every configured server on /v1/chat/completions when the
// request does not select servers via metadata.mcp_servers. Off by
// default: plain OpenAI clients cannot send that metadata, so without it
// they never reach the model's server-side tools.
Auto bool `yaml:"auto,omitempty" json:"auto,omitempty"`
}
// @Description Agent configuration
type AgentConfig struct {
MaxAttempts int `yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
MaxIterations int `yaml:"max_iterations,omitempty" json:"max_iterations,omitempty"`
EnableReasoning bool `yaml:"enable_reasoning,omitempty" json:"enable_reasoning,omitempty"`
EnablePlanning bool `yaml:"enable_planning,omitempty" json:"enable_planning,omitempty"`
EnableMCPPrompts bool `yaml:"enable_mcp_prompts,omitempty" json:"enable_mcp_prompts,omitempty"`
EnablePlanReEvaluator bool `yaml:"enable_plan_re_evaluator,omitempty" json:"enable_plan_re_evaluator,omitempty"`
DisableSinkState bool `yaml:"disable_sink_state,omitempty" json:"disable_sink_state,omitempty"`
LoopDetection int `yaml:"loop_detection,omitempty" json:"loop_detection,omitempty"`
MaxAdjustmentAttempts int `yaml:"max_adjustment_attempts,omitempty" json:"max_adjustment_attempts,omitempty"`
ForceReasoningTool bool `yaml:"force_reasoning_tool,omitempty" json:"force_reasoning_tool,omitempty"`
// MaxGenerationTokens caps the tokens a single completion may generate in
// the agent loop. Neither max_iterations nor loop_detection stops one
// completion that keeps generating; without a per-model value every model
// gets the client's fixed default, which is too low for some reasoning
// models and far above what others need. 0 keeps the client default.
MaxGenerationTokens int `yaml:"max_generation_tokens,omitempty" json:"max_generation_tokens,omitempty"`
}
// ApplyAgentGenerationCap puts agent.max_generation_tokens on the LLM client
// the agent loop uses. The client applies it to every request that carries no
// cap of its own, so a larger cap cogito sets when it retries a completion
// truncated by length still wins. Unset leaves the client default in place.
func (c *ModelConfig) ApplyAgentGenerationCap(llm interface{ SetMaxTokens(int) }) {
if c.Agent.MaxGenerationTokens > 0 {
llm.SetMaxTokens(c.Agent.MaxGenerationTokens)
}
}
// HasMCPServers returns true if any MCP servers (remote or stdio) are configured.
func (c MCPConfig) HasMCPServers() bool {
return c.Servers != "" || c.Stdio != ""
}
// ServerNames returns the names of all configured remote and stdio servers,
// sorted so the enabled set does not depend on map iteration order.
func (c *MCPConfig) ServerNames() ([]string, error) {
remote, stdio, err := c.MCPConfigFromYAML()
if err != nil {
return nil, err
}
names := make([]string, 0, len(remote.Servers)+len(stdio.Servers))
for name := range remote.Servers {
names = append(names, name)
}
for name := range stdio.Servers {
names = append(names, name)
}
slices.Sort(names)
return names, nil
}
func (c *MCPConfig) MCPConfigFromYAML() (MCPGenericConfig[MCPRemoteServers], MCPGenericConfig[MCPSTDIOServers], error) {
var remote MCPGenericConfig[MCPRemoteServers]
var stdio MCPGenericConfig[MCPSTDIOServers]
if err := yaml.Unmarshal([]byte(c.Servers), &remote); err != nil {
return remote, stdio, err
}
if err := yaml.Unmarshal([]byte(c.Stdio), &stdio); err != nil {
return remote, stdio, err
}
for name, server := range remote.Servers {
if err := server.Validate(); err != nil {
return remote, stdio, fmt.Errorf("remote MCP server %q: %w", name, err)
}
}
return remote, stdio, nil
}
// @Description MCP generic configuration
type MCPGenericConfig[T any] struct {
Servers T `yaml:"mcpServers,omitempty" json:"mcpServers,omitempty"`
}
type (
MCPRemoteServers map[string]MCPRemoteServer
MCPSTDIOServers map[string]MCPSTDIOServer
)
// @Description MCP remote server configuration
type MCPRemoteServer struct {
URL string `json:"url,omitempty"`
Token string `json:"token,omitempty"`
// OAuth2 makes LocalAI obtain short-lived access tokens with the
// client_credentials grant instead of sending a static Token. The two are
// mutually exclusive.
OAuth2 *MCPOAuth2Config `yaml:"oauth2,omitempty" json:"oauth2,omitempty"`
}
// @Description OAuth2 client_credentials settings for a remote MCP server
type MCPOAuth2Config struct {
TokenURL string `yaml:"token_url,omitempty" json:"token_url,omitempty"`
// ClientID / ClientSecret may be given literally, or by naming an
// environment variable (*_env) so the secret stays out of the model YAML.
// Exactly one of the two forms must be set for each.
ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"`
ClientIDEnv string `yaml:"client_id_env,omitempty" json:"client_id_env,omitempty"`
ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"`
ClientSecretEnv string `yaml:"client_secret_env,omitempty" json:"client_secret_env,omitempty"`
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
// EndpointParams are extra form parameters for the token request, e.g.
// "audience" (Auth0/Keycloak) or "resource" (RFC 8707).
EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"`
}
// Validate checks the static shape of a remote server entry. Environment
// variables are deliberately not resolved here: in distributed mode the
// connection is made on an agent worker, whose environment may differ from
// the frontend that loads the YAML.
func (s MCPRemoteServer) Validate() error {
if s.OAuth2 == nil {
return nil
}
if s.Token != "" {
return errors.New("token and oauth2 are mutually exclusive")
}
o := s.OAuth2
if o.TokenURL == "" {
return errors.New("oauth2.token_url is required")
}
if err := validateTokenURL(o.TokenURL); err != nil {
return err
}
if err := exactlyOne("client_id", o.ClientID, o.ClientIDEnv); err != nil {
return err
}
return exactlyOne("client_secret", o.ClientSecret, o.ClientSecretEnv)
}
// validateTokenURL requires https for the token endpoint so the client
// secret is not sent in clear text. Loopback hosts are exempt to allow
// local identity providers and tests.
func validateTokenURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("oauth2.token_url %q is not a valid absolute URL", raw)
}
switch u.Scheme {
case "https":
return nil
case "http":
switch u.Hostname() {
case "localhost", "127.0.0.1", "::1":
return nil
}
}
return errors.New("oauth2.token_url must use https (http is allowed only for localhost, 127.0.0.1 and ::1)")
}
func exactlyOne(field, literal, env string) error {
switch {
case literal != "" && env != "":
return fmt.Errorf("oauth2.%s and oauth2.%s_env are mutually exclusive", field, field)
case literal == "" && env == "":
return fmt.Errorf("oauth2 requires %s or %s_env", field, field)
}
return nil
}
// String redacts credentials when server entries are formatted as text.
func (s MCPRemoteServer) String() string {
auth := "none"
switch {
case s.OAuth2 != nil:
auth = "oauth2(" + s.OAuth2.TokenURL + ")"
case s.Token != "":
auth = "token([redacted])"
}
return fmt.Sprintf("{url: %s, auth: %s}", s.URL, auth)
}
// LogValue also redacts credentials in structured JSON logs, which bypass String.
func (s MCPRemoteServer) LogValue() slog.Value {
return slog.StringValue(s.String())
}
// @Description MCP STDIO server configuration
type MCPSTDIOServer struct {
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
Command string `json:"command,omitempty"`
}
// Pipeline stage names. They match the Pipeline yaml keys and are the stage
// identifiers the realtime endpoint routes by (failover chains per stage,
// model_failover events, preload roles), so every user shares one spelling.
const (
PipelineStageVAD = "vad"
PipelineStageTranscription = "transcription"
PipelineStageLLM = "llm"
PipelineStageTTS = "tts"
PipelineStageSoundDetection = "sound_detection"
PipelineStageVoiceRecognition = "voice_recognition"
)
// @Description Pipeline defines other models to use for audio-to-audio
type Pipeline struct {
TTS string `yaml:"tts,omitempty" json:"tts,omitempty"`
LLM string `yaml:"llm,omitempty" json:"llm,omitempty"`
Transcription string `yaml:"transcription,omitempty" json:"transcription,omitempty"`
VAD string `yaml:"vad,omitempty" json:"vad,omitempty"`
// SoundDetection names a sound-event-classification model (e.g. ced). When
// set, each VAD-committed realtime utterance is also run through it and the
// scored AudioSet tags are emitted as a conversation.item.sound_detection
// server event, alongside (and independent of) transcription.
SoundDetection string `yaml:"sound_detection,omitempty" json:"sound_detection,omitempty"`
// SoundDetectionWindowMs / SoundDetectionHopMs enable server-side windowing
// for a sound-detection-only realtime session: instead of the client
// committing audio buffers, the server classifies the last WindowMs of
// streamed audio every HopMs and emits a sound_detection event per hop. Both
// must be > 0 to activate; otherwise the session stays client-driven (the
// client commits windows via input_audio_buffer.commit).
SoundDetectionWindowMs int `yaml:"sound_detection_window_ms,omitempty" json:"sound_detection_window_ms,omitempty"`
SoundDetectionHopMs int `yaml:"sound_detection_hop_ms,omitempty" json:"sound_detection_hop_ms,omitempty"`
// Diarization asks the transcription model for speaker labels on each
// VAD-committed utterance and emits every labelled segment as a
// conversation.item.input_audio_transcription.segment event. It needs a
// transcription model that diarizes (e.g. parakeet-cpp with a
// diarization_model companion); off by default because some backends fail
// a diarize request they cannot serve. Speaker labels are per turn.
Diarization bool `yaml:"diarization,omitempty" json:"diarization,omitempty"`
// AnyToAny marks a realtime_audio model that serves the whole speech loop
// itself through AudioToAudioStream (for example LFM3-Audio on llama.cpp).
// true routes the session to that single stream; unset or false keeps the
// chained VAD, transcription, LLM and TTS pipeline the four slots describe.
AnyToAny *bool `yaml:"any_to_any,omitempty" json:"any_to_any,omitempty"`
// ReasoningEffort sets the reasoning effort (none|minimal|low|medium|high) for
// the pipeline's LLM without editing the LLM model config. Overrides the LLM's
// own reasoning_effort. Unset leaves the LLM model config in charge.
ReasoningEffort string `yaml:"reasoning_effort,omitempty" json:"reasoning_effort,omitempty"`
// Streaming opts each pipeline stage into incremental delivery (LLM tokens,
// TTS audio chunks, transcription text). Unset stages keep the blocking
// unary path, so existing configs are unaffected.
Streaming PipelineStreaming `yaml:"streaming,omitempty" json:"streaming,omitempty"`
// DisableThinking suppresses reasoning/thinking for the pipeline LLM (maps
// to enable_thinking=false backend metadata) without editing the underlying
// LLM model config. Unset leaves the LLM model config in charge.
DisableThinking *bool `yaml:"disable_thinking,omitempty" json:"disable_thinking,omitempty"`
// MaxHistoryItems caps how many trailing conversation items are fed to the
// LLM each realtime turn (0 = unlimited, rely on the LLM's context window).
// Unset (nil) uses the per-model-type default. Set it on a composed pipeline
// (VAD+STT+LLM+TTS) so a long-running session doesn't grow until the LLM's
// context fills.
MaxHistoryItems *int `yaml:"max_history_items,omitempty" json:"max_history_items,omitempty"`
// Compaction folds conversation items that age out of the live window
// (max_history_items) into a rolling summary instead of dropping them, so
// long realtime sessions stay cheap without losing earlier context. Nil
// (block absent) means disabled, preserving existing behavior.
Compaction *PipelineCompaction `yaml:"compaction,omitempty" json:"compaction,omitempty"`
// VoiceRecognition gates the pipeline behind speaker verification. Nil
// (block absent) means no gate, preserving existing behavior.
VoiceRecognition *PipelineVoiceRecognition `yaml:"voice_recognition,omitempty" json:"voice_recognition,omitempty"`
// TurnDetection sets the server-side default turn-detection mode for
// realtime sessions on this pipeline, so clients need no session.update
// to benefit. A client session.update still overrides type and eagerness
// per session; retranscribe is server-side only. Unset keeps server_vad.
TurnDetection PipelineTurnDetection `yaml:"turn_detection,omitempty" json:"turn_detection,omitempty"`
// Classifier switches realtime responses to prefill-only option
// selection (LocalAI classifier mode): each user turn is scored
// against a fixed option list via the Score primitive and the winning
// option's canned reply / tool call is emitted, so weak hardware
// never pays for autoregressive decode. Nil means disabled; clients
// can still enable per session via session.update localai_classifier.
// Validated (and rejected loudly) at realtime session setup, like the
// pipeline model slots.
Classifier *PipelineClassifier `yaml:"classifier,omitempty" json:"classifier,omitempty"`
// DisableWarmup turns off eager pre-loading of the pipeline's sub-models at
// realtime session start. By default (false) LocalAI loads every configured
// sub-model backend (VAD, transcription, LLM, TTS, sound detection, voice
// recognition) into memory (concurrently) before the
// session is announced and blocks until they are ready, so the first turn
// pays no cold-start cost and a model that fails to load surfaces as an error
// at session start rather than mid-call. Set true to restore the lazy "load
// on first use" behavior — session start no longer blocks on loading and
// load errors surface on first use instead (e.g. to keep idle sessions from
// holding model memory they may never use).
DisableWarmup bool `yaml:"disable_warmup,omitempty" json:"disable_warmup,omitempty"`
}
// PipelineClassifier is the YAML mirror of the realtime API's
// localai_classifier extension (see
// core/http/endpoints/openai/types/classifier.go, which documents the
// field semantics and owns validation — the realtime session converts and
// validates this block at setup).
type PipelineClassifier struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// Model optionally names a different config to score on. Empty uses
// the pipeline's llm — with slot-based Score the same process serves
// both scoring and generation and shares its prompt cache.
Model string `yaml:"model,omitempty" json:"model,omitempty"`
Threshold float64 `yaml:"threshold,omitempty" json:"threshold,omitempty"`
Normalization string `yaml:"normalization,omitempty" json:"normalization,omitempty"`
HistoryItems int `yaml:"history_items,omitempty" json:"history_items,omitempty"`
Fallback *PipelineClassifierFallback `yaml:"fallback,omitempty" json:"fallback,omitempty"`
Options []PipelineClassifierOption `yaml:"options,omitempty" json:"options,omitempty"`
// Address gates every turn on the assistant being addressed by one of
// these names (wake-word behavior); see types.ClassifierAddress.
Address *PipelineClassifierAddress `yaml:"address,omitempty" json:"address,omitempty"`
}
// PipelineClassifierAddress mirrors types.ClassifierAddress for YAML.
type PipelineClassifierAddress struct {
Names []string `yaml:"names,omitempty" json:"names,omitempty"`
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
Reply string `yaml:"reply,omitempty" json:"reply,omitempty"`
}
type PipelineClassifierOption struct {
ID string `yaml:"id" json:"id"`
Description string `yaml:"description" json:"description"`
Reply string `yaml:"reply,omitempty" json:"reply,omitempty"`
Tool *PipelineClassifierTool `yaml:"tool,omitempty" json:"tool,omitempty"`
}
type PipelineClassifierTool struct {
Name string `yaml:"name" json:"name"`
// Arguments is a plain YAML map; the realtime session marshals it to
// the JSON arguments string of the emitted function call. With Slots
// it is a template: "{{name}}" values are filled by a constrained
// completion when the option wins.
Arguments map[string]any `yaml:"arguments,omitempty" json:"arguments,omitempty"`
// Slots declares the inferred arguments; see types.ClassifierSlot.
Slots []PipelineClassifierSlot `yaml:"slots,omitempty" json:"slots,omitempty"`
}
type PipelineClassifierSlot struct {
Name string `yaml:"name" json:"name"`
Type string `yaml:"type" json:"type"` // number | enum | string
Values []string `yaml:"values,omitempty" json:"values,omitempty"`
Default string `yaml:"default,omitempty" json:"default,omitempty"`
Hint string `yaml:"hint,omitempty" json:"hint,omitempty"`
}
type PipelineClassifierFallback struct {
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
Reply string `yaml:"reply,omitempty" json:"reply,omitempty"`
}
// PipelineCompaction configures summarize-then-drop for a realtime pipeline.
type PipelineCompaction struct {
// Enabled turns summarize-then-drop on. Default false.
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// TriggerItems is the high-water mark: once live items exceed it, overflow
// above max_history_items is summarized and evicted. Must exceed
// max_history_items; clamped up if not. Default: 2x max_history_items.
TriggerItems int `yaml:"trigger_items,omitempty" json:"trigger_items,omitempty"`
// SummaryModel optionally names a smaller/cheaper model for the summary
// call. Empty uses the pipeline's own LLM.
SummaryModel string `yaml:"summary_model,omitempty" json:"summary_model,omitempty"`
// MaxSummaryTokens advises the summary length (fed to the prompt). Default 512.
MaxSummaryTokens int `yaml:"max_summary_tokens,omitempty" json:"max_summary_tokens,omitempty"`
}
// ApplyReasoningEffort resolves the effective reasoning effort — a per-request
// value (requestEffort) overrides the config's own ReasoningEffort default —
// stores it on the config so gRPCPredictOpts forwards it to the backend as the
// reasoning_effort chat_template_kwarg, and maps it onto the enable_thinking
// toggle the backend also reads:
// - "none" always disables thinking.
// - any explicit level enables it, UNLESS the config already disabled reasoning
// (an operator's explicit disable wins over a request asking to think).
//
// An empty requestEffort keeps the config's own default. With no effort set
// anywhere it is a no-op, leaving the model's reasoning settings untouched.
func (c *ModelConfig) ApplyReasoningEffort(requestEffort string) {
effort := requestEffort
if effort == "" {
effort = c.ReasoningEffort
}
c.ReasoningEffort = effort
switch strings.ToLower(effort) {
case "none":
disable := true
c.ReasoningConfig.DisableReasoning = &disable
case "minimal", "low", "medium", "high", "xhigh", "max":
if c.ReasoningConfig.DisableReasoning == nil || !*c.ReasoningConfig.DisableReasoning {
enable := false
c.ReasoningConfig.DisableReasoning = &enable
}
}
}
// coerceChatTemplateKwarg coerces a request-metadata string value for use as a
// jinja chat_template_kwarg. "true"/"false" become real booleans (so a jinja
// `{% if preserve_thinking %}` reads false correctly, since any non-empty string
// is truthy); everything else stays a string. Numeric/typed per-request values are
// out of scope - set those in the model YAML chat_template_kwargs (YAML keeps the type).
func coerceChatTemplateKwarg(v string) any {
switch v {
case "true":
return true
case "false":
return false
default:
return v
}
}
// ResolveChatTemplateKwargs builds the final chat_template_kwargs map forwarded to
// the backend, layered: the model config map (base) < the coerced backend metadata
// (server reasoning levers + client request overrides). `meta` is the already-merged
// backend metadata string map. The reserved "chat_template_kwargs" key is skipped so
// a client cannot smuggle a nested blob. Returns nil when there is nothing to forward.
func (c *ModelConfig) ResolveChatTemplateKwargs(meta map[string]string) map[string]any {
out := map[string]any{}
for k, v := range c.ChatTemplateKwargs {
out[k] = v
}
for k, v := range meta {
if k == "chat_template_kwargs" {
continue
}
out[k] = coerceChatTemplateKwarg(v)
}
if len(out) == 0 {
return nil
}
return out
}
// @Description PipelineStreaming toggles incremental delivery per realtime stage.
type PipelineStreaming struct {
LLM *bool `yaml:"llm,omitempty" json:"llm,omitempty"`
TTS *bool `yaml:"tts,omitempty" json:"tts,omitempty"`
Transcription *bool `yaml:"transcription,omitempty" json:"transcription,omitempty"`
// ClauseChunking splits the streamed LLM reply into speakable clauses and
// synthesizes each as soon as it completes, instead of buffering the whole
// message before TTS. Script-aware (CJK/Thai), so it does not rely on
// whitespace sentence boundaries. Requires explicit true; unset and false
// retain whole-reply synthesis for engines without native live text input.
ClauseChunking *bool `yaml:"clause_chunking,omitempty" json:"clause_chunking,omitempty"`
}
// IsAnyToAny reports whether the realtime session runs the whole speech loop
// on one model through AudioToAudioStream (pipeline.any_to_any: true).
func (p Pipeline) IsAnyToAny() bool { return p.AnyToAny != nil && *p.AnyToAny }
// StreamLLM reports whether LLM tokens should be streamed for this pipeline.
func (p Pipeline) StreamLLM() bool { return p.Streaming.LLM != nil && *p.Streaming.LLM }
// StreamTTS reports whether TTS audio should be streamed for this pipeline.
func (p Pipeline) StreamTTS() bool { return p.Streaming.TTS != nil && *p.Streaming.TTS }
// StreamTranscription reports whether transcription text should be streamed.
func (p Pipeline) StreamTranscription() bool {
return p.Streaming.Transcription != nil && *p.Streaming.Transcription
}
// ChunkClauses reports whether the streamed reply should be split into
// script-aware clauses and synthesized incrementally rather than buffered whole.
func (p Pipeline) ChunkClauses() bool {
return p.Streaming.ClauseChunking != nil && *p.Streaming.ClauseChunking
}
// ThinkingDisabled reports whether the pipeline forces the LLM's thinking off.
func (p Pipeline) ThinkingDisabled() bool {
return p.DisableThinking != nil && *p.DisableThinking
}
// Voice-recognition gate enum values.
const (
VoiceGateModeIdentify = "identify"
VoiceGateModeVerify = "verify"
VoiceGateWhenEvery = "every"
VoiceGateWhenFirst = "first"
VoiceGateRejectEvent = "drop_event"
VoiceGateRejectSilent = "drop_silent"
// defaultVoiceGateThreshold is the cosine-distance default tuned for the
// ECAPA-TDNN speaker encoder on VoxCeleb.
defaultVoiceGateThreshold = 0.25
)
// @Description PipelineVoiceRecognition gates a realtime pipeline behind speaker verification.
type PipelineVoiceRecognition struct {
// Model is the speaker-recognition backend model name.
Model string `yaml:"model,omitempty" json:"model,omitempty"`
// Mode is "identify" (1:N against the voice registry) or "verify"
// (1:few against reference audios).
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// Threshold is the maximum cosine distance that still counts as a match.
Threshold float32 `yaml:"threshold,omitempty" json:"threshold,omitempty"`
// When is "every" (verify each utterance) or "first" (verify once, then
// trust the session).
When string `yaml:"when,omitempty" json:"when,omitempty"`
// OnReject is "drop_event" (drop + emit an error event) or "drop_silent"
// (drop quietly).
OnReject string `yaml:"on_reject,omitempty" json:"on_reject,omitempty"`
// AntiSpoofing enables the backend liveness check (verify mode only).
AntiSpoofing bool `yaml:"anti_spoofing,omitempty" json:"anti_spoofing,omitempty"`
// Allow filters which registry identities are authorized (identify mode).
Allow VoiceRecognitionAllow `yaml:"allow,omitempty" json:"allow,omitempty"`
// References are the authorized reference speakers (verify mode).
References []VoiceReference `yaml:"references,omitempty" json:"references,omitempty"`
// Enforce controls the authorization gate. A nil value or true rejects
// unauthorized speakers (the historical behavior). false resolves the
// speaker's identity for surfacing/personalization but never drops a turn.
Enforce *bool `yaml:"enforce,omitempty" json:"enforce,omitempty"`
// Identity surfaces the recognized speaker to the client and the LLM. It is
// independent of Enforce: identity can be surfaced without gating.
Identity *VoiceIdentityConfig `yaml:"identity,omitempty" json:"identity,omitempty"`
}
// @Description VoiceRecognitionAllow filters authorized registry identities.
type VoiceRecognitionAllow struct {
// Names matches registered Metadata.Name exactly.
Names []string `yaml:"names,omitempty" json:"names,omitempty"`
// Labels authorizes any identity carrying a matching label key.
Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"`
}
// @Description VoiceReference is one authorized reference speaker for verify mode.
type VoiceReference struct {
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Audio string `yaml:"audio,omitempty" json:"audio,omitempty"`
}
// @Description VoiceIdentityConfig surfaces the recognized speaker to the realtime
// client and the LLM. When set, identity is resolved on every turn even if the
// gate's When is "first" (the gate still authorizes only once).
type VoiceIdentityConfig struct {
// Announce emits a conversation.item.speaker event to the client.
Announce bool `yaml:"announce,omitempty" json:"announce,omitempty"`
// AnnounceUnknown also emits the event when there is no confident match.
AnnounceUnknown bool `yaml:"announce_unknown,omitempty" json:"announce_unknown,omitempty"`
// Personalize informs the LLM who is speaking.
Personalize bool `yaml:"personalize,omitempty" json:"personalize,omitempty"`
// InjectName sets the per-message name field on each user turn.
InjectName bool `yaml:"inject_name,omitempty" json:"inject_name,omitempty"`
// InjectSystemNote maintains a "current speaker" note in the system message.
InjectSystemNote bool `yaml:"inject_system_note,omitempty" json:"inject_system_note,omitempty"`
// NoteUnknown adds a "the current speaker is unknown" note (enables the model
// to ask who it is talking to).
NoteUnknown bool `yaml:"note_unknown,omitempty" json:"note_unknown,omitempty"`
}
// VoiceGateEnabled reports whether a voice-recognition gate is configured. The
// mere presence of the block is the intent signal: a present-but-incomplete
// block (e.g. missing model) must fail closed at construction, not be silently
// skipped here.
func (p Pipeline) VoiceGateEnabled() bool {
return p.VoiceRecognition != nil
}
// EnforceGate reports whether the gate rejects unauthorized speakers. A nil
// Enforce means "enforce" so existing configs keep gating.
func (p PipelineVoiceRecognition) EnforceGate() bool {
return p.Enforce == nil || *p.Enforce
}
// IdentityEnabled reports whether the speaker's identity must be resolved for
// surfacing or personalization.
func (p PipelineVoiceRecognition) IdentityEnabled() bool {
return p.Identity != nil && (p.Identity.Announce || p.Identity.Personalize)
}
// AnnounceEnabled reports whether to emit the conversation.item.speaker event.
func (p PipelineVoiceRecognition) AnnounceEnabled() bool {
return p.Identity != nil && p.Identity.Announce
}
// PersonalizeEnabled reports whether to inform the LLM of the speaker.
func (p PipelineVoiceRecognition) PersonalizeEnabled() bool {
return p.Identity != nil && p.Identity.Personalize
}
// Normalize fills in defaults in place for omitted fields.
func (v *PipelineVoiceRecognition) Normalize() {
if v.Mode == "" {
v.Mode = VoiceGateModeIdentify
}
if v.When == "" {
v.When = VoiceGateWhenEvery
}
if v.OnReject == "" {
v.OnReject = VoiceGateRejectEvent
}
if v.Threshold == 0 {
v.Threshold = defaultVoiceGateThreshold
}
}
// Validate checks shape and enum values. registryAvailable indicates whether a
// VoiceRegistry exists (required by identify mode). Empty When/OnReject/Mode are
// treated as valid because Normalize defaults them.
func (v PipelineVoiceRecognition) Validate(registryAvailable bool) error {
if v.Model == "" {
return fmt.Errorf("voice_recognition: model is required")
}
switch v.Mode {
case "", VoiceGateModeIdentify:
if !registryAvailable {
return fmt.Errorf("voice_recognition mode 'identify' requires a voice registry")
}
case VoiceGateModeVerify:
if len(v.References) == 0 {
return fmt.Errorf("voice_recognition mode 'verify' requires at least one reference")
}
for i, r := range v.References {
if r.Audio == "" {
return fmt.Errorf("voice_recognition reference %d (%q) is missing an audio path", i, r.Name)
}
}
default:
return fmt.Errorf("voice_recognition: unknown mode %q", v.Mode)
}
switch v.When {
case "", VoiceGateWhenEvery, VoiceGateWhenFirst:
default:
return fmt.Errorf("voice_recognition: unknown when %q", v.When)
}
switch v.OnReject {
case "", VoiceGateRejectEvent, VoiceGateRejectSilent:
default:
return fmt.Errorf("voice_recognition: unknown on_reject %q", v.OnReject)
}
// A zero threshold means "unset" (Normalize defaults it); only validate an
// explicitly-set value. Cosine distance ranges 0..2.
if v.Threshold != 0 && (v.Threshold < 0 || v.Threshold > 2) {
return fmt.Errorf("voice_recognition: threshold %v out of range (0..2)", v.Threshold)
}
return nil
}
// @Description PipelineTurnDetection sets realtime turn-detection defaults.
type PipelineTurnDetection struct {
// Type selects the default turn_detection mode for sessions on this
// pipeline: "server_vad" (silence-based) or "semantic_vad" (the
// transcription model's end-of-utterance token drives a dynamic silence
// window; needs a streaming-EOU transcription model such as
// parakeet_realtime_eou_120m-v1, degrades to silence-only otherwise).
Type string `yaml:"type,omitempty" json:"type,omitempty"`
// Eagerness is the semantic_vad fallback when no end-of-utterance token
// was seen: low waits 8s of silence, medium/auto 4s, high 2s.
Eagerness string `yaml:"eagerness,omitempty" json:"eagerness,omitempty"`
// Retranscribe (semantic_vad only) cross-checks every EOU-triggered
// commit with an offline decode of the buffered turn: the commit only
// proceeds when the batch decode also ends in the end-of-utterance token,
// and its transcript is the one used. The streamed and batch transcripts
// are compared in the logs — a diagnostic for streaming/batch alignment
// at the cost of one extra decode per turn.
Retranscribe *bool `yaml:"retranscribe,omitempty" json:"retranscribe,omitempty"`
// VadWindowSec widens the slice of recent audio the VAD rescans each
// tick. The pipeline sizes it automatically from the commit silence
// threshold (server_vad silence window, or the semantic eagerness
// fallback) plus a warm-up margin; set this only to widen it further —
// values below the automatic floor are ignored.
VadWindowSec float64 `yaml:"vad_window_sec,omitempty" json:"vad_window_sec,omitempty"`
}
// TurnDetectionSemantic reports whether this pipeline defaults sessions to
// semantic (EOU-driven) turn detection.
func (p Pipeline) TurnDetectionSemantic() bool {
return strings.EqualFold(strings.TrimSpace(p.TurnDetection.Type), "semantic_vad")
}
// TurnDetectionRetranscribe reports whether semantic_vad commits should be
// cross-checked (and transcribed) by an offline decode of the buffered turn.
func (p Pipeline) TurnDetectionRetranscribe() bool {
return p.TurnDetection.Retranscribe != nil && *p.TurnDetection.Retranscribe
}
// @Description File configuration for model downloads
type File struct {
Filename string `yaml:"filename,omitempty" json:"filename,omitempty"`
SHA256 string `yaml:"sha256,omitempty" json:"sha256,omitempty"`
URI downloader.URI `yaml:"uri,omitempty" json:"uri,omitempty"`
}
type FeatureFlag map[string]*bool
func (ff FeatureFlag) Enabled(s string) bool {
if v, exists := ff[s]; exists && v != nil {
return *v
}
return false
}
// @Description GRPC configuration
type GRPC struct {
Attempts int `yaml:"attempts,omitempty" json:"attempts,omitempty"`
AttemptsSleepTime int `yaml:"attempts_sleep_time,omitempty" json:"attempts_sleep_time,omitempty"`
}
// @Description Diffusers configuration
type Diffusers struct {
CUDA bool `yaml:"cuda,omitempty" json:"cuda,omitempty"`
PipelineType string `yaml:"pipeline_type,omitempty" json:"pipeline_type,omitempty"`
SchedulerType string `yaml:"scheduler_type,omitempty" json:"scheduler_type,omitempty"`
OriginalConfigFile string `yaml:"original_config_file,omitempty" json:"original_config_file,omitempty"`
EnableParameters string `yaml:"enable_parameters,omitempty" json:"enable_parameters,omitempty"` // A list of comma separated parameters to specify
IMG2IMG bool `yaml:"img2img,omitempty" json:"img2img,omitempty"` // Image to Image Diffuser
ClipSkip int `yaml:"clip_skip,omitempty" json:"clip_skip,omitempty"` // Skip every N frames
ClipModel string `yaml:"clip_model,omitempty" json:"clip_model,omitempty"` // Clip model to use
ClipSubFolder string `yaml:"clip_subfolder,omitempty" json:"clip_subfolder,omitempty"` // Subfolder to use for clip model
ControlNet string `yaml:"control_net,omitempty" json:"control_net,omitempty"`
}
// @Description LLMConfig is a struct that holds the configuration that are generic for most of the LLM backends.
type LLMConfig struct {
SystemPrompt string `yaml:"system_prompt,omitempty" json:"system_prompt,omitempty"`
TensorSplit string `yaml:"tensor_split,omitempty" json:"tensor_split,omitempty"`
MainGPU string `yaml:"main_gpu,omitempty" json:"main_gpu,omitempty"`
RMSNormEps float32 `yaml:"rms_norm_eps,omitempty" json:"rms_norm_eps,omitempty"`
NGQA int32 `yaml:"ngqa,omitempty" json:"ngqa,omitempty"`
PromptCachePath string `yaml:"prompt_cache_path,omitempty" json:"prompt_cache_path,omitempty"`
PromptCacheAll *bool `yaml:"prompt_cache_all,omitempty" json:"prompt_cache_all,omitempty"`
PromptCacheRO bool `yaml:"prompt_cache_ro,omitempty" json:"prompt_cache_ro,omitempty"`
MirostatETA *float64 `yaml:"mirostat_eta,omitempty" json:"mirostat_eta,omitempty"`
MirostatTAU *float64 `yaml:"mirostat_tau,omitempty" json:"mirostat_tau,omitempty"`
Mirostat *int `yaml:"mirostat,omitempty" json:"mirostat,omitempty"`
NGPULayers *int `yaml:"gpu_layers,omitempty" json:"gpu_layers,omitempty"`
MMap *bool `yaml:"mmap,omitempty" json:"mmap,omitempty"`
MMlock *bool `yaml:"mmlock,omitempty" json:"mmlock,omitempty"`
LowVRAM *bool `yaml:"low_vram,omitempty" json:"low_vram,omitempty"`
Reranking *bool `yaml:"reranking,omitempty" json:"reranking,omitempty"`
Grammar string `yaml:"grammar,omitempty" json:"grammar,omitempty"`
StopWords []string `yaml:"stopwords,omitempty" json:"stopwords,omitempty"`
Cutstrings []string `yaml:"cutstrings,omitempty" json:"cutstrings,omitempty"`
ExtractRegex []string `yaml:"extract_regex,omitempty" json:"extract_regex,omitempty"`
TrimSpace []string `yaml:"trimspace,omitempty" json:"trimspace,omitempty"`
TrimSuffix []string `yaml:"trimsuffix,omitempty" json:"trimsuffix,omitempty"`
ContextSize *int `yaml:"context_size,omitempty" json:"context_size,omitempty"`
NUMA bool `yaml:"numa,omitempty" json:"numa,omitempty"`
LoraAdapter string `yaml:"lora_adapter,omitempty" json:"lora_adapter,omitempty"`
LoraBase string `yaml:"lora_base,omitempty" json:"lora_base,omitempty"`
LoraAdapters []string `yaml:"lora_adapters,omitempty" json:"lora_adapters,omitempty"`
LoraScales []float32 `yaml:"lora_scales,omitempty" json:"lora_scales,omitempty"`
LoraScale float32 `yaml:"lora_scale,omitempty" json:"lora_scale,omitempty"`
NoMulMatQ bool `yaml:"no_mulmatq,omitempty" json:"no_mulmatq,omitempty"`
DraftModel string `yaml:"draft_model,omitempty" json:"draft_model,omitempty"`
NDraft int32 `yaml:"n_draft,omitempty" json:"n_draft,omitempty"`
Quantization string `yaml:"quantization,omitempty" json:"quantization,omitempty"`
LoadFormat string `yaml:"load_format,omitempty" json:"load_format,omitempty"`
GPUMemoryUtilization float32 `yaml:"gpu_memory_utilization,omitempty" json:"gpu_memory_utilization,omitempty"` // vLLM
TrustRemoteCode bool `yaml:"trust_remote_code,omitempty" json:"trust_remote_code,omitempty"` // vLLM
EnforceEager bool `yaml:"enforce_eager,omitempty" json:"enforce_eager,omitempty"` // vLLM
SwapSpace int `yaml:"swap_space,omitempty" json:"swap_space,omitempty"` // vLLM
MaxModelLen int `yaml:"max_model_len,omitempty" json:"max_model_len,omitempty"` // vLLM
TensorParallelSize int `yaml:"tensor_parallel_size,omitempty" json:"tensor_parallel_size,omitempty"` // vLLM
DisableLogStatus bool `yaml:"disable_log_stats,omitempty" json:"disable_log_stats,omitempty"` // vLLM
DType string `yaml:"dtype,omitempty" json:"dtype,omitempty"` // vLLM
LimitMMPerPrompt LimitMMPerPrompt `yaml:"limit_mm_per_prompt,omitempty" json:"limit_mm_per_prompt,omitempty"` // vLLM
// EngineArgs is a backend-native passthrough applied to the engine constructor
// (e.g. vLLM AsyncEngineArgs). Values may be primitives or nested maps; nested
// maps materialise into the backend's nested config dataclasses (e.g.
// SpeculativeConfig, KVTransferConfig, CompilationConfig). Unknown keys cause
// the backend to fail LoadModel with a list of valid names.
EngineArgs map[string]any `yaml:"engine_args,omitempty" json:"engine_args,omitempty"`
MMProj string `yaml:"mmproj,omitempty" json:"mmproj,omitempty"`
FlashAttention *string `yaml:"flash_attention,omitempty" json:"flash_attention,omitempty"`
NoKVOffloading bool `yaml:"no_kv_offloading,omitempty" json:"no_kv_offloading,omitempty"`
CacheTypeK string `yaml:"cache_type_k,omitempty" json:"cache_type_k,omitempty"`
CacheTypeV string `yaml:"cache_type_v,omitempty" json:"cache_type_v,omitempty"`
RopeScaling string `yaml:"rope_scaling,omitempty" json:"rope_scaling,omitempty"`
ModelType string `yaml:"type,omitempty" json:"type,omitempty"`
YarnExtFactor float32 `yaml:"yarn_ext_factor,omitempty" json:"yarn_ext_factor,omitempty"`
YarnAttnFactor float32 `yaml:"yarn_attn_factor,omitempty" json:"yarn_attn_factor,omitempty"`
YarnBetaFast float32 `yaml:"yarn_beta_fast,omitempty" json:"yarn_beta_fast,omitempty"`
YarnBetaSlow float32 `yaml:"yarn_beta_slow,omitempty" json:"yarn_beta_slow,omitempty"`
CFGScale float32 `yaml:"cfg_scale,omitempty" json:"cfg_scale,omitempty"` // Classifier-Free Guidance Scale
}
// @Description LimitMMPerPrompt is a struct that holds the configuration for the limit-mm-per-prompt config in vLLM
type LimitMMPerPrompt struct {
LimitImagePerPrompt int `yaml:"image,omitempty" json:"image,omitempty"`
LimitVideoPerPrompt int `yaml:"video,omitempty" json:"video,omitempty"`
LimitAudioPerPrompt int `yaml:"audio,omitempty" json:"audio,omitempty"`
}
// @Description TemplateConfig is a struct that holds the configuration of the templating system
type TemplateConfig struct {
// Chat is the template used in the chat completion endpoint
Chat string `yaml:"chat,omitempty" json:"chat,omitempty"`
// ChatMessage is the template used for chat messages
ChatMessage string `yaml:"chat_message,omitempty" json:"chat_message,omitempty"`
// Completion is the template used for completion requests
Completion string `yaml:"completion,omitempty" json:"completion,omitempty"`
// Edit is the template used for edit completion requests
Edit string `yaml:"edit,omitempty" json:"edit,omitempty"`
// Functions is the template used when tools are present in the client requests
Functions string `yaml:"function,omitempty" json:"function,omitempty"`
// UseTokenizerTemplate is a flag that indicates if the tokenizer template should be used.
// Note: this is mostly consumed for backends such as vllm and transformers
// that can use the tokenizers specified in the JSON config files of the models
UseTokenizerTemplate bool `yaml:"use_tokenizer_template,omitempty" json:"use_tokenizer_template,omitempty"`
// SystemMessagesAfterFirst controls what happens to system-role messages that
// appear after the leading system block. Some tokenizer chat templates (e.g.
// Qwen3.8 / Flash-Next) raise "System message must be at the beginning" for
// them, while agent frameworks (cogito tool selection, adjustment prompts)
// legitimately append system instructions mid-conversation.
// ""/"error": pass through unchanged (template decides)
// "merge": fold them into the leading system message
// "user": forward them as user-role instructions (keeps their position)
SystemMessagesAfterFirst string `yaml:"system_messages_after_first,omitempty" json:"system_messages_after_first,omitempty"`
// JoinChatMessagesByCharacter is a string that will be used to join chat messages together.
// It defaults to \n
JoinChatMessagesByCharacter *string `yaml:"join_chat_messages_by_character,omitempty" json:"join_chat_messages_by_character,omitempty"`
Multimodal string `yaml:"multimodal,omitempty" json:"multimodal,omitempty"`
ReplyPrefix string `yaml:"reply_prefix,omitempty" json:"reply_prefix,omitempty"`
}
func (c *ModelConfig) syncKnownUsecasesFromString() {
c.KnownUsecases = GetUsecasesFromYAML(c.KnownUsecaseStrings)
// Make sure the usecases are valid, we rewrite with what we identified
c.KnownUsecaseStrings = []string{}
for k, usecase := range GetAllModelConfigUsecases() {
if c.HasUsecases(usecase) {
c.KnownUsecaseStrings = append(c.KnownUsecaseStrings, k)
}
}
// GetAllModelConfigUsecases returns a map, and ranging one yields a random
// order per call. KnownUsecaseStrings is part of the serialized config, so
// an unsorted list gives the same file a different config revision on every
// load. In distributed mode that reads as a config change and the router
// rejects the request with ErrStaleModelConfigRevision.
slices.Sort(c.KnownUsecaseStrings)
}
func (c *ModelConfig) UnmarshalYAML(value *yaml.Node) error {
type BCAlias ModelConfig
var aux BCAlias
if err := value.Decode(&aux); err != nil {
return err
}
mc := ModelConfig(aux)
*c = mc
c.syncKnownUsecasesFromString()
return nil
}
func (c *ModelConfig) SetFunctionCallString(s string) {
c.functionCallString = s
}
func (c *ModelConfig) SetFunctionCallNameString(s string) {
c.functionCallNameString = s
}
func (c *ModelConfig) ShouldUseFunctions() bool {
return ((c.functionCallString != "none" || c.functionCallString == "") || c.ShouldCallSpecificFunction())
}
func (c *ModelConfig) ShouldCallSpecificFunction() bool {
return len(c.functionCallNameString) > 0
}
// MMProjFileName returns the filename of the MMProj file
// If the MMProj is a URL, it will return the MD5 of the URL which is the filename
func (c *ModelConfig) MMProjFileName() string {
uri := downloader.URI(c.MMProj)
if uri.LooksLikeURL() {
f, _ := uri.FilenameFromUrl()
return f
}
return c.MMProj
}
func (c *ModelConfig) IsMMProjURL() bool {
uri := downloader.URI(c.MMProj)
return uri.LooksLikeURL()
}
func (c *ModelConfig) IsModelURL() bool {
uri := downloader.URI(c.Model)
return uri.LooksLikeURL()
}
// ModelID returns the identifier used to reference this model across the
// system: the configured Name, falling back to Model when Name is empty.
// This is the single source of truth for the id fed to model.WithModelID and
// the prefix-cache chain salt; both MUST agree with the router's tracking key
// or the prefix-cache salt diverges silently.
func (c ModelConfig) ModelID() string {
if c.Name != "" {
return c.Name
}
return c.Model
}
// ModelFileName returns the controller-managed snapshot when the model has a
// committed artifact, otherwise preserving the legacy URL/repository behavior.
func (c *ModelConfig) ModelFileName() string {
if len(c.Artifacts) > 0 && c.Artifacts[0].Resolved != nil {
relative, err := modelartifacts.RelativeSnapshotPath(c.Artifacts[0].Resolved.CacheKey)
if err == nil {
// Single-file snapshots (e.g. a GGUF) must resolve to the file inside
// the snapshot directory; single-file backends load a file, not a dir.
// Multi-file snapshots keep pointing at the directory.
if primary := c.Artifacts[0].Resolved.PrimaryFile; primary != "" {
return filepath.Join(relative, filepath.FromSlash(primary))
}
return relative
}
}
uri := downloader.URI(c.Model)
if uri.LooksLikeURL() {
f, _ := uri.FilenameFromUrl()
return f
}
return c.Model
}
func (c *ModelConfig) FunctionToCall() string {
if c.functionCallNameString != "" &&
c.functionCallNameString != "none" && c.functionCallNameString != "auto" {
return c.functionCallNameString
}
return c.functionCallString
}
func (cfg *ModelConfig) SetDefaults(opts ...ConfigLoaderOption) {
lo := &LoadOptions{}
lo.Apply(opts...)
ctx := lo.ctxSize
threads := lo.threads
f16 := lo.f16
debug := lo.debug
// Cloud-proxy: normalise empty Mode so downstream consumers
// switch on two concrete values only. Validate accepts empty too,
// but SetDefaults is the chokepoint that runs before any
// inference path reads cfg.Proxy.Mode.
if cfg.Proxy.Mode == "" {
cfg.Proxy.Mode = ProxyModePassthrough
}
// When templating is delegated to the backend (use_tokenizer_template),
// the backend also owns tool-call grammar generation and parsing. Sending
// a LocalAI-generated grammar alongside overrides the backend's native
// (name-first) tool pipeline and makes it stream the tool-call JSON back as
// plain content (issue #10052). The GGUF auto-import path already couples
// these two flags; enforce it here so gallery and hand-written configs that
// set use_tokenizer_template directly stay consistent.
if cfg.TemplateConfig.UseTokenizerTemplate {
cfg.FunctionsConfig.GrammarConfig.NoGrammar = true
}
// Apply model-family-specific inference defaults before generic fallbacks.
// This ensures gallery-installed and runtime-loaded models get optimal parameters.
ApplyInferenceDefaults(cfg, cfg.Name, cfg.Model)
// Apply serving-policy defaults (device-independent): cross-request prefix
// caching. Propagates to distributed nodes via the model options.
ApplyServingDefaults(cfg)
// Generic fallback defaults (sampling params + runtime flags), applied after
// the model-family / hardware / serving tiers above. Only fills unset values.
ApplyGenericDefaults(cfg)
trueV := true
falseV := false
if threads == 0 {
// Threads can't be 0
threads = 4
}
if cfg.Threads == nil {
cfg.Threads = &threads
}
if cfg.F16 == nil {
cfg.F16 = &f16
}
if cfg.Debug == nil {
cfg.Debug = &falseV
}
if debug {
cfg.Debug = &trueV
}
// If a context size was provided via LoadOptions, apply it before hooks so they
// don't override it with their own defaults.
if ctx != 0 && cfg.ContextSize == nil {
cfg.ContextSize = &ctx
}
runBackendHooks(cfg, lo.modelPath)
// llama.cpp selects raw-vs-final embedding layout at model load. Other
// backends may expose per-token embeddings through their own contracts, so
// do not inject a llama.cpp-specific option into them.
if IsGoSidePooling(cfg.Pooling) && IsLlamaCppBackend(cfg.Backend) && !hasLlamaPoolingOption(cfg.Options) {
cfg.Options = append(cfg.Options, "pooling:none")
xlog.Debug("Go-side pooling requested: auto-appending backend option",
"model", cfg.Name, "pooling", cfg.Pooling, "option", "pooling:none")
}
// Apply hardware-driven defaults (e.g. a larger physical batch on Blackwell)
// LAST, after the context size is fully resolved (explicit config, LoadOptions,
// then the GGUF guess inside runBackendHooks): the Blackwell batch guard sizes
// the per-device compute buffer against this model's context, so it must see
// the final value, not a pre-guess nil. Uses the local GPU here; in distributed
// mode the router re-applies the same heuristics for the selected node's GPU
// before loading. Explicit config always wins.
ApplyHardwareDefaults(cfg, localGPU())
cfg.syncKnownUsecasesFromString()
}
func (c *ModelConfig) Validate() (bool, error) {
for _, operation := range c.ThreeDOperations() {
for _, parameter := range operation.Parameters {
if parameter.Default != "" {
if err := parameter.Validate(parameter.Default); err != nil {
return false, fmt.Errorf("%s default: %w", operation.ID, err)
}
}
}
}
if c.Compression.Enabled {
if c.IsCloudProxyBackendPassthrough() {
return false, fmt.Errorf("compression: cloud-proxy passthrough is unsupported; configure proxy mode translate")
}
if c.Compression.TriggerAtRatio < 0 || c.Compression.TriggerAtRatio > 1 {
return false, fmt.Errorf("compression: trigger_at_ratio must be between 0 and 1")
}
if c.Compression.KeepTailTokens < 0 || c.Compression.MaxSummaryTokens < 0 {
return false, fmt.Errorf("compression: token limits cannot be negative")
}
switch c.Compression.OnPostCompressionOverflow {
case "", "error", "drop_oldest_summary":
default:
return false, fmt.Errorf("compression: unknown on_post_compression_overflow %q", c.Compression.OnPostCompressionOverflow)
}
}
if c.IsAlias() && len(c.Artifacts) > 0 {
return false, fmt.Errorf("alias model %q cannot declare artifacts", c.Name)
}
seenArtifacts := make(map[string]struct{}, len(c.Artifacts))
primaries := 0
for i, artifact := range c.Artifacts {
normalized, err := artifact.Normalize()
if err != nil {
return false, fmt.Errorf("artifact %d: %w", i, err)
}
if _, exists := seenArtifacts[normalized.Name]; exists {
return false, fmt.Errorf("duplicate artifact name %q", normalized.Name)
}
seenArtifacts[normalized.Name] = struct{}{}
if normalized.Target == modelartifacts.TargetModel {
primaries++
// Artifacts[0] is the load target for every consumer (ModelFileName,
// size estimation, staging), so the primary has to occupy that slot
// rather than merely exist somewhere in the list.
if i != 0 {
return false, fmt.Errorf("the primary artifact must be declared first, found it at position %d", i)
}
}
}
if len(c.Artifacts) > 0 && primaries != 1 {
return false, fmt.Errorf("a config with artifacts must declare exactly one %q target, found %d", modelartifacts.TargetModel, primaries)
}
if c.IsFailover() {
if err := c.validateFailover(); err != nil {
return false, err
}
return true, nil
}
// An alias is a pure redirect: validate only its own shape here. Target
// existence and the no-chain rule need the full config set, so the loader
// (load-time) and the create/swap endpoints enforce those.
if c.IsAlias() {
if c.Name == "" {
return false, fmt.Errorf("alias config requires a name")
}
if c.Alias == c.Name {
return false, fmt.Errorf("alias %q cannot point to itself", c.Name)
}
if c.Backend != "" || c.Model != "" {
return false, fmt.Errorf("alias config %q must not set backend or parameters.model: an alias is a pure redirect", c.Name)
}
if err := c.validateProfileFields(); err != nil {
return false, err
}
return true, nil
}
if err := c.validateProfileFields(); err != nil {
return false, err
}
downloadedFileNames := []string{}
for _, f := range c.DownloadFiles {
downloadedFileNames = append(downloadedFileNames, f.Filename)
}
validationTargets := []string{c.Backend, c.Model, c.MMProj}
validationTargets = append(validationTargets, downloadedFileNames...)
// Simple validation to make sure the model can be correctly loaded
for _, n := range validationTargets {
if n == "" {
continue
}
if strings.HasPrefix(n, string(os.PathSeparator)) ||
strings.Contains(n, "..") {
return false, fmt.Errorf("invalid file path: %s", n)
}
}
if c.Backend != "" {
// a regex that checks that is a string name with no special characters, except '-' and '_'
re := regexp.MustCompile(`^[a-zA-Z0-9-_]+$`)
if !re.MatchString(c.Backend) {
return false, fmt.Errorf("invalid backend name: %s", c.Backend)
}
}
// Validate MCP configuration if present
if c.MCP.Servers != "" || c.MCP.Stdio != "" {
if _, _, err := c.MCP.MCPConfigFromYAML(); err != nil {
return false, fmt.Errorf("invalid MCP configuration: %w", err)
}
}
// engine_args crosses the gRPC boundary as a JSON-encoded string. Reject
// unmarshalable values here so a config that would silently lose user-set
// options at load time is rejected at parse time instead.
if len(c.EngineArgs) > 0 {
if _, err := json.Marshal(c.EngineArgs); err != nil {
return false, fmt.Errorf("engine_args is not JSON-serialisable: %w", err)
}
}
if err := c.Proxy.validateCredentialSources(); err != nil {
return false, err
}
switch c.Proxy.Mode {
case "", ProxyModePassthrough, ProxyModeTranslate:
// Empty is accepted at validate-time and normalised to
// passthrough by SetDefaults so it never reaches runtime.
default:
return false, fmt.Errorf("proxy: unknown mode %q (expected %s or %s)",
c.Proxy.Mode, ProxyModePassthrough, ProxyModeTranslate)
}
if c.Proxy.Mode == ProxyModeTranslate && c.Proxy.Provider == "" {
return false, fmt.Errorf("proxy: translate mode requires provider (%s, %s)",
ProxyProviderOpenAI, ProxyProviderAnthropic)
}
// Score on llama-cpp runs through the slot loop (SERVER_TASK_TYPE_SCORE,
// see backend/cpp/llama-cpp/patches/), so it is safe to combine with
// chat/completion/embeddings on one config — no conflict check needed.
// Pattern detector: validate built-in names and that each operator-defined
// pattern is a well-formed, anchored, bounded restricted-regex. Reject at
// load so a bad pattern surfaces as a clear config error rather than a
// silent no-op (or a fail-closed block) at request time.
if c.IsPatternDetector() {
for _, name := range c.PIIDetection.Builtins {
if _, ok := piipattern.LookupBuiltin(name); !ok {
return false, fmt.Errorf("pii_detection: unknown built-in pattern %q", name)
}
}
for _, p := range c.PIIDetection.Patterns {
if p.Name == "" {
return false, fmt.Errorf("pii_detection: pattern is missing a name")
}
if err := piipattern.ValidatePattern(p.Match); err != nil {
return false, fmt.Errorf("pii_detection: pattern %q: %w", p.Name, err)
}
}
}
// router.score_normalization is consumed lazily by the score
// classifier at first-request time; without load-time validation
// a typo wouldn't surface until the first router request panicked
// inside NewScoreClassifier. Reject unknown values here so the
// operator sees the offending key at startup.
switch c.Router.ScoreNormalization {
case "", ScoreNormalizationRaw, ScoreNormalizationMean:
// ok
default:
return false, fmt.Errorf("router: unknown score_normalization %q (expected %q or %q)",
c.Router.ScoreNormalization, ScoreNormalizationRaw, ScoreNormalizationMean)
}
if c.Router.Classifier == "decisions" {
t := c.Router.ActivationThreshold
if math.IsNaN(t) || math.IsInf(t, 0) || t < 0 || t > 1 {
return false, fmt.Errorf("router.decisions activation_threshold must be finite and in [0,1]")
}
if c.Router.ClassifierModel == "" {
return false, fmt.Errorf("router.decisions requires classifier_model")
}
if len(c.Router.Policies) == 0 || len(c.Router.Policies) > 64 {
return false, fmt.Errorf("router.decisions requires 1 to 64 policies")
}
seen := map[string]bool{}
for _, p := range c.Router.Policies {
if strings.TrimSpace(p.Label) == "" || strings.TrimSpace(p.Description) == "" || seen[p.Label] {
return false, fmt.Errorf("router.decisions requires unique nonblank labels and descriptions")
}
seen[p.Label] = true
}
if c.Router.KNN != nil || c.Router.EmbeddingCache != nil {
return false, fmt.Errorf("router.decisions does not support knn or embedding_cache composition")
}
}
if c.Router.KNN != nil {
if err := c.Router.KNN.Validate(); err != nil {
return false, err
}
}
// router.classifier_system_template parses as Go text/template
// (Sprig funcs available at execution time). Reject malformed
// templates at load time so the operator sees the parse error
// at startup rather than as a 500 on the first router request.
if c.Router.ClassifierSystemTemplate != "" {
if _, err := template.New("classifier_system").Parse(c.Router.ClassifierSystemTemplate); err != nil {
return false, fmt.Errorf("router: classifier_system_template parse error: %w", err)
}
}
// Go-side embedding pooling: reject unknown schemes and half-life
// misconfigurations at load time. llama.cpp chooses its result layout at
// model load, so its configured layout must agree with the model-level
// pooling scheme in both directions. Other backends declare the actual
// result layout on EmbeddingResult and are checked at request time.
if err := ValidatePooling(c.Pooling, c.PoolingHalfLifeTokens); err != nil {
return false, fmt.Errorf("parameters: %w", err)
}
if IsLlamaCppBackend(c.Backend) {
backendPooling, configured := llamaPoolingOption(c.Options)
raw := configured && backendPooling == "none"
if IsGoSidePooling(c.Pooling) && configured && !raw {
return false, fmt.Errorf(
"parameters.pooling %q needs raw per-token vectors but llama.cpp pooling %q returns a final vector: drop the option or set \"pooling:none\"",
c.Pooling, backendPooling)
}
if !IsGoSidePooling(c.Pooling) && raw {
return false, fmt.Errorf(
"parameters.pooling %q cannot pass through llama.cpp per-token vectors from \"pooling:none\": choose %q, %q or %q, or enable backend pooling",
c.Pooling, PoolingMean, PoolingLast, PoolingDecayedMean)
}
}
return true, nil
}
// Score normalisation modes mirror router.ScoreNormalization* —
// duplicated as constants on the config package so ModelConfig.Validate
// can reject unknown values without taking a dependency on the router
// package (which already depends on config).
const (
ScoreNormalizationRaw = "raw"
ScoreNormalizationMean = "mean"
)
// Embedding pooling schemes (parameters.pooling). Defined here so
// ModelConfig.Validate can reject unknown values without depending on
// core/backend (which already depends on config); core/backend/pooling.go
// aliases these for the pooling implementation.
const (
// PoolingBackend (or the empty string) leaves pooling to the inference
// backend — the pre-existing behavior of the embeddings path.
PoolingBackend = "backend"
// PoolingMean averages the backend's raw per-token vectors Go-side.
PoolingMean = "mean"
// PoolingLast selects the last token's vector Go-side.
PoolingLast = "last"
// PoolingDecayedMean is a Go-side mean weighted toward the most recent
// tokens: w_i = 2^(-(T-1-i)/H) with half-life H tokens
// (parameters.pooling_half_life_tokens, default 256).
PoolingDecayedMean = "decayed_mean"
)
// IsGoSidePooling reports whether scheme pools in LocalAI's Go layer,
// which requires raw per-token vectors from the backend (the "pooling:none"
// backend option) rather than a backend-pooled single vector.
func IsGoSidePooling(scheme string) bool {
switch scheme {
case PoolingMean, PoolingLast, PoolingDecayedMean:
return true
}
return false
}
// ValidatePooling checks a pooling scheme + half-life pair. Shared by
// ModelConfig.Validate (model YAML) and the embeddings endpoint
// (per-request override) so both paths reject exactly the same shapes.
func ValidatePooling(scheme string, halfLifeTokens int) error {
switch scheme {
case "", PoolingBackend, PoolingMean, PoolingLast, PoolingDecayedMean:
default:
return fmt.Errorf("unknown pooling %q (expected %q, %q, %q or %q)",
scheme, PoolingBackend, PoolingMean, PoolingLast, PoolingDecayedMean)
}
if halfLifeTokens < 0 {
return fmt.Errorf("pooling_half_life_tokens must be non-negative, got %d", halfLifeTokens)
}
if halfLifeTokens > 0 && scheme != PoolingDecayedMean {
return fmt.Errorf("pooling_half_life_tokens only applies to pooling %q, not %q",
PoolingDecayedMean, scheme)
}
return nil
}
// llamaPoolingOption returns the last load-time llama.cpp pooling option. The
// server accepts both names and processes options in order, so mirroring that
// behavior avoids disagreeing with the layout the backend actually loads.
func llamaPoolingOption(options []string) (string, bool) {
var value string
found := false
for _, o := range options {
if name, candidate, ok := strings.Cut(o, ":"); ok && (name == "pooling" || name == "pooling_type") {
value = candidate
found = true
}
}
return value, found
}
func hasLlamaPoolingOption(options []string) bool {
_, found := llamaPoolingOption(options)
return found
}
func (c *ModelConfig) HasTemplate() bool {
return c.TemplateConfig.Completion != "" || c.TemplateConfig.Edit != "" || c.TemplateConfig.Chat != "" || c.TemplateConfig.ChatMessage != "" || c.TemplateConfig.UseTokenizerTemplate
}
func (c *ModelConfig) GetModelConfigFile() string {
return c.modelConfigFile
}
// validateProfileFields checks client_tools and the profile block. It runs for
// aliases too, which otherwise skip validation: their profile is used.
func (c *ModelConfig) validateProfileFields() error {
for key, value := range map[string]string{"client_tools": c.ClientTools, "profile.client_tools": c.Profile.ClientTools} {
switch value {
case "", ClientToolsDrop:
default:
return fmt.Errorf("%s: unknown value %q (allowed: %q)", key, value, ClientToolsDrop)
}
}
if c.Profile.IsZero() {
return nil
}
if !c.IsAlias() {
return fmt.Errorf("profile is only valid on an alias config (model %q has no alias)", c.Name)
}
if c.Profile.MCP.HasMCPServers() {
if _, _, err := c.Profile.MCP.MCPConfigFromYAML(); err != nil {
return fmt.Errorf("profile: invalid MCP configuration: %w", err)
}
}
if ec := c.Profile.EvidenceCheck; ec.Note != "" && !ec.Numbers {
return fmt.Errorf("profile.evidence_check.note has no effect without numbers: true")
}
return nil
}
// PersistedConfigRevision returns the revision stamped when this configuration
// was loaded, or "" when it was never stamped (a config synthesized outside the
// loader). Callers that need a revision for a request must prefer this over
// recomputing one from the config they hold: by then the request pipeline has
// merged the caller's prediction parameters into it.
func (c *ModelConfig) PersistedConfigRevision() string {
return c.persistedConfigRevision
}
// StampPersistedConfigRevision records the revision of this configuration as
// persisted. It is computed from the receiver as-is, so callers must invoke it
// only on a configuration that has not been merged with request overrides.
func (c *ModelConfig) StampPersistedConfigRevision() error {
revision, err := modelConfigRevision(c)
if err != nil {
return err
}
c.persistedConfigRevision = revision
return nil
}
// GetModelTemplate returns the model's chat template if available
func (c *ModelConfig) GetModelTemplate() string {
return c.modelTemplate
}
// IsDisabled returns true if the model is disabled
func (c *ModelConfig) IsDisabled() bool {
return c.Disabled != nil && *c.Disabled
}
// IsPinned returns true if the model is pinned (excluded from idle unloading and eviction)
func (c *ModelConfig) IsPinned() bool {
return c.Pinned != nil && *c.Pinned
}
// GetConcurrencyGroups returns the model's concurrency groups, normalized:
// trimmed of whitespace, empty entries dropped, deduped. Returns nil when no
// effective groups remain. The result is a fresh slice; the caller may
// mutate it without affecting the config.
func (c *ModelConfig) GetConcurrencyGroups() []string {
if len(c.ConcurrencyGroups) == 0 {
return nil
}
out := make([]string, 0, len(c.ConcurrencyGroups))
for _, g := range c.ConcurrencyGroups {
g = strings.TrimSpace(g)
if g == "" || slices.Contains(out, g) {
continue
}
out = append(out, g)
}
if len(out) == 0 {
return nil
}
return out
}
type ModelConfigUsecase int
const (
FLAG_ANY ModelConfigUsecase = 0b000000000000
FLAG_CHAT ModelConfigUsecase = 0b000000000001
FLAG_COMPLETION ModelConfigUsecase = 0b000000000010
FLAG_EDIT ModelConfigUsecase = 0b000000000100
FLAG_EMBEDDINGS ModelConfigUsecase = 0b000000001000
FLAG_RERANK ModelConfigUsecase = 0b000000010000
FLAG_IMAGE ModelConfigUsecase = 0b000000100000
FLAG_TRANSCRIPT ModelConfigUsecase = 0b000001000000
FLAG_TTS ModelConfigUsecase = 0b000010000000
FLAG_SOUND_GENERATION ModelConfigUsecase = 0b000100000000
FLAG_TOKENIZE ModelConfigUsecase = 0b001000000000
FLAG_VAD ModelConfigUsecase = 0b010000000000
FLAG_VIDEO ModelConfigUsecase = 0b100000000000
FLAG_DETECTION ModelConfigUsecase = 0b1000000000000
FLAG_VISION ModelConfigUsecase = 0b10000000000000
FLAG_FACE_RECOGNITION ModelConfigUsecase = 0b100000000000000
FLAG_SPEAKER_RECOGNITION ModelConfigUsecase = 0b1000000000000000
FLAG_AUDIO_TRANSFORM ModelConfigUsecase = 0b10000000000000000
FLAG_DIARIZATION ModelConfigUsecase = 0b100000000000000000
FLAG_REALTIME_AUDIO ModelConfigUsecase = 0b1000000000000000000
// Marks a model as wired for the Score gRPC primitive (joint
// log-prob of candidate continuations under a shared prompt). Must
// be declared explicitly via `known_usecases: [score]` — there's
// no heuristic for it. On llama-cpp, Score runs through the slot
// loop (SERVER_TASK_TYPE_SCORE), so it may combine freely with
// chat/completion/embeddings on one config and shares the slot's
// prompt cache with generation.
FLAG_SCORE ModelConfigUsecase = 0b10000000000000000000
// Marks a model as wired for the Depth gRPC primitive (per-pixel
// metric depth + camera pose + 3D point cloud via Depth Anything 3).
FLAG_DEPTH ModelConfigUsecase = 0b100000000000000000000
// Marks a model as wired for the TokenClassify gRPC primitive (the
// openai-privacy-filter PII NER tier — per-token BIOES classification).
// Like FLAG_SCORE it must be declared explicitly via
// `known_usecases: [token_classify]`; there's no heuristic. Requires
// TOKEN_CLS pooling, which is loaded via the embeddings flag. On
// llama-cpp the classification windows ride the embedding task queue,
// so it may combine freely with other usecases.
FLAG_TOKEN_CLASSIFY ModelConfigUsecase = 0b1000000000000000000000
// Marks a model as wired for the SoundDetection gRPC primitive
// (audio tagging / sound-event classification — scored AudioSet
// labels via the SoundDetection RPC, e.g. ced).
FLAG_SOUND_CLASSIFICATION ModelConfigUsecase = 0b10000000000000000000000
// Marks a model as wired for the Generate3D gRPC primitive
// (image-conditioned 3D asset generation — a binary glTF mesh with
// optional PBR material, e.g. trellis2cpp).
FLAG_3D ModelConfigUsecase = 0b100000000000000000000000
FLAG_3D_ANIMATION ModelConfigUsecase = 1 << 24
// Marks a model as a decision model: it answers typed choice / noul /
// score questions over a state (served by POST /v1/systemone).
// Explicit only, like FLAG_SCORE: a decision model never generates
// text, so guessing chat or embeddings for it would surface it in
// pickers it cannot serve.
FLAG_DECISIONS ModelConfigUsecase = 1 << 25
// Marks a model as an image upscaler served by the UpscaleImage RPC.
FLAG_UPSCALE ModelConfigUsecase = 1 << 26
// Marks a model as a motion-capture backend (GEM-X pose streaming).
// Keep every usecase flag a distinct bit: the "Usecase flags" spec
// guards against two flags silently sharing one.
FLAG_MOTION ModelConfigUsecase = 1 << 27
// Marks a model as a time-series forecaster served by the Forecast RPC.
FLAG_FORECAST ModelConfigUsecase = 1 << 28
// Common Subsets
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
)
// ModalityGroups defines groups of usecases that belong to the same modality.
// Flags within the same group are NOT orthogonal (e.g., chat and completion are
// both text/language). A model is multimodal when its usecases span 2+ groups.
var ModalityGroups = []ModelConfigUsecase{
FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT, // text/language
FLAG_VISION | FLAG_DETECTION | FLAG_MOTION, // visual understanding
FLAG_TRANSCRIPT | FLAG_REALTIME_AUDIO | FLAG_SOUND_CLASSIFICATION, // audio input — realtime_audio is any-to-any, so it counts here too
FLAG_TTS | FLAG_SOUND_GENERATION | FLAG_REALTIME_AUDIO, // audio output — and here, so a lone realtime_audio flag still reads as multimodal
FLAG_AUDIO_TRANSFORM, // audio in/out transforms
FLAG_IMAGE | FLAG_VIDEO | FLAG_3D | FLAG_3D_ANIMATION, // visual generation
}
// IsMultimodal returns true if the given usecases span two or more orthogonal
// modality groups. For example chat+vision is multimodal, but chat+completion
// is not (both belong to the text/language group).
func IsMultimodal(usecases ModelConfigUsecase) bool {
groupCount := 0
for _, group := range ModalityGroups {
if usecases&group != 0 {
groupCount++
if groupCount >= 2 {
return true
}
}
}
return false
}
func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
return map[string]ModelConfigUsecase{
// Note: FLAG_ANY is intentionally excluded from this map
// because it's 0 and would always match in HasUsecases checks
"FLAG_CHAT": FLAG_CHAT,
"FLAG_COMPLETION": FLAG_COMPLETION,
"FLAG_EDIT": FLAG_EDIT,
"FLAG_EMBEDDINGS": FLAG_EMBEDDINGS,
"FLAG_RERANK": FLAG_RERANK,
"FLAG_IMAGE": FLAG_IMAGE,
"FLAG_TRANSCRIPT": FLAG_TRANSCRIPT,
"FLAG_TTS": FLAG_TTS,
"FLAG_SOUND_GENERATION": FLAG_SOUND_GENERATION,
"FLAG_TOKENIZE": FLAG_TOKENIZE,
"FLAG_VAD": FLAG_VAD,
"FLAG_LLM": FLAG_LLM,
"FLAG_VIDEO": FLAG_VIDEO,
"FLAG_DETECTION": FLAG_DETECTION,
"FLAG_VISION": FLAG_VISION,
"FLAG_FACE_RECOGNITION": FLAG_FACE_RECOGNITION,
"FLAG_SPEAKER_RECOGNITION": FLAG_SPEAKER_RECOGNITION,
"FLAG_AUDIO_TRANSFORM": FLAG_AUDIO_TRANSFORM,
"FLAG_DIARIZATION": FLAG_DIARIZATION,
"FLAG_SOUND_CLASSIFICATION": FLAG_SOUND_CLASSIFICATION,
"FLAG_REALTIME_AUDIO": FLAG_REALTIME_AUDIO,
"FLAG_SCORE": FLAG_SCORE,
"FLAG_DEPTH": FLAG_DEPTH,
"FLAG_TOKEN_CLASSIFY": FLAG_TOKEN_CLASSIFY,
"FLAG_3D": FLAG_3D,
"FLAG_3D_ANIMATION": FLAG_3D_ANIMATION,
"FLAG_DECISIONS": FLAG_DECISIONS,
"FLAG_UPSCALE": FLAG_UPSCALE,
"FLAG_MOTION": FLAG_MOTION,
"FLAG_FORECAST": FLAG_FORECAST,
}
}
func stringToFlag(s string) string {
return "FLAG_" + strings.ToUpper(s)
}
func GetUsecasesFromYAML(input []string) *ModelConfigUsecase {
if len(input) == 0 {
return nil
}
result := FLAG_ANY
flags := GetAllModelConfigUsecases()
for _, str := range input {
for _, flag := range []string{stringToFlag(str), str} {
f, exists := flags[flag]
if exists {
result |= f
}
}
}
return &result
}
// HasUsecases examines a ModelConfig and determines which endpoints have a chance of success.
//
// Declared known_usecases are normally additive — the guessing heuristic
// still adds whatever it can infer from backend/templates. The exceptions
// are FLAG_SCORE, FLAG_TOKEN_CLASSIFY, FLAG_DECISIONS and FLAG_UPSCALE: when the operator
// declared any of them, they reserved the model for a direct-decode primitive
// (the router classifier, the PII NER tier, or a decision head). Letting GuessUsecases
// paint chat/completion/embeddings on top would surface it in pickers it
// was deliberately kept out of. So a declared score or token_classify
// list is authoritative; declare the generation usecases explicitly
// alongside score to serve both from one config.
func (c *ModelConfig) HasUsecases(u ModelConfigUsecase) bool {
if c.KnownUsecases != nil {
if (u & *c.KnownUsecases) == u {
return true
}
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY | FLAG_DECISIONS | FLAG_UPSCALE)) != 0 {
return false
}
}
return c.GuessUsecases(u)
}
// GuessUsecases is a **heuristic based** function, as the backend in question may not be loaded yet, and the config may not record what it's useful at.
// In its current state, this function should ideally check for properties of the config like templates, rather than the direct backend name checks for the lower half.
// This avoids the maintenance burden of updating this list for each new backend - but unfortunately, that's the best option for some services currently.
func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
// Upscaling requires a dedicated model declaration, not just an image backend.
if u&FLAG_UPSCALE != 0 {
return false
}
if u&FLAG_MOTION != 0 {
// Motion is declared by the backend registry, never inferred from templates.
if cap := GetBackendCapability(c.Backend); cap == nil || !slices.Contains(cap.PossibleUsecases, UsecaseMotion) {
return false
}
}
// Backends that are clearly not text-generation
nonTextGenBackends := []string{
"whisper", "piper", "kokoro",
"diffusers", "stablediffusion", "stablediffusion-ggml",
"rerankers", "silero-vad", "rfdetr", "insightface", "speaker-recognition",
"transformers-musicgen", "ace-step", "acestep-cpp", "timestral-cpp",
}
if (u & FLAG_CHAT) == FLAG_CHAT {
// A router model is a chat dispatcher: it carries no chat
// template of its own (those live on the candidates it routes
// to) and is invoked through the chat endpoint, so the router
// block stands in for chat capability.
if !c.HasRouter() {
if c.TemplateConfig.Chat == "" && c.TemplateConfig.ChatMessage == "" && !c.TemplateConfig.UseTokenizerTemplate {
return false
}
if slices.Contains(nonTextGenBackends, c.Backend) {
return false
}
if c.Embeddings != nil && *c.Embeddings {
return false
}
}
}
if (u & FLAG_COMPLETION) == FLAG_COMPLETION {
if c.TemplateConfig.Completion == "" {
return false
}
if slices.Contains(nonTextGenBackends, c.Backend) {
return false
}
}
if (u & FLAG_EDIT) == FLAG_EDIT {
if c.TemplateConfig.Edit == "" {
return false
}
}
if (u & FLAG_EMBEDDINGS) == FLAG_EMBEDDINGS {
if c.Embeddings == nil || !*c.Embeddings {
return false
}
}
if (u & FLAG_IMAGE) == FLAG_IMAGE {
imageBackends := []string{"diffusers", "stablediffusion", "stablediffusion-ggml"}
if !slices.Contains(imageBackends, c.Backend) {
return false
}
if c.Backend == "diffusers" && c.Diffusers.PipelineType == "" {
return false
}
}
if (u & FLAG_VIDEO) == FLAG_VIDEO {
videoBackends := []string{"diffusers", "stablediffusion", "vllm-omni"}
if !slices.Contains(videoBackends, c.Backend) {
return false
}
if c.Backend == "diffusers" && c.Diffusers.PipelineType == "" {
return false
}
}
if (u & FLAG_RERANK) == FLAG_RERANK {
if c.Backend != "rerankers" && (c.Reranking == nil || !*c.Reranking) {
return false
}
}
if (u & FLAG_TRANSCRIPT) == FLAG_TRANSCRIPT {
if c.Backend != "whisper" {
return false
}
// whisper models with vad_only option are VAD, not transcription
if slices.Contains(c.Options, "vad_only") {
return false
}
}
if (u & FLAG_TTS) == FLAG_TTS {
ttsBackends := []string{"piper", "transformers-musicgen", "kokoro"}
if !slices.Contains(ttsBackends, c.Backend) {
return false
}
}
if (u & FLAG_VISION) == FLAG_VISION {
// Without a branch here the function falls through to true, which paints
// vision onto every chat model. That is not just a cosmetic wrong answer:
// syncKnownUsecasesFromString rewrites KnownUsecaseStrings from
// HasUsecases, so a guessed FLAG_VISION is written back and the next sync
// parses it into KnownUsecases as if the operator had declared it,
// defeating the explicit-signal checks in VisionSupported. Defer to the
// same explicit signals here; VisionSupported never calls back into
// HasUsecases, so this does not recurse.
if !c.VisionSupported() {
return false
}
}
if (u & FLAG_DETECTION) == FLAG_DETECTION {
detectionBackends := []string{"rfdetr", "sam3-cpp", "insightface"}
if !slices.Contains(detectionBackends, c.Backend) {
return false
}
}
if (u & FLAG_DEPTH) == FLAG_DEPTH {
depthBackends := []string{"depth-anything"}
if !slices.Contains(depthBackends, c.Backend) {
return false
}
}
if (u & FLAG_FORECAST) == FLAG_FORECAST {
forecastBackends := []string{"timestral-cpp"}
if !slices.Contains(forecastBackends, c.Backend) {
return false
}
}
if (u & FLAG_3D) == FLAG_3D {
threeDBackends := []string{"trellis2cpp"}
if !slices.Contains(threeDBackends, c.Backend) {
return false
}
}
if (u&FLAG_3D_ANIMATION) == FLAG_3D_ANIMATION && c.Backend != "kimodocpp" {
return false
}
if (u & FLAG_FACE_RECOGNITION) == FLAG_FACE_RECOGNITION {
faceBackends := []string{"insightface"}
if !slices.Contains(faceBackends, c.Backend) {
return false
}
}
if (u & FLAG_SPEAKER_RECOGNITION) == FLAG_SPEAKER_RECOGNITION {
speakerBackends := []string{"speaker-recognition"}
if !slices.Contains(speakerBackends, c.Backend) {
return false
}
}
if (u & FLAG_AUDIO_TRANSFORM) == FLAG_AUDIO_TRANSFORM {
audioTransformBackends := []string{"localvqe"}
if !slices.Contains(audioTransformBackends, c.Backend) {
return false
}
}
if (u & FLAG_SOUND_GENERATION) == FLAG_SOUND_GENERATION {
soundGenBackends := []string{"transformers-musicgen", "ace-step", "acestep-cpp", "mock-backend"}
if !slices.Contains(soundGenBackends, c.Backend) {
return false
}
}
if (u & FLAG_TOKENIZE) == FLAG_TOKENIZE {
tokenizeCapableBackends := []string{"llama.cpp", "rwkv"}
if !slices.Contains(tokenizeCapableBackends, c.Backend) {
return false
}
}
if (u & FLAG_VAD) == FLAG_VAD {
if c.Backend != "silero-vad" && c.Backend != "sherpa-onnx" && !(c.Backend == "whisper" && slices.Contains(c.Options, "vad_only")) {
return false
}
}
if (u & FLAG_DIARIZATION) == FLAG_DIARIZATION {
// vibevoice-cpp emits speaker-labelled segments natively from its
// ASR pass; sherpa-onnx pipes pyannote segmentation + speaker
// embeddings + clustering. Both surface as a Diarize gRPC.
diarizationBackends := []string{"vibevoice-cpp", "sherpa-onnx"}
if !slices.Contains(diarizationBackends, c.Backend) {
return false
}
}
if (u & FLAG_SOUND_CLASSIFICATION) == FLAG_SOUND_CLASSIFICATION {
// ced is a sound-event tagger (AudioSet labels) surfaced via the
// SoundDetection gRPC. Models without an explicit known_usecases
// still surface when they run on one of these backends.
soundClassificationBackends := []string{"ced"}
if !slices.Contains(soundClassificationBackends, c.Backend) {
return false
}
}
if (u & FLAG_REALTIME_AUDIO) == FLAG_REALTIME_AUDIO {
// No heuristic: realtime_audio is a deliberate operator choice. A
// plain GGUF must not appear on the Talk page, so HasUsecases(
// FLAG_REALTIME_AUDIO) is true only when KnownUsecases declares it.
return false
}
if (u & FLAG_SCORE) == FLAG_SCORE {
// No heuristic: Score-intent is a deliberate operator choice
// (it keeps the model out of pickers it wasn't meant for), so
// HasUsecases(FLAG_SCORE) is true only when KnownUsecases
// declares it explicitly.
return false
}
if (u & FLAG_TOKEN_CLASSIFY) == FLAG_TOKEN_CLASSIFY {
// No heuristic: token-classification intent is a deliberate
// operator choice (it reserves the model from generation traffic
// on llama-cpp, and the model's TOKEN_CLS head isn't useful as
// general embeddings), so HasUsecases(FLAG_TOKEN_CLASSIFY) is true
// only when KnownUsecases declares it explicitly.
return false
}
if (u & FLAG_DECISIONS) == FLAG_DECISIONS {
// No heuristic: decisions intent is a deliberate operator choice
// (the model is a non-generative decision head), so
// HasUsecases(FLAG_DECISIONS) is true only when KnownUsecases
// declares it explicitly.
return false
}
return true
}
// BuildCogitoOptions generates cogito options from the model configuration
// It accepts a context, MCP sessions, and optional callback functions for status, reasoning, tool calls, and tool results
func (c *ModelConfig) BuildCogitoOptions() []cogito.Option {
cogitoOpts := []cogito.Option{
cogito.WithIterations(3), // default to 3 iterations
cogito.WithMaxAttempts(3), // default to 3 attempts
cogito.WithForceReasoning(),
}
// Apply agent configuration options
if c.Agent.EnableReasoning {
cogitoOpts = append(cogitoOpts, cogito.WithForceReasoning())
}
if c.Agent.EnablePlanning {
cogitoOpts = append(cogitoOpts, cogito.EnableAutoPlan)
}
if c.Agent.EnableMCPPrompts {
cogitoOpts = append(cogitoOpts, cogito.EnableMCPPrompts)
}
if c.Agent.EnablePlanReEvaluator {
cogitoOpts = append(cogitoOpts, cogito.EnableAutoPlanReEvaluator)
}
if c.Agent.MaxIterations != 0 {
cogitoOpts = append(cogitoOpts, cogito.WithIterations(c.Agent.MaxIterations))
}
if c.Agent.MaxAttempts != 0 {
cogitoOpts = append(cogitoOpts, cogito.WithMaxAttempts(c.Agent.MaxAttempts))
}
if c.Agent.DisableSinkState {
cogitoOpts = append(cogitoOpts, cogito.DisableSinkState)
}
if c.Agent.LoopDetection != 0 {
cogitoOpts = append(cogitoOpts, cogito.WithLoopDetection(c.Agent.LoopDetection))
}
if c.Agent.MaxAdjustmentAttempts != 0 {
cogitoOpts = append(cogitoOpts, cogito.WithMaxAdjustmentAttempts(c.Agent.MaxAdjustmentAttempts))
}
if c.Agent.ForceReasoningTool {
cogitoOpts = append(cogitoOpts, cogito.WithForceReasoningTool())
}
return cogitoOpts
}