Compare commits

..

36 Commits

Author SHA1 Message Date
Ettore Di Giacinto
f48efa465e fix(llama-cpp): stop non-TTS models crashing on the new pin
Two regressions, both hit every ordinary llama-cpp model and neither was
caught locally because every test on this branch loaded a TTS model.

The first is a null dereference. server_slot::tts_ctx::reset() called
mtmd_helper_gen_audio_reset() unconditionally, but the gen-audio pipeline
is only allocated for models carrying a gen-audio mmproj, and upstream's
implementation reads ctx->pipeline before null-checking anything. Since
server_slot::reset() runs during slot initialization for every model, any
non-TTS model segfaulted the backend the moment it loaded. Guard the call
on the is_supported() predicate already defined beside it, and keep the
plain field resets unconditional.

The second is unrelated to TTS and came in with the pin bump.
PredictOptions.Penalty is a bare proto float, so a caller that names no
repetition penalty sends 0 rather than omitting the field. Since
9de0fcf2b, common_sampler_init() rejects a non-positive penalty_repeat
outright because it would divide logits by zero, turning every such
request into "Failed to initialize samplers". Treat 0 as unset and leave
llama.cpp's own neutral default in place.

Verified with the same suite CI runs, which is what caught both:
tests/e2e-backends passes 6 of 6 including the load and predict specs
that were red. Qwen3-TTS still synthesises on both paths, 24 kHz mono
16-bit WAV with exactly one RIFF header on the streamed output.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 14:50:35 +00:00
Ettore Di Giacinto
aac507d2cc fix(importers): import a Qwen3-TTS GGUF repo as TTS, not chat
The llama-cpp importer hardcodes known_usecases: [chat] and assigns any
mmproj-matching file as a vision projector, so ggml-org/Qwen3-TTS-12Hz-1.7B-
Base-GGUF imported as a chat model with vision. Both fields were wrong, and
the model was unreachable from /tts and from the Voice Library.

Filenames cannot fix this. A Qwen3-TTS repo has the exact shape of a vision
repo, one backbone GGUF plus one mmproj-*.gguf, so the projector's own header
is the only honest signal: mtmd writes clip.has_gen_audio_encoder for the
projectors it can drive as a speech pipeline and refuses to build one without
it. Probe the selected mmproj for that flag, reusing the range-fetch the MTP
detection already does, and declare tts when it is set. The mmproj assignment
then stops reading as vision on its own, since a declared-TTS model already
exempts its projector from vision detection.

The probe is best-effort like the MTP one: a network blip leaves the chat
default in place rather than failing the import.

Verified against the real artifacts on disk: the Qwen3-TTS projector reports
gen-audio, its backbone does not.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 12:40:45 +00:00
Ettore Di Giacinto
bb392fb2ec fix(gallery): declare what nemotron-3-nano-omni actually accepts
The entry is backend: vllm-omni with known_usecases: [chat, completion], no
mmproj and no media marker, so it used to report vision only through the
blanket GuessUsecases fallthrough that the vision branch in this branch
removed. Nemotron 3 Nano Omni is a multimodal understanding model: image,
video and audio in, text out. Declaring that is what the sibling
vllm-omni-qwen3-omni-30b already does.

known_usecases gains vision only. FLAG_VIDEO is video GENERATION, an output
modality, and this model generates none; video and audio input belong in
known_input_modalities, which is where AudioInputSupported and
VideoInputSupported read them from.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 12:40:45 +00:00
Ettore Di Giacinto
9805e62f5d fix(config): register llama-cpp as a TTS and voice-cloning backend
The branch taught the llama-cpp backend to serve Qwen3-TTS and shipped two
gallery entries for it, but never told the capability table. llama-cpp still
declared only the text RPCs and usecases, so:

- VoiceCloningForModel returned nil at the capability check, before it ever
  reached the model's own tts.voice_cloning override, and /tts answered 400
  "selected model does not support reference-audio voice cloning" for any
  localai://voice-profiles/... voice. No model YAML could opt back in.
- GET /api/backends/usecases did not list tts for llama-cpp, so the gallery
  greyed out the TTS filter for the entries this branch adds.
- The React TTS page saw voice_cloning: null and kept both models out of the
  Voice Library.

Add the TTS RPCs and usecase, and the reference-audio contract.

The contract needs narrowing, because the per-backend switch in
VoiceCloningForModel ends in a permissive default: an unnarrowed entry would
have advertised reference-audio cloning on every GGUF chat model in the
gallery. Narrow on the declared TTS usecase rather than the model name. The
TTS checkpoints are the only llama-cpp models carrying known_usecases: [tts];
name matching would have to guess at third-party repacks, and "base", the
substring the neighbouring Qwen and vLLM cases key on, is a routine word in
text-model names. The check reads the declared bit directly instead of going
through HasUsecases, which falls through to GuessUsecases and would hand the
decision to a heuristic that never had a llama.cpp TTS model in mind.

DefaultUsecases stays [chat]: a bare GGUF served by llama.cpp is a chat model,
and both the gallery filter and the importer read that field.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 12:40:45 +00:00
Ettore Di Giacinto
67f14217b8 docs: cover Qwen3-TTS on the llama-cpp backend
Adds the gallery variants, the two-file mmproj configuration, the
required voice reference, and the language and sampling knobs. Also
corrects the streaming-support list, which named only voxcpm.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 11:59:27 +00:00
Ettore Di Giacinto
ea1fdc2a9e feat(gallery): add Qwen3-TTS entries for the llama-cpp backend
Two entries over upstream's own GGUF conversion, Q8_0 and Q4_K_M, each
pairing a backbone with the Q8_0 projector. Named to sit alongside the
existing qwen3-tts-cpp entries rather than replace them.

Also tags the llama-cpp backend text-to-speech / TTS so the backend browser
surfaces the capability.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 11:52:22 +00:00
Ettore Di Giacinto
e07fc579e8 fix(config): keep a TTS speaker-encoder projector out of vision detection
Task 1 exempted a declared-TTS model's mmproj from VisionSupported, but the
first real gallery entry with an mmproj still came back vision-capable through
two paths the earlier fix did not close.

GuessUsecases has no FLAG_VISION branch, so it falls through to true for any
chat-ish model. That is not just a wrong answer at the call site:
syncKnownUsecasesFromString rewrites KnownUsecaseStrings from HasUsecases, and
the loader calls it more than once per config file, so the guessed FLAG_VISION
is written out and parsed back into KnownUsecases as if the operator had
declared it. Give GuessUsecases a FLAG_VISION branch that defers to the same
explicit signals VisionSupported uses.

Second, llama.cpp builds an mtmd context for the speaker-encoder projector and
reports its media marker on the first chat probe, which resurrected vision
after the model had been used once. Apply the same declared-TTS exemption to
MediaMarker that the mmproj check already had.

Verified against the qwen3-tts-llamacpp-q4 gallery entry: no vision capability
and no image input modality, before load, after a TTS request, and after a chat
probe.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 11:52:16 +00:00
Ettore Di Giacinto
1c5ff4e076 build(llama-cpp): let unpatched forks opt out of the TTS task
turboquant and bonsai copy grpc-server.cpp into llama.cpp forks that do
not carry our patches. disable-tts-task.sh injects the same kind of
preprocessor switch disable-score-task.sh already uses, so those builds
answer UNIMPLEMENTED rather than failing to compile.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 11:18:28 +00:00
Ettore Di Giacinto
873d3c59eb fix(llama-cpp): send the TTS sample rate up front, and tidy three review items
Four items from the Task 4 review.

Streaming first-byte latency. TTSStream sent the sample-rate reply only
once the first audio result arrived, and a chunk needs a whole 72-frame
window, roughly 5.8 s of audio and far longer in wall time on CPU. The
Go side blocks on that reply before it can emit the WAV header, so a
streaming client sat at zero bytes for the whole stretch. The rate is a
property of the loaded model and is available synchronously from
mtmd_gen_audio_get_info, so it now goes out immediately after post_task
and the rate_sent bookkeeping is gone. Measured on a warm model, first
byte drops from 30.48 s to 0.014 s, and the output is still a valid WAV
with exactly one RIFF header at byte 0.

Unchecked close. The non-streaming path ignored ofstream::close(), so a
failure that only surfaces on flush was reported as success while
leaving a truncated file at dst. It now returns INTERNAL like the other
write failures.

Wrong comment on set_lang. gen_audio::inp::get() already maps a stored
blank to nullptr, so our guard is behavior-preserving, not
behavior-fixing. The comment claimed otherwise; the code was right.

Repetition penalty. penalty_last_n = -1 is inert at this pin, because
llama_sampler_init_penalties clamps it with std::max(penalty_last_n, 0)
and then builds a disabled sampler, so the 1.05 penalty never applies.
Upstream's README attributes looping to a missing repeat_penalty, so it
was worth testing as a root-cause fix for the model running to the frame
cap. Dropping the line lets the sampling default of 64 apply, which was
confirmed in the sampler chain trace as penalty_last_n = 64 with
repeat_penalty = 1.050. Over 15 uncapped short requests each way it did
not help: 0 of 15 ran to the cap with the penalty inert, 1 of 15 with it
active. Both lines are therefore kept for parity with upstream's draft,
and a comment now records that the pair is inert and why, so the next
reader does not believe a penalty is applied. max_frames remains the way
to bound output.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 11:10:51 +00:00
Ettore Di Giacinto
60526ff092 feat(llama-cpp): expose max_frames for TTS requests
The Qwen3-TTS backbone does not always emit <|codec_eos_token|>, and
when it does not, generation runs to upstream's 512-frame n_predict
default. At the model's 12.5 Hz frame rate that is 40.96 s of audio,
which a short input can trigger: one request in this session produced
40.96 s for a ten-word sentence. prepareTTSTask hardcoded n_predict to
-1, so callers had no way to bound it.

Add a max_frames key alongside top_k and top_p, parsed with the same
strict whole-string parsing so a typo is an error rather than a silently
truncated value, and rejected with a field-naming message when negative.
0 keeps the existing sentinel convention and means unset, so a request
that omits it behaves exactly as before.

Named max_frames rather than n_predict because frames are what the
parameter means at a TTS endpoint: one frame is 0.08 s of audio.

The 512-frame default is deliberately unchanged. Lowering it would
truncate legitimately long inputs, which is a worse failure than an
occasionally overlong one.

Verified end to end on one text of thirty words:

  max_frames=25    HTTP 200,  96044 bytes,  2.00 s, exactly 25 frames
  max_frames=50    HTTP 200, 192044 bytes,  4.00 s, exactly 50 frames
  no max_frames    HTTP 200, 572204 bytes, 11.92 s, stopped at its own
                   codec EOS after 149 frames, unchanged behavior

  max_frames=-1    InvalidArgument "max_frames must be >= 0, got \"-1\""
  max_frames=many  InvalidArgument "max_frames must be an integer, got \"many\""

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 10:11:51 +00:00
Ettore Di Giacinto
1941a8b191 fix(llama-cpp): clear the TTS slot sequence between requests
Only the first TTS request in a backend process succeeded. Every later
one failed instantly, in about 0.13 s, with "TTS prompt processing
failed" from step_prompt, regardless of streaming or non-streaming and
regardless of the text. With LOCALAI_SINGLE_ACTIVE_BACKEND=true the
process is kept alive between requests, so a deployment would have
served exactly one utterance per backend start.

The cause is missing KV hygiene, not anything in the gRPC adapter. TTS
slots never enter the shared batch: pre_decode() returns early for them
and process_tts_slots() drives them instead, so they skip the
prompt-cache bookkeeping that clears a slot's sequence between requests.
Nothing in the gen-audio path makes up for it: mtmd_helper_gen_audio_reset
only clears host-side buffers, and the pipeline always decodes from
position 0 into the sequence identified by slot.id. So the second task
on a slot writes positions 0..N over the first task's tokens and
llama_decode fails.

Fix is one call to slot.prompt_clear(), the same helper the normal path
uses, in the SERVER_TASK_TYPE_TTS branch of launch_slot_with_task before
set_input. It goes into 0002 rather than a new patch file because it is
a defect in the code that patch introduces, and the header now records
it as ours so we know whether it still needs carrying if #26603 merges
without it.

Verified in one backend process, different text on every request:
three consecutive non-streaming requests, three consecutive streaming
requests, and an interleaved non-streaming, streaming, non-streaming,
streaming run. All ten returned HTTP 200 with
RIFF ... WAVE audio, Microsoft PCM, 16 bit, mono 24000 Hz, the streamed
ones carrying exactly one RIFF header at byte 0, and every output
measured as real speech rather than silence or a truncated fragment.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 09:44:46 +00:00
Ettore Di Giacinto
b04de703a3 chore(llama-cpp): bump pin to 9de0fcf2b and drop the TTS codec mask
Upstream fixed the Qwen3-TTS abort in ggml-org/llama.cpp c8e03ce81
("mtmd/ggml: add ggml_build_forward_order", #26649), landed one hour
after the previous pin. ggml_build_forward_expand marks a tensor and all
its ancestors for compute, so using it as a pure ordering hint defeated
ggml_build_forward_select and made GEN_WAV calls execute the GEN_CODE
branch against a stale inp_code0, hitting the get_rows bound assert in
ggml_compute_forward_get_rows.

That single defect accounts for every abort seen on this model, so
0003-mask-non-codec-tokens-for-tts.patch is removed rather than rebased.
The mask changed the observed behavior, but it was perturbing a graph
ordering bug rather than fixing a sampling one: at the new pin the whole
path works without it. Keeping it would have meant carrying a 152k-entry
logit bias, and rebasing it on every pin bump, for no benefit.

Verified at 9de0fcf2b with only 0001 and 0002 applied, which both apply
clean with no fuzz and needed no rebase:

  non-streaming  HTTP 200, 410924 bytes, 8.56 s
                 RIFF (little-endian) data, WAVE audio, Microsoft PCM,
                 16 bit, mono 24000 Hz
  streaming      HTTP 200, 560684 bytes, 11.68 s, exactly one RIFF at
                 byte 0, same format, which also exercises the
                 float32-to-s16 conversion at runtime for the first time

Pristine unpatched llama-tts at the same pin now also completes, 130
frames to a valid WAV, where it aborted at frame 55 before.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 08:31:02 +00:00
Ettore Di Giacinto
5da3b2fc44 fix(llama-cpp): mask non-codec tokens for Qwen3-TTS generation
The Qwen3-TTS gen-audio pipeline maps a sampled backbone token to a
codebook row with an unchecked subtraction, in mtmd-helper-gen.cpp:

    inp.code0 = sampled - codec_0;

For ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF the vocab is 155008 tokens,
<|codec_0|> is 151936 and the codec codes end at 153983. The model's own
tokenizer.ggml.suppress_tokens holds 1023 ids covering 153984..155007,
every special above the codec range except <|codec_eos_token|> (154086)
which stays reachable as the stop token. Nothing masks the text range
0..151935, so the backbone can sample a text token at any step, the
subtraction goes negative, and ggml_compute_forward_get_rows aborts the
whole backend process on GGML_ASSERT(i01 >= 0 && i01 < ne01).

Complete the mask upstream started: bias every token below <|codec_0|>
to -INFINITY for TTS tasks so only codec codes and the codec EOS remain
reachable. The biases are appended to task.params.sampling.logit_bias,
which common_sampler_init already merges with the model's suppress
tokens into one llama_sampler_init_logit_bias, so no sampler is added to
the chain. Measured cost is 0.082 ms per sampled token and 1.16 MB, set
against a forward pass in the multi-millisecond range.

It lands in launch_slot_with_task rather than in a route handler so that
llama.cpp's own POST /tts and LocalAI's TTS/TTSStream RPCs are both
covered, and <|codec_0|> is resolved from the vocab rather than
hardcoded so a model without it is left alone.

This is reproducible with upstream's own llama-tts and no LocalAI code
loaded, aborting at frame 55 on Q4_K_M and frame 71 on Q8_0, so it is
neither a quantization artifact nor an artifact of the gRPC adapter.
Two further defects in the same draft pipeline still prevent end-to-end
audio; they are independent of this one and are recorded in the task
report for an upstream bug report.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 08:08:26 +00:00
Ettore Di Giacinto
cd42e80b2c feat(llama-cpp): implement the TTS and TTSStream RPCs
Both were declared in backend.proto but unimplemented. They now submit a
SERVER_TASK_TYPE_TTS task and drain the response reader, the same shape
PredictStream uses.

The streaming path emits a leading sample_rate message and then raw PCM,
because ModelTTSStream builds the WAV header itself; the non-streaming
path emits a complete WAV to the requested dst.

The streamed samples are converted from the pipeline's float32 to signed
16-bit first. MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM hands back floats, while
the header ModelTTSStream writes announces 16-bit samples, so shipping
the floats verbatim would decode as noise.

prepare.sh and CMakeLists.txt now stage tts_request_options.h alongside
the other grpc-server helpers, and register its standalone test with
ctest the way passthrough_options_test is registered.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-06 00:39:20 +00:00
Ettore Di Giacinto
ecf6ebda63 chore(llama-cpp): bump pin to f9e832c10 and carry the TTS server task
Picks up ggml-org/llama.cpp#26254 (Qwen3-TTS via mtmd) and #26536 (the
short-input audio chunk fix). Adds 0002-add-server-task-type-tts.patch,
the server-side half of the still-draft #26603, so TTS runs through the
slot scheduler instead of racing it. Remove that patch when #26603 merges.

The patch is rebased on top of the score patch: its tokenize-switch hunk
collided with the SERVER_TASK_TYPE_SCORE case, and its lone SRV_WRN call
passes no variadic argument, which the macro cannot expand. The score
patch itself needed no refresh.

Also fixes fallout from the bump in grpc-server.cpp: upstream dropped the
per-slot n_ctx argument from server_schema::eval_llama_cmpl_schema. Only
the schema branch loses it, since forks predating the server-schema split
still expect the old argument list.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-05 23:51:54 +00:00
Ettore Di Giacinto
0f0369072b fix(llama-cpp): range-check the TTS top_k and top_p request params
Format validation alone let NaN, infinity and out-of-range values through.
The consumer copies both values into the audio generation input
unconditionally and only guards its separate sampler assignment with
"> 0", a test NaN also fails, so a NaN reached llama.cpp with the guard
never firing. top_k must now be >= 0 and top_p must fall within 0.0 to 1.0
inclusive, with the bound written as a negated in-range test so NaN is
rejected rather than silently accepted.

Also cover the two checks the suite could not previously kill: the
whole-string check in the float parser and the int32 range check.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-05 23:15:33 +00:00
Ettore Di Giacinto
4d280d6e6e feat(llama-cpp): add TTS request option parsing helper
Validates text and speaker reference presence and strictly parses the
top_k / top_p per-request params, in a header with no llama.cpp or gRPC
dependencies so the standalone C++ unit test gate picks it up.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-05 23:05:34 +00:00
Ettore Di Giacinto
3edeebb61f fix(config): do not read a TTS speaker-encoder mmproj as vision support
Qwen3-TTS on llama-cpp ships an mmproj holding the speaker encoder and
code predictor. VisionSupported() treated any non-empty MMProj as proof
of image input, so every such model would be advertised as vision-capable.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-05 22:58:38 +00:00
mudler's LocalAI [bot]
1271b97a46 docs(blog): cover the terminal agent in the 4.8 post (#11372)
docs(blog): cover the terminal agent, and fix the counts in the intro

The 4.8 post never mentions that `local-ai chat` stopped being a REPL
and became an agent (#11291): the nib harness compiled into the binary,
with tool use behind an approval gate, sub-agents, MCP servers, plugins
and skills, auto-configured against the local instance. It also ships a
shell integration script for zsh, bash and fish that binds Ctrl+Space.

That is one of the larger user-facing changes in the release and it was
missing from both the post and the release-notes highlights. Added a
section after 3D generation, including the breaking changes for anyone
who had habits around the old REPL: `/clear` is gone in favour of
`/compact`, and a model switch now keeps the conversation.

While in the intro, corrected the counts. The post said 374 pull
requests in twenty-one days, which was accurate when it was drafted on
the 4th but not once v4.8.0 was tagged on the 5th. The published release
notes say 386 in twenty-two days, and the intro now matches them rather
than contradicting them.

For the record, neither figure is exactly right: `git log --format=%s
v4.7.1..v4.8.0 | grep -cE '\(#[0-9]+\)$'` counts 388 squash-merged pull
requests, and 389 from v4.7.0. The notes were cut before the last few
landed. Matching the published notes was the priority here, since that
is the artifact everyone else quotes, and 386 is the number already in
circulation.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-05 16:46:27 +02:00
Ettore Di Giacinto
2c0e7c584d website: re-record the hero and gallery clips for the 4.8 UI
The two landing-page clips predated the v4.8.0 interface work (#11288,
#11305, #11307): the gallery clip showed the retired light-theme Install
Models table, and the hero clip toured the Nodes pages in a full browser
window while its caption promised a chat completion on CPU.

Both are re-recorded from a real local-ai built from v4.8.0, dark theme,
app chrome only:

- hero-ui.mp4: a chat completion on lfm2.5-1.2b-instruct streaming on
  CPU with the live tok/s meter, so the caption now matches the footage.
  The poster frame is regenerated from the new clip.
- gallery.mp4: the Discover rail and detail pane, the hardware
  recommendation lanes, the VRAM-by-context chart, and a real install
  with the live progress banner.

The hand-typed model count moves from 1,585 to 1,255 in the three places
it appears, matching the distinct-model count the recorded UI shows on
screen. The 3d-generation clip is untouched: the post-capture UI changes
do not show in its footage.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5
2026-08-05 12:46:44 +00:00
localai-org-maint-bot
fb444f917f gallery: add Agents-A1 4B variants (#11365)
Add the official Q4_K_M and Q8_0 GGUF builds with their matching vision projectors so the compact agentic model can be installed through LocalAI.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-05 09:40:37 +02:00
localai-org-maint-bot
a05a790021 fix(ci): emit verifiable backend signature bundles (#11366)
Cosign v2.4.1 does not select the Sigstore bundle format by default, while LocalAI's verifier only consumes OCI bundle referrers. Request the format explicitly for both registries and guard the producer contract with a shell regression test.

Document strict backend integrity configuration and release-tag identities for operators.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-05 09:39:35 +02:00
localai-org-maint-bot
9f62401fca feat(traces): show in-flight API requests (#11368)
Register JSON API exchanges before their handlers run so the traces dashboard can surface active work. Replace the live entry with the completed persisted record under the same ID, and clean it up if a handler panics.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-05 09:37:05 +02:00
Ettore Di Giacinto
4a5c5e51b7 website: say plainly that engines are swappable behind the same API
The runtime section described the small core and on-demand backends but
never stated the simple fact readers look for: one model can run on
llama.cpp while the next loads on vLLM, SGLang or MLX, behind the same
endpoint, and switching is one line in the model's config.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-fable-5
2026-08-05 07:33:08 +00:00
mudler's LocalAI [bot]
c61b6f2286 docs(blog): new DeepSeek and Laguna numbers, visuals, humanizer pass (#11369)
* docs(blog): new DeepSeek and Laguna numbers, visuals, humanizer pass

vllm.cpp master moved 26 commits past what the post was written against,
and two results changed enough to matter. Both came from the same lever:
staging weights device-resident at load instead of reading them from the
GGUF mmap over unified memory, which the GB10 reads about 20% slower per
GEMV than device memory.

- DeepSeek-V4-Flash against DwarfStar: 0.997x parity becomes 1.144x
  ahead, 18.69 vs 16.33 tok/s decode, same generated tokens.
- Laguna-XS-2.1 against vLLM: 87% becomes 1.03x, 44.46 vs 43.10 tok/s.
  New row in the scoreboard.

Adds three visuals. A chart of throughput against every reference engine,
which is worth having now that the spread is 0.976 to 1.144 rather than a
flat line at parity. The Activity page with four installs running, and the
model detail pane with all four pocket-35b variants. Both screenshots were
recaptured on 2026-08-04 because #11288, #11305, #11307 and #11222 had all
changed those pages since the earlier set.

llama.cpp is deliberately absent from the chart: its 1.18x is a prefill
ratio, and putting it on the same axis as throughput ratios would be
comparing two different measurements.

Also carries the media the release notes embed, since a GitHub release
body needs URLs that survive publishing and drag-and-drop has no CLI.
Supersedes #11364.

Humanizer pass on the prose. The post had collected five exactness idioms
in one section (token-for-token, byte-exact twice, byte-identical,
token-identical). One is precision, five is a tic, so the 27B row keeps
its "token-for-token identical" where identical output is the actual
claim and the rest say what they mean. That also fixed a hyphen in
predicate position ("is token-identical").

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

* docs(blog): redraw the benchmark chart as a branded card

The Flint bar chart was generic: default palette, no brand, and drawn
from zero, which made five ratios between 0.976 and 1.144 look like five
bars of roughly equal length.

Redrawn in the style of recorder-for-agents' render-card.sh cards, the
same shape as the vllm.cpp README GIF. Palette taken from the two logos
rather than invented (LocalAI navy #0E2632 and teal #469AAF, vllm.cpp
teal #3AB4CA), SVG generated by a small JS loop so the geometry is exact
at any scale, headless Chrome to PNG at 2x.

The substantive change is that bars now run from the 1.00 parity line
instead of from zero. Deviation is what the data is about, so DeepSeek's
+14.4% and MLX-LM's -2.4% are both legible, and the one row that is
behind is the one row in amber. Each bar carries its ratio and the raw
measurement under it.

Keeps the .html source next to the .png so the chart is editable later:
change a number, re-run render-card.sh.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-05 09:14:51 +02:00
localai-org-maint-bot
0332e9729f gallery: add LFM2.5 2.6B variants (#11351)
Add LiquidAI official Q4_K_M and Q8_0 GGUF builds with linked variant selection and documented generation defaults.

Assisted-by: Codex:gpt-5 [Hugging Face]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-05 01:37:33 +02:00
mudler's LocalAI [bot]
a8d310573e chore: ⬆️ Update mudler/vllm.cpp to 0757cac231ecd571a83c4fd2f50805c9251fc225 (#11352)
⬆️ Update mudler/vllm.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-05 01:37:09 +02:00
mudler's LocalAI [bot]
144baaa809 chore: ⬆️ Update ggml-org/whisper.cpp to 306c88f4d1286aec1bf96e544632897886af5501 (#11353)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-05 01:36:56 +02:00
mudler's LocalAI [bot]
86c2e9a273 chore: ⬆️ Update leejet/stable-diffusion.cpp to ea7f0c87cfe4c673263b4c201c596c7f1cbe2528 (#11354)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-05 01:36:41 +02:00
mudler's LocalAI [bot]
89995d7535 chore: ⬆️ Update 0xShug0/audio.cpp to 238ab6a9e321c17de8e120559f57efeedaeb1345 (#11355)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-05 01:36:26 +02:00
mudler's LocalAI [bot]
1f4ec3bdf8 chore: ⬆️ Update CrispStrobe/CrispASR to ec730908a418b6032f9e69ded6186d3f042a7747 (#11356)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-05 01:36:13 +02:00
mudler's LocalAI [bot]
1466aaa9f7 chore: ⬆️ Update antirez/ds4 to 6747e7718dd08f00b680d0c16231f2d59ec3747e (#11357)
⬆️ Update antirez/ds4

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-05 01:36:01 +02:00
mudler's LocalAI [bot]
e6712844ee chore: ⬆️ Update ikawrakow/ik_llama.cpp to 6b55d2c7504f482e7c8ec6cbf22a19f3778c522b (#11358)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-05 01:35:49 +02:00
mudler's LocalAI [bot]
b1d964ef7b chore(model-gallery): ⬆️ update checksum (#11359)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-05 01:35:37 +02:00
mudler's LocalAI [bot]
0d342c61d8 docs(backends): correct the vllm-cpp description in the gallery (#11363)
This is the text users read in the backends list and the gallery, and it
was the last place still describing vllm.cpp as "a from-scratch C++20
port of vLLM created and maintained by the LocalAI team" with no
indication of maturity.

Three corrections, matching the v4.8 release notes and blog post:

- It leads with ALPHA. These are alpha development builds and llama-cpp
  stays the recommendation for production, which is the single most
  useful thing to know before clicking install.
- It is maintained by the LocalAI team but developed in its own
  repository and usable without LocalAI. vLLM is named for what it
  actually is, the reference implementation that output is checked
  against and benchmarked against, rather than just the thing that was
  ported.
- It records the featureset that has grown past vLLM: GGUF loading,
  speculative decoding and KV offload, alongside the architecture and
  hardware coverage that were already listed.

Also notes that the project is expected to be renamed, with the new name
still to be decided, so anyone who installs it now is not surprised
later.

vllm-cpp-development inherits all of this through the YAML anchor, so
both entries are covered by the one edit. Verified the file still parses
and that both entries carry the new text.


Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-05 01:35:21 +02:00
mudler's LocalAI [bot]
4fec33966a docs(blog): final figures for the 4.8 post, and the MLX provider (#11362)
* docs(blog): final figures for the 4.8 post, and the MLX provider

The cycle closed at 374 PRs over twenty-one days, not the 321 over
eighteen the post was written against. Corrects the summary, the opening
line, the contributor count and the gallery total, and moves the date to
the day the release is cut.

Adds the MLX GEMM provider (#11137), which merged after the post was
written and is the one number an Apple Silicon reader wants: 1.54x to
2.19x on an M4 with time to first token roughly halving, both arms
toggled on one binary. The +/-10% caveat travels with the table rather
than being left in the PR.

Two lines edited against the no-ai-slop skill while I was in the file,
the same pass #11324 ran over the engines post:

- The opener balanced two clauses across a colon and closed on "without
  lying to you", which is the built-to-be-quoted shape readers picked
  out of the HN thread. It is a flat statement now.
- "This is a new modality rather than a new backend under an existing
  one" is a binary contrast that says nothing the next clause does not.

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

* docs(blog): call vllm.cpp alpha, and finish the no-ai-slop pass

vllm.cpp is not a released backend and the post read like it was. The
old wording buried the caveat in a block quote at the end of the section
and still said "first release of a young engine". It now says plainly,
before the caveat can be skipped, that these are alpha development
builds, that shipping them in 4.8 is about letting people try the thing
rather than recommending it, and that llama-cpp stays the default.

Also completes the no-ai-slop pass I had only half run. Counting the
lines built to be quoted, headings and section endings included, the post
is in reasonable shape: long flat informational stretches, tables
followed by a plain finding, headings that are labels rather than
epigram-verdicts. Three patterns survived, each one an item in eval.md:

- "and inverts that:" set the usual shape against ours across a colon.
  The sentence works without the frame.
- "Two things were conflated there: a signal, which needs one line, and
  the detail, which needs somewhere to put it" is a role-assignment pair.
  Says what happens instead.
- "The maturity statement from the release notes is worth repeating in
  full" is throat-clearing in front of a quote, and the quote is gone.

Left the rest alone. Minimum effective edit, not a rewrite.

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

* docs(blog): present vllm.cpp as a community project, with its own numbers

The post described vllm.cpp as "a from-scratch port of vLLM, written and
maintained by the LocalAI team". Two things wrong with that. It is a
community project, and it has stopped being only a port: it loads GGUF,
runs on CPU, Metal and Vulkan, ships speculative decoding and KV offload,
and its benchmark page measures against llama.cpp, MLX-LM and DwarfStar
as well as vLLM, because those are the engines it competes with on that
hardware.

vLLM's role is now stated for what it is, the reference implementation.
Correctness is checked against it and the scoreboard is kept against it.
Also flags that the name will probably change, since it is drifting far
enough that vllm.cpp will eventually mislead.

Adds real numbers from the project's own docs/BENCHMARKS.md rather than
adjectives: 1.045x vLLM at concurrency 1 on Qwen3.6-27B NVFP4 with
token-for-token identical output, 1.010x and 1.013x at c16 and c32 on the
35B MoE and behind below that, prefill 1.18x over llama.cpp on CPU
aarch64, 97.6% of MLX-LM warm total on an M4. Upstream's own caution
travels with them: it treats c2 through c32 as ties because its noise
band is 0.5% and those margins are 0.7% to 1.7%.

Every figure was checked against ~/_git/vllm.cpp/docs/BENCHMARKS.md
rather than restated from memory. The heading is marked alpha to match
the section body.

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

* docs(blog): say who maintains vllm.cpp, and add the DeepSeek Flash result

Two corrections to the previous commit.

"A community project" says nothing and was not quite true either. The
LocalAI team maintains vllm.cpp. Community-first is the intent, not a
description, so it now says that and says what backs it: its own
repository, its own docs, benchmark record and issue tracker, and it runs
without LocalAI anywhere in the picture.

Adds the DeepSeek-V4-Flash result, which makes the divergence point
better than any of the prose around it. That model does not run on vLLM
on a single GB10: every vLLM-loadable checkpoint is 156 GB or more
against a 119 GiB unified pool, and the only quant that fits is an
extreme-low-bit GGUF that vLLM cannot load. vllm.cpp reads GGUF and runs
it at 16.28 tok/s against ds4's 16.33, a parity result. Also notes MTP
speculative decoding, token-identical to vLLM's and about 4% faster at
concurrency 1.

Both figures checked against ~/_git/vllm.cpp/docs/BENCHMARKS.md.

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

* docs(blog): lead the DeepSeek result with what we run, not with what vLLM cannot

The previous version opened on "that model does not run on vLLM on a
single GB10 at all". Wrong emphasis twice over: it makes a strong
negative claim about another project the headline, and it buries the
actual result, which is that vllm.cpp runs DeepSeek-V4-Flash at roughly
2-bit (IQ2_XXS mixed, about 80 GB) on a single DGX Spark and decodes at
16.28 tok/s against DwarfStar's 16.33.

The size constraint is still there, stated as the reason the quant is
what it is rather than as a point about vLLM: at 300B+ total parameters
even a 4-bit checkpoint is 156 GB or more, so a 2-bit GGUF is what fits
the Spark's 119 GiB unified pool.

The table row now names the quant and the box (IQ2_XXS, one DGX Spark)
instead of just "GGUF, GB10", since that is the part a reader with a
Spark wants.

Figures unchanged and still from ~/_git/vllm.cpp/docs/BENCHMARKS.md.

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

* docs(blog): say the new name is undecided

"The name will probably change at some point" invited the obvious
question. It now says the rename is expected and the name is still to be
decided, which is the actual state.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-05 01:23:49 +02:00
52 changed files with 2695 additions and 127 deletions

View File

@@ -16,8 +16,7 @@ side (`pkg/oci/cosignverify` plus the gallery YAML).
per-arch manifest before checking signatures.
- **Storage:** Signatures are written as OCI 1.1 referrers
(`--registry-referrers-mode=oci-1-1`) in the new Sigstore bundle format
(current cosign releases do this by default; no `--new-bundle-format`
flag). No `:sha256-<hex>.sig` tag clutter.
(`--new-bundle-format`). No `:sha256-<hex>.sig` tag clutter.
- **Consumer:** `pkg/oci/cosignverify` discovers the bundle via the
referrers API, hands it to `sigstore-go`, and verifies it against the
policy declared in the gallery YAML (`Gallery.Verification`).
@@ -34,14 +33,15 @@ to sign. The job needs:
- `permissions: { id-token: write, contents: read }` at the job level so
the runner can exchange its GitHub OIDC token for a Fulcio cert.
- `sigstore/cosign-installer@v3` step (current cosign releases already
default to the new bundle format).
- `sigstore/cosign-installer@v3` step (the pinned cosign v2 release needs
`--new-bundle-format` explicitly).
- After each `docker buildx imagetools create`, resolve the resulting
list digest with `docker buildx imagetools inspect <tag> --format
'{{.Manifest.Digest}}'` and sign:
```sh
cosign sign --yes --recursive \
--new-bundle-format \
--registry-referrers-mode=oci-1-1 \
"${REGISTRY_REPO}@${DIGEST}"
```
@@ -70,7 +70,7 @@ entry (`backend/index.yaml`):
url: github:mudler/LocalAI/backend/index.yaml@master
verification:
issuer: "https://token.actions.githubusercontent.com"
identity_regex: "^https://github\\.com/mudler/LocalAI/\\.github/workflows/backend_merge\\.yml@refs/heads/master$"
identity_regex: "^https://github\\.com/mudler/LocalAI/\\.github/workflows/backend_merge\\.yml@refs/(heads/master|tags/.+)$"
# Optional revocation cutoff; advance during incident response.
# not_before: "2026-06-01T00:00:00Z"
```

View File

@@ -71,8 +71,8 @@ jobs:
# cosign signs each pushed manifest list with --recursive so the
# index and every per-arch entry get an attached Sigstore bundle.
# Recent cosign releases always emit the new bundle format, so
# there's no extra CLI flag to opt into it.
# The pinned cosign v2 release needs --new-bundle-format explicitly;
# the verifier only consumes OCI 1.1 Sigstore bundle referrers.
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3
@@ -159,6 +159,7 @@ jobs:
# manifest before checking signatures need the per-arch
# signatures, not just the list-level one.
cosign sign --yes --recursive \
--new-bundle-format \
--registry-referrers-mode=oci-1-1 \
"quay.io/go-skynet/local-ai-backends@${digest}"
@@ -185,6 +186,7 @@ jobs:
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')
cosign sign --yes --recursive \
--new-bundle-format \
--registry-referrers-mode=oci-1-1 \
"localai/localai-backends@${digest}"

View File

@@ -9,7 +9,7 @@
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
# rebuild and so the bump bot can see the pin.
AUDIO_CPP_VERSION?=4e3aea2fd99aeaa5924e71c51eb2793846045332
AUDIO_CPP_VERSION?=238ab6a9e321c17de8e120559f57efeedaeb1345
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))

View File

@@ -42,6 +42,7 @@ define bonsai-build
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build purge
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
$(info $(GREEN)I bonsai build info:$(1)$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build llama.cpp
@@ -79,6 +80,7 @@ bonsai-cpu-all:
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build purge
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
$(info $(GREEN)I bonsai build info:cpu-all-variants$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BONSAI_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build llama.cpp

View File

@@ -1,10 +1,10 @@
# ds4 backend Makefile.
#
# Upstream pin lives below as DS4_VERSION?=b7e9f0091139999b6c070a57590c447c5741da5c
# Upstream pin lives below as DS4_VERSION?=6747e7718dd08f00b680d0c16231f2d59ec3747e
# (.github/bump_deps.sh) can find and update it - matches the
# llama-cpp / ik-llama-cpp / turboquant convention.
DS4_VERSION?=b7e9f0091139999b6c070a57590c447c5741da5c
DS4_VERSION?=6747e7718dd08f00b680d0c16231f2d59ec3747e
DS4_REPO?=https://github.com/antirez/ds4
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))

View File

@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=60389410a1ff01f9d37dcc6261db33b3183bdea2
IK_LLAMA_VERSION?=6b55d2c7504f482e7c8ec6cbf22a19f3778c522b
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=

View File

@@ -115,4 +115,9 @@ if(LLAMA_GRPC_BUILD_TESTS)
target_include_directories(passthrough_options_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(passthrough_options_test PRIVATE cxx_std_17)
add_test(NAME passthrough_options_test COMMAND passthrough_options_test)
add_executable(tts_request_options_test tts_request_options_test.cpp tts_request_options.h)
target_include_directories(tts_request_options_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(tts_request_options_test PRIVATE cxx_std_17)
add_test(NAME tts_request_options_test COMMAND tts_request_options_test)
endif()

View File

@@ -1,5 +1,5 @@
LLAMA_VERSION?=221f0f6356efe2260023208365705ec5d5a7c8f5
LLAMA_VERSION?=9de0fcf2b3e587a43f293d9a2b6ec0a32991f768
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Mark a copied gRPC server as targeting a llama.cpp fork that does not carry
# LocalAI's SERVER_TASK_TYPE_TTS patch. The RPCs remain present in the shared
# protobuf service, but respond with UNIMPLEMENTED instead of referencing
# server task types and mtmd gen-audio APIs absent from those forks.
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 <grpc-server.cpp>" >&2
exit 2
fi
SRC=$1
if [[ ! -f "$SRC" ]]; then
echo "grpc-server.cpp not found at $SRC" >&2
exit 2
fi
if grep -q '^#define LOCALAI_LLAMA_CPP_NO_TTS_TASK' "$SRC"; then
echo "==> $SRC already disables the LocalAI TTS task, skipping"
exit 0
fi
awk '
!done && /^#include/ {
print "#define LOCALAI_LLAMA_CPP_NO_TTS_TASK 1"
print "// ^ injected by disable-tts-task.sh for an unpatched llama.cpp fork"
print ""
done = 1
}
{ print }
END {
if (!done) {
print "disable-tts-task.sh: no #include anchor found" > "/dev/stderr"
exit 1
}
}
' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> LocalAI TTS task disabled in $SRC"

View File

@@ -55,6 +55,7 @@
#include "llama_compat.h" // fork-skew switches, generated by prepare.sh
#include "message_content.h"
#include "passthrough_options.h"
#include "tts_request_options.h"
#include <getopt.h>
#include <grpcpp/ext/proto_server_reflection_plugin.h>
#include <grpcpp/grpcpp.h>
@@ -65,6 +66,7 @@
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iterator>
#include <list>
@@ -233,7 +235,15 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
data["typical_p"] = predict->typicalp();
data["temperature"] = predict->temperature();
data["repeat_last_n"] = predict->repeat();
data["repeat_penalty"] = predict->penalty();
// PredictOptions.Penalty is a bare proto float, so a caller that names no
// repetition penalty sends 0 rather than omitting the field. Since
// llama.cpp 9de0fcf2b, common_sampler_init() rejects a non-positive
// penalty_repeat outright (it would divide logits by zero), which turned
// every such request into "Failed to initialize samplers". Treat 0 as
// "unset" and leave llama.cpp's own neutral default in place.
if (predict->penalty() > 0.0f) {
data["repeat_penalty"] = predict->penalty();
}
data["frequency_penalty"] = predict->frequencypenalty();
data["presence_penalty"] = predict->presencepenalty();
data["mirostat"] = predict->mirostat();
@@ -1445,6 +1455,26 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
}
#ifndef LOCALAI_LLAMA_CPP_NO_TTS_TASK
// MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM hands back raw float32 samples, but the
// WAV header core/backend/tts.go builds around the streamed chunks announces
// 16-bit samples, so the wire has to carry s16 or the client decodes floats as
// integers and hears noise. The scaling matches write_wav16() in
// tools/mtmd/mtmd-helper-gen.cpp, which is what the non-streaming path writes.
static std::string tts_pcm_f32_to_s16(const std::string & samples) {
const size_t n = samples.size() / sizeof(float);
std::string out;
out.resize(n * sizeof(int16_t));
for (size_t i = 0; i < n; i++) {
float v = 0.0f;
std::memcpy(&v, samples.data() + i * sizeof(float), sizeof(float));
const int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, v)) * 32767.0f);
std::memcpy(&out[i * sizeof(int16_t)], &s, sizeof(int16_t));
}
return out;
}
#endif
// GRPC Server start
class BackendServiceImpl final : public backend::Backend::Service {
private:
@@ -2089,15 +2119,23 @@ public:
task.tokens = std::move(inputs[i]);
#ifdef LOCALAI_HAS_SERVER_SCHEMA
// The schema evaluator no longer takes the per-slot n_ctx: upstream
// dropped the parameter and server-schema stopped consulting n_ctx at
// all, leaving the context bound to the slot. Forks that predate the
// server-schema split still expect it, so only this branch loses it.
task.params = server_schema::eval_llama_cmpl_schema(
ctx_server.impl->vocab,
params_base,
ctx_server.get_meta().logit_bias_eog,
data);
#else
task.params = server_task::params_from_json_cmpl(
#endif
ctx_server.impl->vocab,
params_base,
ctx_server.get_meta().slot_n_ctx,
ctx_server.get_meta().logit_bias_eog,
data);
#endif
task.id_slot = json_value(data, "id_slot", -1);
// OAI-compat: enable autoparser (PEG-based chat parsing) so that
@@ -2659,15 +2697,23 @@ public:
task.tokens = std::move(inputs[i]);
#ifdef LOCALAI_HAS_SERVER_SCHEMA
// The schema evaluator no longer takes the per-slot n_ctx: upstream
// dropped the parameter and server-schema stopped consulting n_ctx at
// all, leaving the context bound to the slot. Forks that predate the
// server-schema split still expect it, so only this branch loses it.
task.params = server_schema::eval_llama_cmpl_schema(
ctx_server.impl->vocab,
params_base,
ctx_server.get_meta().logit_bias_eog,
data);
#else
task.params = server_task::params_from_json_cmpl(
#endif
ctx_server.impl->vocab,
params_base,
ctx_server.get_meta().slot_n_ctx,
ctx_server.get_meta().logit_bias_eog,
data);
#endif
task.id_slot = json_value(data, "id_slot", -1);
// OAI-compat: enable autoparser (PEG-based chat parsing) so that
@@ -2994,6 +3040,229 @@ public:
return grpc::Status::OK;
}
#ifndef LOCALAI_LLAMA_CPP_NO_TTS_TASK
// Builds the shared TTS task from a request. Returns a non-OK status and
// leaves `task` untouched when the request is malformed or the loaded model
// cannot synthesise audio.
grpc::Status prepareTTSTask(const backend::TTSRequest* request, bool stream, server_task & task) {
if (!ctx_server.get_meta().has_cap_tts) {
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
"the loaded model does not support audio generation (no gen-audio mmproj)");
}
std::map<std::string, std::string> params(request->params().begin(), request->params().end());
const auto opts = llama_grpc::parse_tts_request_options(
request->text(),
request->voice(),
request->has_language() ? request->language() : std::string(),
params);
if (!opts.ok) {
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, opts.error);
}
auto wrapper = mtmd_helper_bitmap_init_from_file(ctx_server.impl->mctx, opts.voice_path.c_str(), false);
if (!wrapper.bitmap) {
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
"failed to read speaker reference audio: " + opts.voice_path);
}
task.tts_inp.set_prompt(opts.text);
// core/backend/tts.go always sets TTSRequest.language, so has_language()
// is true even when the caller named no language and the string is empty.
// gen_audio::inp::get() already maps a stored blank to nullptr, so this
// guard is behavior-preserving rather than behavior-fixing. It is kept
// so the "unset" intent is visible at the call site instead of resting
// on a detail of the helper.
if (!opts.language.empty()) {
task.tts_inp.set_lang(opts.language);
}
task.tts_inp.set_speaker_ref(mtmd::bitmap_ptr(wrapper.bitmap));
task.tts_inp.data.top_k = opts.top_k;
task.tts_inp.data.top_p = opts.top_p;
task.tts_inp.data.stream = stream;
task.tts_inp.data.out_type = stream
? MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM // Go prepends its own WAV header, see core/backend/tts.go
: MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
task.params.stream = stream;
// -1 keeps upstream's 512-frame default. The model does not always emit
// its codec EOS, so a short input can otherwise generate the full cap.
task.params.n_predict = opts.max_frames > 0 ? opts.max_frames : -1;
task.params.sampling = params_base.sampling;
// Both values mirror upstream's draft POST /tts handler. Note that the
// pair is INERT at this pin: llama_sampler_init_penalties() clamps
// penalty_last_n with std::max(penalty_last_n, 0), so -1 means "off",
// not "the whole generation", and the penalty sampler is then built
// disabled. No repetition penalty is actually applied.
//
// That is deliberate. Dropping the second line lets the sampling
// default of 64 apply and genuinely engages the 1.05 penalty, which was
// measured here against the model's habit of never emitting its codec
// EOS and running to the frame cap: 0 of 15 short requests ran away
// with the penalty inert, 1 of 15 with it active over the last 64
// tokens. It does not fix the runaway, so the line stays for parity
// with the draft. Use max_frames to bound the output instead.
task.params.sampling.penalty_repeat = 1.05f;
task.params.sampling.penalty_last_n = -1;
if (opts.top_k > 0) {
task.params.sampling.top_k = opts.top_k;
}
if (opts.top_p > 0) {
task.params.sampling.top_p = opts.top_p;
}
return grpc::Status::OK;
}
grpc::Status TTS(ServerContext* context, const backend::TTSRequest* request, backend::Result* result) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
if (request->dst().empty()) {
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "dst must name an output file path");
}
server_task task(SERVER_TASK_TYPE_TTS);
auto prepared = prepareTTSTask(request, /* stream= */ false, task);
if (!prepared.ok()) return prepared;
auto rd = ctx_server.get_response_reader();
task.id = rd.get_new_id();
rd.post_task(std::move(task));
auto should_stop = [context]() { return context->IsCancelled(); };
std::string audio;
while (true) {
auto res = rd.next(should_stop);
if (!res) {
return grpc::Status(grpc::StatusCode::CANCELLED, "TTS request cancelled");
}
if (res->is_error()) {
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
}
auto * tts_res = dynamic_cast<server_task_result_tts *>(res.get());
if (tts_res == nullptr) {
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a TTS task");
}
audio.append(tts_res->audio);
if (tts_res->final) {
break;
}
}
std::ofstream out(request->dst(), std::ios::binary | std::ios::trunc);
if (!out) {
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to open output file: " + request->dst());
}
out.write(audio.data(), (std::streamsize) audio.size());
if (!out) {
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to write output file: " + request->dst());
}
// Buffered data is flushed here, so a full disk or a failing device can
// surface for the first time on close. Reporting success then would
// leave a truncated file behind under the name the caller will read.
out.close();
if (!out) {
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to close output file: " + request->dst());
}
result->set_success(true);
result->set_message("TTS audio generated");
return grpc::Status::OK;
}
grpc::Status TTSStream(ServerContext* context, const backend::TTSRequest* request, grpc::ServerWriter<backend::Reply>* writer) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
auto identity = checkModelIdentity(request);
if (!identity.ok()) return identity;
if (params_base.model.path.empty()) {
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
server_task task(SERVER_TASK_TYPE_TTS);
auto prepared = prepareTTSTask(request, /* stream= */ true, task);
if (!prepared.ok()) return prepared;
auto rd = ctx_server.get_response_reader();
task.id = rd.get_new_id();
rd.post_task(std::move(task));
auto should_stop = [context]() { return context->IsCancelled(); };
// core/backend/tts.go:ModelTTSStream builds the WAV header itself from
// the sample rate in the first reply's Message, then concatenates every
// Reply.Audio verbatim. So the rate goes out once, up front, and the
// chunks stay raw PCM.
//
// Send it before draining rather than off the first audio result: a
// chunk needs a whole 72-frame window, about 5.8 s of audio and far
// longer in wall time on CPU, and the Go side cannot emit the WAV
// header until this reply lands. Waiting would hold the client at zero
// bytes for that entire stretch. The rate is a property of the loaded
// model, available synchronously, so there is nothing to wait for.
{
backend::Reply header;
const json info = { {"sample_rate", mtmd_gen_audio_get_info(ctx_server.impl->mctx).sample_rate} };
header.set_message(info.dump());
if (!writer->Write(header)) {
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the TTS stream");
}
}
while (true) {
auto res = rd.next(should_stop);
if (!res) {
return grpc::Status(grpc::StatusCode::CANCELLED, "TTS request cancelled");
}
if (res->is_error()) {
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
}
auto * tts_res = dynamic_cast<server_task_result_tts *>(res.get());
if (tts_res == nullptr) {
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a TTS task");
}
if (!tts_res->audio.empty()) {
backend::Reply chunk;
chunk.set_audio(tts_pcm_f32_to_s16(tts_res->audio));
if (!writer->Write(chunk)) {
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the TTS stream");
}
}
if (tts_res->final) {
break;
}
}
return grpc::Status::OK;
}
#else
grpc::Status TTS(ServerContext* context, const backend::TTSRequest* request, backend::Result* result) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
(void) request;
(void) result;
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
"TTS is unavailable in this llama.cpp fork backend");
}
grpc::Status TTSStream(ServerContext* context, const backend::TTSRequest* request, grpc::ServerWriter<backend::Reply>* writer) override {
auto auth = checkAuth(context);
if (!auth.ok()) return auth;
(void) request;
(void) writer;
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
"TTSStream is unavailable in this llama.cpp fork backend");
}
#endif
// Score returns the model's joint log-probability of each candidate
// continuation given a shared prompt.
//

View File

@@ -0,0 +1,895 @@
# Carries the server-side half of ggml-org/llama.cpp#26603 (POST /tts), which
# adds SERVER_TASK_TYPE_TTS to the shared server_context. LocalAI's gRPC
# adapter rides on that same server_context and cannot drive the mtmd gen-audio
# loop directly: server_context owns the llama_context and schedules slots on
# its own thread, so a handler calling llama_decode would corrupt state.
#
# The upstream HTTP route (tools/server/server.cpp) and the README/CLI hunks are
# intentionally excluded - LocalAI does not serve llama.cpp's HTTP surface.
#
# Rebased against LLAMA_VERSION 9de0fcf2b with 0001-add-server-task-type-score.patch
# already applied: the SERVER_TASK_TYPE_TTS case in the tokenize switch lands
# after the SCORE case that patch adds, so this patch must stay second in ls order.
#
# Three fixes on top of the draft, all ours, all candidates to send back to #26603:
#
# 1. Its lone SRV_WRN call passes only a format string, but the macro expands
# __VA_ARGS__ without the GNU comma-elision extension, so the expansion ends
# in a trailing comma and does not compile. The "%s" wrapper added here is the
# same idiom upstream already uses for its other argument-less SRV_WRN calls;
# drop it if the draft fixes the call before it merges.
#
# 2. The slot.prompt_clear() added to the SERVER_TASK_TYPE_TTS branch of
# launch_slot_with_task. Without it only the FIRST TTS request in a server
# process succeeds and every later one fails instantly in step_prompt. TTS
# slots never enter the shared batch (pre_decode() returns early for them and
# process_tts_slots() drives them), so they skip the prompt-cache bookkeeping
# that would otherwise clear the sequence between requests; meanwhile the
# gen-audio pipeline always decodes from position 0 and its reset() only
# clears host-side buffers, never the KV cache. The result is that request 2
# decodes over request 1's tokens. This one is a genuine defect in the draft,
# not a LocalAI integration artifact, and should be reported upstream.
#
# 3. The is_supported() guard in server_slot::tts_ctx::reset(). The draft calls
# mtmd_helper_gen_audio_reset() unconditionally, but it only ever init()s the
# gen-audio pipeline for models that carry a gen-audio mmproj, so the handle
# stays null for every ordinary model. Upstream's implementation
# (tools/mtmd/mtmd-helper-gen.cpp) reads ctx->pipeline before it null-checks
# anything, so the slot-init call to server_slot::reset() segfaults the
# process on any non-TTS model - which is every chat model LocalAI loads
# through this backend. The missing null check on mtmd_helper_gen_audio_*
# is upstream's bug, so carry this guard until they add one, even if the
# draft's own reset() call is fixed.
#
# REMOVE THIS PATCH once #26603 merges upstream and LLAMA_VERSION is bumped past
# the merge commit. It exists only because that PR is still a draft. If it merges
# without fixes 2 and 3 above, those hunks still need carrying.
diff --git a/tools/mtmd/mtmd-helper-gen.cpp b/tools/mtmd/mtmd-helper-gen.cpp
index b52dc8e..fd9d6ca 100644
--- a/tools/mtmd/mtmd-helper-gen.cpp
+++ b/tools/mtmd/mtmd-helper-gen.cpp
@@ -48,29 +48,38 @@ static llama_token find_special_token(const llama_vocab * vocab, const std::stri
return LLAMA_TOKEN_NULL;
}
+static void put_bytes(std::vector<char> & buf, const void * p, size_t n) {
+ const char * c = (const char *) p;
+ buf.insert(buf.end(), c, c + n);
+}
+
+// data_sz == UINT32_MAX writes the "unknown length" sentinel (streaming), same as ffmpeg does on a pipe
+static void write_wav16_header(std::vector<char> & buf, uint32_t data_sz, int32_t rate) {
+ const uint32_t riff_sz = data_sz == UINT32_MAX ? UINT32_MAX : 36 + data_sz;
+ const uint32_t fmt_sz = 16, byte_rate = (uint32_t) rate * 2;
+ const uint16_t fmt = 1, ch = 1, align = 2, bits = 16;
+ const uint32_t rate32 = (uint32_t) rate;
+ put_bytes(buf, "RIFF", 4); put_bytes(buf, &riff_sz, 4); put_bytes(buf, "WAVE", 4);
+ put_bytes(buf, "fmt ", 4); put_bytes(buf, &fmt_sz, 4);
+ put_bytes(buf, &fmt, 2); put_bytes(buf, &ch, 2); put_bytes(buf, &rate32, 4);
+ put_bytes(buf, &byte_rate, 4); put_bytes(buf, &align, 2); put_bytes(buf, &bits, 2);
+ put_bytes(buf, "data", 4); put_bytes(buf, &data_sz, 4);
+}
+
+static void append_wav16_pcm(std::vector<char> & buf, const float * pcm, size_t n) {
+ for (size_t i = 0; i < n; i++) {
+ int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, pcm[i])) * 32767.0f);
+ put_bytes(buf, &s, 2);
+ }
+}
+
static bool write_wav16(std::vector<char> & buf, const std::vector<float> & pcm, int32_t rate) {
// RIFF chunk sizes are 32-bit; refuse to emit a file with a truncated header
if (pcm.size() > ((size_t) UINT32_MAX - 36) / 2) {
return false;
}
- const uint32_t data_sz = (uint32_t) (pcm.size() * 2);
- const uint32_t riff_sz = 36 + data_sz;
- const uint32_t fmt_sz = 16, byte_rate = (uint32_t) rate * 2;
- const uint16_t fmt = 1, ch = 1, align = 2, bits = 16;
- const uint32_t rate32 = (uint32_t) rate;
- auto put = [&](const void * p, size_t n) {
- const char * c = (const char *) p;
- buf.insert(buf.end(), c, c + n);
- };
- put("RIFF", 4); put(&riff_sz, 4); put("WAVE", 4);
- put("fmt ", 4); put(&fmt_sz, 4);
- put(&fmt, 2); put(&ch, 2); put(&rate32, 4);
- put(&byte_rate, 4); put(&align, 2); put(&bits, 2);
- put("data", 4); put(&data_sz, 4);
- for (float v : pcm) {
- int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, v)) * 32767.0f);
- put(&s, 2);
- }
+ write_wav16_header(buf, (uint32_t) (pcm.size() * 2), rate);
+ append_wav16_pcm(buf, pcm.data(), pcm.size());
return true;
}
@@ -89,6 +98,8 @@ public:
// those read what they need from h_state_in instead
virtual int32_t step_gen(llama_token sampled, const float * h_state_in, const float ** h_state_out) = 0;
virtual int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) = 0;
+ // forces any buffered codes through code2wav now, regardless of window_frames
+ virtual int32_t flush() = 0;
protected:
llama_context * lctx;
@@ -119,6 +130,9 @@ public:
prompt_batch.reset();
n_prompt = 0;
prompt_pos = 0;
+ stream = false;
+ pcm_sent = 0;
+ wav_header_sent = false;
}
int32_t set_input(const mtmd_helper_gen_audio_inp * inp) override {
@@ -204,6 +218,7 @@ public:
top_k = inp->top_k > 0 ? inp->top_k : 50;
top_p = inp->top_p > 0 ? inp->top_p : 1.0f;
out_type = inp->out_type;
+ stream = inp->stream;
// the text stream keeps flowing during generation: after frame k, the input adds
// trailing text row k on top of the codes embedding, then tts_eos, then tts_pad
@@ -289,31 +304,60 @@ public:
}
int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) override {
- if (!flush_gen_wav()) {
- return 1;
+ *out_sample_rate = info.sample_rate;
+
+ if (!stream) {
+ // one-shot call: force out whatever's left, regardless of window_frames
+ if (!flush_gen_wav()) {
+ return 1;
+ }
+ if (out_n_samples) {
+ *out_n_samples = (int64_t) audio_pcm.size();
+ }
+ if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
+ *out_data = (const char *) audio_pcm.data();
+ *out_data_len = audio_pcm.size() * sizeof(float);
+ return 0;
+ }
+ out_buf.clear();
+ if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
+ LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
+ return 1;
+ }
+ *out_data = out_buf.data();
+ *out_data_len = out_buf.size();
+ return 0;
}
- *out_sample_rate = info.sample_rate;
+ // streaming: only return audio produced since the previous call
+ const size_t n_new = audio_pcm.size() - pcm_sent;
if (out_n_samples) {
- *out_n_samples = (int64_t) audio_pcm.size();
+ *out_n_samples = (int64_t) n_new;
}
if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
- *out_data = (const char *) audio_pcm.data();
- *out_data_len = audio_pcm.size() * sizeof(float);
+ *out_data = (const char *) (audio_pcm.data() + pcm_sent);
+ *out_data_len = n_new * sizeof(float);
+ pcm_sent = audio_pcm.size();
return 0;
}
out_buf.clear();
- if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
- LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
- return 1;
+ if (!wav_header_sent) {
+ write_wav16_header(out_buf, UINT32_MAX, info.sample_rate);
+ wav_header_sent = true;
}
+ append_wav16_pcm(out_buf, audio_pcm.data() + pcm_sent, n_new);
+ pcm_sent = audio_pcm.size();
*out_data = out_buf.data();
*out_data_len = out_buf.size();
return 0;
}
+ int32_t flush() override {
+ return flush_gen_wav() ? 0 : 1;
+ }
+
private:
bool ensure_cache() {
if (specials_ok) {
@@ -357,7 +401,7 @@ private:
LOG_ERR("mtmd_helper_gen_audio: mmproj has no speaker/audio encoder\n");
return false;
}
- const std::string marker = mtmd_default_marker();
+ const std::string marker = mtmd_get_marker(mctx);
mtmd_input_text text{ marker.c_str(), marker.size(), false, true };
mtmd_input_chunks * chunks = mtmd_input_chunks_init();
const mtmd_bitmap * bptr = bitmap;
@@ -442,6 +486,9 @@ private:
std::vector<float> h_state_buf;
mtmd_helper_gen_audio_outtype out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
std::vector<char> out_buf;
+ bool stream = false;
+ size_t pcm_sent = 0; // samples already returned by get_output()
+ bool wav_header_sent = false;
};
static std::unique_ptr<mtmd_gen_audio_pipeline> make_pipeline(llama_context * lctx, mtmd_context * mctx) {
@@ -473,6 +520,14 @@ void mtmd_helper_gen_audio_reset(mtmd_helper_gen_audio * ctx) {
}
}
+struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void) {
+ mtmd_helper_gen_audio_inp inp{};
+ inp.top_k = 50;
+ inp.top_p = 1.0f;
+ inp.out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
+ return inp;
+}
+
int32_t mtmd_helper_gen_audio_set_input(mtmd_helper_gen_audio * ctx, const mtmd_helper_gen_audio_inp * inp) {
if (!ctx->pipeline) {
LOG_ERR("mtmd_helper_gen_audio: unsupported or missing gen-audio pipeline\n");
@@ -503,3 +558,10 @@ int32_t mtmd_helper_gen_audio_get_output(mtmd_helper_gen_audio * ctx, int32_t *
}
return ctx->pipeline->get_output(out_sample_rate, out_data, out_data_len, out_n_samples);
}
+
+int32_t mtmd_helper_gen_audio_flush(mtmd_helper_gen_audio * ctx) {
+ if (!ctx->pipeline) {
+ return 1;
+ }
+ return ctx->pipeline->flush();
+}
diff --git a/tools/mtmd/mtmd-helper.h b/tools/mtmd/mtmd-helper.h
index 7e5cf9b..1f3ec01 100644
--- a/tools/mtmd/mtmd-helper.h
+++ b/tools/mtmd/mtmd-helper.h
@@ -175,6 +175,7 @@ enum mtmd_helper_gen_audio_outtype {
MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV, // WAV PCM 16-bit LE, mono
};
struct mtmd_helper_gen_audio_inp {
+ bool stream; // if true, output() must be called after each step_gen()
llama_seq_id seq_id;
const char * prompt;
@@ -189,6 +190,8 @@ struct mtmd_helper_gen_audio_inp {
enum mtmd_helper_gen_audio_outtype out_type;
};
+MTMD_API struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void);
+
MTMD_API mtmd_helper_gen_audio * mtmd_helper_gen_audio_init(
struct llama_context * lctx,
struct mtmd_context * mctx);
@@ -217,6 +220,8 @@ MTMD_API int32_t mtmd_helper_gen_audio_step_gen(
// out_data valid until next get_output() or reset() call
// out_n_samples (optional, can be NULL) receives the number of generated PCM samples
+// if inp->stream is true: returns only audio produced since the previous call, and
+// *out_data_len == 0 whenever a full window_frames batch hasn't accumulated yet
MTMD_API int32_t mtmd_helper_gen_audio_get_output(
mtmd_helper_gen_audio * ctx,
int32_t * out_sample_rate,
@@ -224,6 +229,10 @@ MTMD_API int32_t mtmd_helper_gen_audio_get_output(
size_t * out_data_len,
int64_t * out_n_samples);
+// forces any buffered codes through code2wav now, regardless of window_frames;
+// call once when generation has ended, before the last get_output() in stream mode
+MTMD_API int32_t mtmd_helper_gen_audio_flush(mtmd_helper_gen_audio * ctx);
+
#ifdef __cplusplus
} // extern "C"
#endif
@@ -250,8 +259,41 @@ struct mtmd_helper_gen_audio_deleter {
};
using gen_audio_ptr = std::unique_ptr<mtmd_helper_gen_audio, mtmd_helper_gen_audio_deleter>;
struct gen_audio {
+
+ // sub-struct, RAII wrapper for mtmd_helper_gen_audio_inp
+ struct inp {
+ mtmd_helper_gen_audio_inp data = mtmd_helper_gen_audio_inp_default();
+ std::string prompt_str;
+ std::string lang_str;
+ mtmd::bitmap_ptr speaker_ref_ptr;
+
+ inp() = default;
+ inp(inp &&) = default;
+ inp & operator=(inp &&) = default;
+ inp(const inp &) = delete;
+ inp & operator=(const inp &) = delete;
+
+ void set_prompt (std::string p) { prompt_str = std::move(p); }
+ void set_lang (std::string l) { lang_str = std::move(l); }
+ void set_speaker_ref(mtmd::bitmap_ptr bmp) { speaker_ref_ptr = std::move(bmp); }
+
+ // pointers are only valid as long as *this is alive
+ const mtmd_helper_gen_audio_inp * get() {
+ data.prompt = prompt_str.c_str();
+ data.prompt_len = prompt_str.size();
+ data.lang = lang_str.empty() ? nullptr : lang_str.c_str();
+ data.speaker_ref = speaker_ref_ptr.get();
+ return &data;
+ }
+ };
+
gen_audio_ptr ctx;
- gen_audio(struct llama_context * lctx, struct mtmd_context * mctx) : ctx(mtmd_helper_gen_audio_init(lctx, mctx)) {}
+ void init(struct llama_context * lctx, struct mtmd_context * mctx) {
+ ctx.reset(mtmd_helper_gen_audio_init(lctx, mctx));
+ }
+ bool valid() const {
+ return ctx.get() != nullptr;
+ }
void reset() {
mtmd_helper_gen_audio_reset(ctx.get());
}
@@ -267,6 +309,9 @@ struct gen_audio {
int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples = nullptr) {
return mtmd_helper_gen_audio_get_output(ctx.get(), out_sample_rate, out_data, out_data_len, out_n_samples);
}
+ int32_t flush() {
+ return mtmd_helper_gen_audio_flush(ctx.get());
+ }
};
} // namespace mtmd_helper
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
index 543dc43..4f01b1e 100644
--- a/tools/server/server-context.cpp
+++ b/tools/server/server-context.cpp
@@ -16,6 +16,7 @@
#include "speculative.h"
#include "mtmd.h"
#include "mtmd-helper.h"
+#include "base64.hpp"
#include <algorithm>
#include <cstddef>
@@ -43,7 +44,8 @@ static uint32_t server_n_outputs_max(const common_params & params) {
const uint32_t n_batch = params.n_batch;
if (params.embedding ||
- (params.pooling_type != LLAMA_POOLING_TYPE_UNSPECIFIED && params.pooling_type != LLAMA_POOLING_TYPE_NONE)) {
+ (params.pooling_type != LLAMA_POOLING_TYPE_UNSPECIFIED && params.pooling_type != LLAMA_POOLING_TYPE_NONE) ||
+ !params.mmproj.path.empty()) { // gen-audio (TTS) capability isn't known until the mmproj loads, size generously
return n_batch;
}
@@ -214,6 +216,30 @@ struct server_slot {
mtmd_context * mctx = nullptr;
mtmd::batch_ptr mbatch = nullptr;
+ struct tts_ctx {
+ mtmd_helper::gen_audio ctx;
+ const float * h_state;
+ llama_token sampled;
+ int32_t n_decoded;
+ bool is_supported() const {
+ return ctx.valid();
+ }
+ void reset() {
+ // mtmd_helper_gen_audio_reset() dereferences its argument before it
+ // null-checks the pipeline, and the pipeline is only allocated for
+ // models that actually carry a gen-audio mmproj. server_slot::reset()
+ // runs for every slot of every model, so without this guard any
+ // non-TTS model segfaults during slot initialization.
+ if (is_supported()) {
+ ctx.reset();
+ }
+ h_state = nullptr;
+ sampled = LLAMA_TOKEN_NULL;
+ n_decoded = 0;
+ }
+ };
+ tts_ctx tts;
+
// speculative decoding
common_speculative * spec;
@@ -403,6 +429,8 @@ struct server_slot {
// clear multimodal state
mbatch.reset();
+
+ tts.reset();
}
void init_sampler() const {
@@ -949,6 +977,14 @@ public:
mtmd_context * mctx = nullptr;
const llama_vocab * vocab = nullptr;
+ bool has_cap_tts() const {
+ return mctx != nullptr && mtmd_gen_audio_get_info(mctx).type != MTMD_GEN_AUDIO_TYPE_NONE;
+ }
+
+ bool has_cap_chat() const {
+ return mctx == nullptr || mtmd_helper_model_can_chat(ctx_tgt, mctx);
+ }
+
server_queue queue_tasks;
server_response queue_results;
@@ -1400,6 +1436,10 @@ private:
slot.mctx = mctx;
slot.prompt.tokens.has_mtmd = mctx != nullptr;
+ if (has_cap_tts()) {
+ slot.tts.ctx.init(ctx_tgt, mctx);
+ }
+
SLT_TRC(slot, "new slot, n_ctx = %d\n", slot.n_ctx);
slot.callback_on_release = [this](int id_slot) {
@@ -1853,6 +1893,28 @@ private:
SLT_DBG(slot, "launching slot : %s\n", safe_json_to_str(slot.to_json()).c_str());
+ if (task.type == SERVER_TASK_TYPE_TTS) {
+ GGML_ASSERT(has_cap_tts()); // should already checked in route handler
+ if (!slot.tts.is_supported()) {
+ slot.tts.ctx.init(ctx_tgt, slot.mctx);
+ }
+
+ // TTS slots never enter the shared batch: pre_decode() returns early for
+ // them and process_tts_slots() drives them instead, so they skip the
+ // prompt-cache bookkeeping that clears this sequence between requests.
+ // The gen-audio pipeline always decodes from position 0, and its own
+ // reset() only clears host-side buffers, so without this the second and
+ // later tasks on a slot decode over the previous request's tokens and
+ // step_prompt() fails immediately.
+ slot.prompt_clear();
+
+ task.tts_inp.data.seq_id = slot.id;
+ if (slot.tts.ctx.set_input(task.tts_inp.get()) != 0) {
+ send_error(task, "failed to process TTS prompt", ERROR_TYPE_SERVER);
+ return false;
+ }
+ }
+
// initialize samplers
if (task.need_sampling()) {
try {
@@ -1875,6 +1937,9 @@ private:
// TODO: getting pre sampling logits is not yet supported with backend sampling
backend_sampling &= !need_pre_sample_logits;
+ // TODO: check verify if this actually works with TTS
+ backend_sampling &= task.type != SERVER_TASK_TYPE_TTS;
+
// TODO: tmp until backend sampling is fully implemented
if (backend_sampling) {
llama_set_sampler(ctx_tgt, slot.id, common_sampler_get(slot.smpl.get()));
@@ -1890,9 +1955,13 @@ private:
slot.task = std::make_unique<const server_task>(std::move(task));
- slot.state = slot.task->is_child()
- ? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
- : SLOT_STATE_STARTED;
+ if (slot.task->type == SERVER_TASK_TYPE_TTS) {
+ slot.state = SLOT_STATE_PROCESSING_PROMPT;
+ } else {
+ slot.state = slot.task->is_child()
+ ? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
+ : SLOT_STATE_STARTED;
+ }
// reset server kill-switch counter
n_empty_consecutive = 0;
@@ -2169,6 +2238,18 @@ private:
queue_results.send(std::move(res));
}
+ void send_tts_result(server_slot & slot, int32_t sample_rate, const char * data, size_t data_len, bool final) {
+ auto res = std::make_unique<server_task_result_tts>();
+
+ res->id = slot.task->id;
+ res->index = slot.task->index;
+ res->sample_rate = sample_rate;
+ res->audio.assign(data, data_len);
+ res->final = final;
+
+ queue_results.send(std::move(res));
+ }
+
void send_final_response(server_slot & slot) {
auto res = std::make_unique<server_task_result_cmpl_final>();
@@ -2668,6 +2749,7 @@ private:
case SERVER_TASK_TYPE_EMBEDDING:
case SERVER_TASK_TYPE_RERANK:
case SERVER_TASK_TYPE_SCORE:
+ case SERVER_TASK_TYPE_TTS:
{
// special case: if input is provided via CLI, tokenize it first
// otherwise, no need to tokenize as it's already done inside the HTTP thread
@@ -3103,6 +3185,14 @@ private:
abort_all_slots("pre_decode() failed: " + std::string(e.what()));
}
+ // note: TTS slots bypass the shared batch entirely
+ try {
+ process_tts_slots();
+ } catch (const std::exception & e) {
+ SRV_ERR("process_tts_slots() failed: %s\n", e.what());
+ abort_all_slots("process_tts_slots() failed: " + std::string(e.what()));
+ }
+
GGML_ASSERT(batch.slot_batched || batch.size() == 0);
if (batch.slot_batched) {
@@ -3173,10 +3263,77 @@ private:
}
}
+ void process_tts_slots() {
+ iterate(slots, [&](server_slot & slot) {
+ if (!slot.is_processing() || slot.task->type != SERVER_TASK_TYPE_TTS) {
+ return;
+ }
+
+ llama_set_embeddings(ctx_tgt, true);
+
+ if (slot.state == SLOT_STATE_PROCESSING_PROMPT) {
+ const int32_t ret = slot.tts.ctx.step_prompt(llama_n_batch(ctx_tgt));
+ if (ret < 0) {
+ send_error(slot, "TTS prompt processing failed", ERROR_TYPE_SERVER);
+ slot.release();
+ } else if (ret == 0) {
+ slot.tts.sampled = common_sampler_sample(slot.smpl.get(), ctx_tgt, -1);
+ common_sampler_accept(slot.smpl.get(), slot.tts.sampled, true);
+ slot.tts.h_state = llama_get_embeddings_ith(ctx_tgt, -1);
+ slot.state = SLOT_STATE_GENERATING;
+ }
+ return;
+ }
+
+ const int32_t n_predict = slot.task->params.n_predict > 0 ? slot.task->params.n_predict : 512;
+ if (slot.tts.n_decoded >= n_predict || llama_vocab_is_eog(vocab, slot.tts.sampled)) {
+ int32_t sample_rate = 0;
+ const char * data = nullptr;
+ size_t data_len = 0;
+ // generation truly ends here: force out any sub-window remainder still buffered
+ if (slot.tts.ctx.flush() != 0 || slot.tts.ctx.get_output(&sample_rate, &data, &data_len) != 0) {
+ send_error(slot, "failed to finalize TTS output", ERROR_TYPE_SERVER);
+ } else {
+ send_tts_result(slot, sample_rate, data, data_len, true);
+ }
+ slot.release();
+ return;
+ }
+
+ const float * h_state_next = nullptr;
+ if (slot.tts.ctx.step_gen(slot.tts.sampled, slot.tts.h_state, &h_state_next) != 0) {
+ send_error(slot, "TTS generation failed", ERROR_TYPE_SERVER);
+ slot.release();
+ return;
+ }
+ slot.tts.h_state = h_state_next;
+ slot.tts.n_decoded++;
+
+ slot.tts.sampled = common_sampler_sample(slot.smpl.get(), ctx_tgt, -1);
+ common_sampler_accept(slot.smpl.get(), slot.tts.sampled, true);
+
+ if (slot.task->params.stream) {
+ int32_t sample_rate = 0;
+ const char * data = nullptr;
+ size_t data_len = 0;
+ if (slot.tts.ctx.get_output(&sample_rate, &data, &data_len) != 0) {
+ send_error(slot, "TTS streaming output failed", ERROR_TYPE_SERVER);
+ slot.release();
+ } else if (data_len > 0) {
+ send_tts_result(slot, sample_rate, data, data_len, false);
+ }
+ }
+ });
+ }
+
void pre_decode() {
// apply context-shift if needed
// TODO: simplify and improve
iterate(slots, [&](server_slot & slot) {
+ if (slot.task && slot.task->type == SERVER_TASK_TYPE_TTS) {
+ // TTS slots drive their own decode loop in process_tts_slots(), never enter the shared batch
+ return;
+ }
if (slot.state == SLOT_STATE_GENERATING && slot.prompt.n_tokens() + 1 >= slot.n_ctx) {
if (!params_base.ctx_shift) {
// this check is redundant (for good)
@@ -3249,7 +3406,7 @@ private:
// determine which slots are generating and drafting
iterate(slots, [&](server_slot & slot) {
- if (slot.state != SLOT_STATE_GENERATING) {
+ if (slot.state != SLOT_STATE_GENERATING || slot.task->type == SERVER_TASK_TYPE_TTS) {
return;
}
@@ -3381,7 +3538,7 @@ private:
return; // batch is full, skip remaining slots
}
- if (!slot.is_processing()) {
+ if (!slot.is_processing() || slot.task->type == SERVER_TASK_TYPE_TTS) {
return;
}
@@ -4390,6 +4547,8 @@ server_context_meta server_context::get_meta() const {
/* has_inp_image */ impl->chat_params.allow_image,
/* has_inp_audio */ impl->chat_params.allow_audio,
/* has_inp_video */ impl->chat_params.allow_video,
+ /* has_cap_chat */ impl->has_cap_chat(),
+ /* has_cap_tts */ impl->has_cap_tts(),
/* json_ui_settings */ impl->json_ui_settings,
/* slot_n_ctx */ impl->get_slot_n_ctx(),
/* pooling_type */ llama_pooling_type(impl->ctx_tgt),
@@ -4469,6 +4628,11 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
res->set_req(&req); // will also set spipe if needed
+ if (!ctx_server.has_cap_chat()) {
+ res->error(format_error_response("this server does not support chat/completions", ERROR_TYPE_NOT_SUPPORTED));
+ return res;
+ }
+
int32_t sse_ping_interval = params.sse_ping_interval;
try {
@@ -5446,6 +5610,150 @@ void server_routes::init_routes() {
return res;
};
+ this->post_tts = [this](const server_http_req & req) {
+ auto res = create_response();
+ res->set_req(&req); // will also set spipe if needed
+
+ if (!ctx_server.has_cap_tts()) {
+ res->error(format_error_response("this server does not support audio generation", ERROR_TYPE_NOT_SUPPORTED));
+ return res;
+ }
+
+ const json body = json::parse(req.body);
+
+ std::string prompt = json_value(body, "input", json_value(body, "prompt", std::string()));
+ if (prompt.empty()) {
+ res->error(format_error_response("\"input\" must be a non-empty string", ERROR_TYPE_INVALID_REQUEST));
+ return res;
+ }
+
+ const std::string response_format = json_value(body, "response_format", std::string("wav"));
+ const bool stream = json_value(body, "stream", false);
+
+ server_task task(SERVER_TASK_TYPE_TTS);
+ task.tts_inp.set_prompt(prompt);
+ task.tts_inp.set_lang(json_value(body, "lang", std::string()));
+ task.tts_inp.data.top_k = json_value(body, "top_k", 0);
+ task.tts_inp.data.top_p = json_value(body, "top_p", 0.0f);
+ task.tts_inp.data.stream = stream;
+ task.tts_inp.data.out_type = response_format == "pcm"
+ ? MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM
+ : MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
+ task.params.stream = stream;
+ task.params.n_predict = json_value(body, "n_predict", -1);
+ task.params.sampling = params.sampling; // baseline defaults, then apply overrides below
+ task.params.sampling.penalty_repeat = json_value(body, "repeat_penalty", 1.05f);
+ task.params.sampling.penalty_last_n = -1;
+ if (task.tts_inp.data.top_k > 0) {
+ task.params.sampling.top_k = task.tts_inp.data.top_k;
+ }
+ if (task.tts_inp.data.top_p > 0) {
+ task.params.sampling.top_p = task.tts_inp.data.top_p;
+ }
+
+ // speaker reference: either an uploaded form file ("speaker_ref") or a base64 JSON field ("speaker_ref_b64")
+ const unsigned char * speaker_ref_data = nullptr;
+ size_t speaker_ref_len = 0;
+ std::string speaker_ref_b64_decoded;
+
+ auto speaker_ref_file = req.files.find("speaker_ref");
+ if (speaker_ref_file != req.files.end()) {
+ speaker_ref_data = speaker_ref_file->second.data.data();
+ speaker_ref_len = speaker_ref_file->second.data.size();
+ } else {
+ std::string speaker_ref_b64 = json_value(body, "speaker_ref_b64", std::string());
+ if (!speaker_ref_b64.empty()) {
+ speaker_ref_b64_decoded = base64::decode(speaker_ref_b64);
+ speaker_ref_data = (const unsigned char *) speaker_ref_b64_decoded.data();
+ speaker_ref_len = speaker_ref_b64_decoded.size();
+ }
+ }
+
+ if (speaker_ref_len > 0) {
+ auto wrapper = mtmd_helper_bitmap_init_from_buf(ctx_server.mctx, speaker_ref_data, speaker_ref_len, false);
+ if (!wrapper.bitmap) {
+ res->error(format_error_response("failed to decode \"speaker_ref\"", ERROR_TYPE_INVALID_REQUEST));
+ return res;
+ }
+ task.tts_inp.set_speaker_ref(mtmd::bitmap_ptr(wrapper.bitmap));
+ } else {
+ // SRV_WRN expands __VA_ARGS__ without the GNU comma-elision extension,
+ // so a bare format string leaves a trailing comma and will not compile
+ SRV_WRN("%s", "no speaker reference provided, the model may behave randomly\n");
+ }
+
+ auto & rd = res->rd;
+ task.id = rd.get_new_id();
+ rd.post_task(std::move(task));
+
+ const std::string content_type = response_format == "pcm" ? "audio/L16" : "audio/wav";
+
+ if (!stream) {
+ auto result = rd.next(req.should_stop);
+ if (!result) {
+ GGML_ASSERT(req.should_stop());
+ return res; // connection is closed
+ }
+ if (result->is_error()) {
+ res->error(result->to_json());
+ return res;
+ }
+ auto * tts_res = dynamic_cast<server_task_result_tts *>(result.get());
+ GGML_ASSERT(tts_res != nullptr);
+ res->status = 200;
+ res->content_type = content_type;
+ res->data = std::move(tts_res->audio);
+ return res;
+ } else {
+ auto first_result = rd.next(req.should_stop);
+ if (!first_result) {
+ GGML_ASSERT(req.should_stop());
+ return res; // connection is closed
+ }
+ if (first_result->is_error()) {
+ res->error(first_result->to_json());
+ return res;
+ }
+ auto * first_tts_res = dynamic_cast<server_task_result_tts *>(first_result.get());
+ GGML_ASSERT(first_tts_res != nullptr);
+
+ res->status = 200;
+ res->content_type = content_type;
+ res->data = std::move(first_tts_res->audio);
+ bool is_done = first_tts_res->final;
+
+ res->set_next([res_this = res.get(), is_done](std::string & output) mutable -> bool {
+ if (is_done) {
+ return false;
+ }
+ if (res_this->should_stop()) {
+ return false;
+ }
+ if (!res_this->data.empty()) {
+ output = std::move(res_this->data);
+ res_this->data.clear();
+ return true;
+ }
+
+ server_response_reader & rd = res_this->rd;
+ if (!rd.has_next()) {
+ return false;
+ }
+ auto result = rd.next([&res_this]() { return res_this->should_stop(); });
+ if (!result || result->is_error()) {
+ return false;
+ }
+ auto * tts_res = dynamic_cast<server_task_result_tts *>(result.get());
+ GGML_ASSERT(tts_res != nullptr);
+ output = std::move(tts_res->audio);
+ is_done = tts_res->final;
+ return true;
+ });
+ }
+
+ return res;
+ };
+
this->get_lora_adapters = [this](const server_http_req & req) {
auto res = create_response();
diff --git a/tools/server/server-context.h b/tools/server/server-context.h
index f9ab113..6105126 100644
--- a/tools/server/server-context.h
+++ b/tools/server/server-context.h
@@ -22,6 +22,8 @@ struct server_context_meta {
bool has_inp_image;
bool has_inp_audio;
bool has_inp_video;
+ bool has_cap_chat;
+ bool has_cap_tts;
json json_ui_settings;
int slot_n_ctx;
enum llama_pooling_type pooling_type;
@@ -151,6 +153,7 @@ struct server_routes {
server_http_context::handler_t post_embeddings;
server_http_context::handler_t post_embeddings_oai;
server_http_context::handler_t post_rerank;
+ server_http_context::handler_t post_tts;
server_http_context::handler_t get_lora_adapters;
server_http_context::handler_t post_lora_adapters;
diff --git a/tools/server/server-task.cpp b/tools/server/server-task.cpp
index 1ee6775..939630b 100644
--- a/tools/server/server-task.cpp
+++ b/tools/server/server-task.cpp
@@ -1523,6 +1523,17 @@ json server_task_result_rerank::to_json() {
};
}
+//
+// server_task_result_tts
+//
+json server_task_result_tts::to_json() {
+ return json {
+ {"sample_rate", sample_rate},
+ {"n_bytes", audio.size()},
+ {"final", final},
+ };
+}
+
//
// server_task_result_error
//
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
index 5bedf19..e6ca67a 100644
--- a/tools/server/server-task.h
+++ b/tools/server/server-task.h
@@ -10,6 +10,7 @@
// TODO: prevent including the whole server-common.h as we only use server_tokens
#include "server-common.h"
+#include "mtmd-helper.h"
using json = nlohmann::ordered_json;
@@ -42,6 +43,7 @@ enum server_task_type {
SERVER_TASK_TYPE_SLOT_ERASE,
SERVER_TASK_TYPE_GET_LORA,
SERVER_TASK_TYPE_SET_LORA,
+ SERVER_TASK_TYPE_TTS,
};
// TODO: change this to more generic "response_format" to replace the "format_response_*" in server-common
@@ -202,6 +204,9 @@ struct server_task {
// used by SERVER_TASK_TYPE_SET_LORA
std::map<int, float> set_lora; // mapping adapter ID -> scale
+ // used by SERVER_TASK_TYPE_TTS
+ mtmd_helper::gen_audio::inp tts_inp;
+
server_task() = default;
server_task(server_task_type type) : type(type) {}
@@ -235,6 +240,7 @@ struct server_task {
switch (type) {
case SERVER_TASK_TYPE_COMPLETION:
case SERVER_TASK_TYPE_INFILL:
+ case SERVER_TASK_TYPE_TTS:
return true;
default:
return false;
@@ -514,6 +520,16 @@ struct server_task_result_embd : server_task_result {
json to_json_oaicompat();
};
+struct server_task_result_tts : server_task_result {
+ std::string audio; // raw bytes for this chunk (WAV or PCM, per request's out_type)
+ int32_t sample_rate = 0;
+ bool final = false; // true for the last chunk of a request
+
+ virtual bool is_stop() override { return final; }
+
+ virtual json to_json() override;
+};
+
struct server_task_result_rerank : server_task_result {
float score = -1e6;

View File

@@ -28,6 +28,10 @@ cp -r message_content_test.cpp llama.cpp/tools/grpc-server/
# Generic passthrough parser staging and its standalone regression test.
cp -r passthrough_options.h llama.cpp/tools/grpc-server/
cp -r passthrough_options_test.cpp llama.cpp/tools/grpc-server/
# TTS request validation (included by grpc-server.cpp) and its standalone
# regression test.
cp -r tts_request_options.h llama.cpp/tools/grpc-server/
cp -r tts_request_options_test.cpp llama.cpp/tools/grpc-server/
# Parent-death watcher (included by grpc-server.cpp) and its standalone unit
# test (run via backend/cpp/run-unit-tests.sh; also buildable under ctest).
cp -r parent_watch.h llama.cpp/tools/grpc-server/

View File

@@ -0,0 +1,149 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
#include <exception>
#include <map>
#include <string>
namespace llama_grpc {
// Validated, parsed form of a backend::TTSRequest, kept free of llama.cpp,
// mtmd and gRPC headers so backend/cpp/run-unit-tests.sh can compile it as a
// standalone translation unit. grpc-server.cpp turns this into a
// mtmd_helper::gen_audio::inp.
struct tts_request_options {
bool ok = false;
std::string error;
std::string text;
std::string voice_path;
std::string language;
// 0 / 0.0f mean "unset": upstream only overrides the sampler defaults when
// the value is strictly positive.
int32_t top_k = 0;
float top_p = 0.0f;
// Upper bound on generated audio frames, exposed because the model does not
// always emit its codec EOS and will otherwise run to the 512-frame default,
// which is roughly 41 s at the 12.5 Hz frame rate. 0 means unset, leaving
// that default in place.
int32_t max_frames = 0;
};
namespace detail {
// Strict whole-string numeric parsing. std::stoi/stof accept trailing garbage
// ("40abc" -> 40), which would silently honour a typo'd request.
inline bool parse_whole_int32(const std::string & value, int32_t & out) {
if (value.empty()) {
return false;
}
try {
size_t consumed = 0;
const long parsed = std::stol(value, &consumed);
if (consumed != value.size()) {
return false;
}
if (parsed < INT32_MIN || parsed > INT32_MAX) {
return false;
}
out = static_cast<int32_t>(parsed);
return true;
} catch (const std::exception &) {
return false;
}
}
inline bool parse_whole_float(const std::string & value, float & out) {
if (value.empty()) {
return false;
}
try {
size_t consumed = 0;
const float parsed = std::stof(value, &consumed);
if (consumed != value.size()) {
return false;
}
out = parsed;
return true;
} catch (const std::exception &) {
return false;
}
}
inline tts_request_options reject(const std::string & message) {
tts_request_options opts;
opts.ok = false;
opts.error = message;
return opts;
}
} // namespace detail
inline tts_request_options parse_tts_request_options(
const std::string & text,
const std::string & voice,
const std::string & language,
const std::map<std::string, std::string> & params) {
if (text.empty()) {
return detail::reject("text must be a non-empty string");
}
// The Qwen3-TTS Base checkpoints have no built-in speaker. Without a
// reference clip the model picks an arbitrary voice, so an unset voice is
// a request error rather than a defaulted one.
if (voice.empty()) {
return detail::reject("voice must name a speaker reference audio file");
}
tts_request_options opts;
opts.text = text;
opts.voice_path = voice;
opts.language = language;
// Both values are range-checked here rather than left to the caller: the
// consumer copies them straight into mtmd_helper::gen_audio::inp, and only
// its separate sampler assignment is guarded by "> 0". An out-of-range or
// non-finite value would slip past that guard and reach llama.cpp.
const auto top_k_it = params.find("top_k");
if (top_k_it != params.end()) {
if (!detail::parse_whole_int32(top_k_it->second, opts.top_k)) {
return detail::reject("top_k must be an integer, got \"" + top_k_it->second + "\"");
}
if (opts.top_k < 0) {
return detail::reject("top_k must be >= 0, got \"" + top_k_it->second + "\"");
}
}
const auto top_p_it = params.find("top_p");
if (top_p_it != params.end()) {
if (!detail::parse_whole_float(top_p_it->second, opts.top_p)) {
return detail::reject("top_p must be a number, got \"" + top_p_it->second + "\"");
}
// Phrased as a negated in-range test, not "p < 0.0f || p > 1.0f",
// because every comparison against NaN is false: the obvious form
// would accept NaN, and NaN then defeats the consumer's "> 0" guard
// too, since that comparison is false as well.
if (!(opts.top_p >= 0.0f && opts.top_p <= 1.0f)) {
return detail::reject("top_p must be between 0.0 and 1.0, got \"" + top_p_it->second + "\"");
}
}
const auto max_frames_it = params.find("max_frames");
if (max_frames_it != params.end()) {
if (!detail::parse_whole_int32(max_frames_it->second, opts.max_frames)) {
return detail::reject("max_frames must be an integer, got \"" + max_frames_it->second + "\"");
}
if (opts.max_frames < 0) {
return detail::reject("max_frames must be >= 0, got \"" + max_frames_it->second + "\"");
}
}
opts.ok = true;
return opts;
}
} // namespace llama_grpc

View File

@@ -0,0 +1,209 @@
// SPDX-License-Identifier: MIT
#include <cstdio>
#include <map>
#include <string>
#include "tts_request_options.h"
static int failures = 0;
static void check(bool ok, const char * name) {
if (!ok) {
++failures;
std::fprintf(stderr, "FAIL: %s\n", name);
}
}
static void test_accepts_a_minimal_valid_request() {
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "en", {});
check(opts.ok, "minimal request is accepted");
check(opts.error.empty(), "minimal request has no error");
check(opts.text == "Hello world", "text passes through");
check(opts.voice_path == "/models/voices/ref.wav", "voice path passes through");
check(opts.language == "en", "language passes through");
check(opts.top_k == 0, "top_k defaults to the unset sentinel");
check(opts.top_p == 0.0f, "top_p defaults to the unset sentinel");
check(opts.max_frames == 0, "max_frames defaults to the unset sentinel");
}
static void test_rejects_empty_text() {
const auto opts = llama_grpc::parse_tts_request_options(
"", "/models/voices/ref.wav", "en", {});
check(!opts.ok, "empty text is rejected");
check(opts.error.find("text") != std::string::npos, "empty-text error names the field");
}
static void test_rejects_missing_speaker_reference() {
// Qwen3-TTS Base has no built-in speaker; without a reference it produces
// an arbitrary voice, so this must be a hard error rather than a surprise.
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "", "en", {});
check(!opts.ok, "missing voice is rejected");
check(opts.error.find("voice") != std::string::npos, "missing-voice error names the field");
}
static void test_parses_sampling_params() {
const std::map<std::string, std::string> params{
{"top_k", "40"},
{"top_p", "0.85"},
};
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", params);
check(opts.ok, "sampling params are accepted");
check(opts.top_k == 40, "top_k is parsed");
check(opts.top_p > 0.849f && opts.top_p < 0.851f, "top_p is parsed");
check(opts.language.empty(), "absent language stays empty");
}
static void test_rejects_malformed_sampling_params() {
const auto bad_top_k = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "forty"}});
check(!bad_top_k.ok, "non-numeric top_k is rejected");
check(bad_top_k.error.find("top_k") != std::string::npos, "top_k error names the field");
const auto bad_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", ""}});
check(!bad_top_p.ok, "empty top_p is rejected");
const auto trailing = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "40abc"}});
check(!trailing.ok, "top_k with trailing garbage is rejected");
const auto trailing_float = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "0.8abc"}});
check(!trailing_float.ok, "top_p with trailing garbage is rejected");
// std::stol returns a long, which is wider than int32_t on 64-bit hosts, so
// an in-range-for-long value still has to be caught before the narrowing.
const auto overflow_top_k = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "99999999999"}});
check(!overflow_top_k.ok, "top_k beyond int32 range is rejected");
check(overflow_top_k.error.find("top_k") != std::string::npos,
"top_k overflow error names the field");
}
static void test_rejects_out_of_range_sampling_params() {
// These reach mtmd_helper::gen_audio::inp unconditionally downstream, where
// the "> 0" sampler guard does not screen them, so they must die here.
const auto negative_top_k = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "-5"}});
check(!negative_top_k.ok, "negative top_k is rejected");
check(negative_top_k.error.find("top_k") != std::string::npos,
"negative top_k error names the field");
const auto negative_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "-0.1"}});
check(!negative_top_p.ok, "negative top_p is rejected");
check(negative_top_p.error.find("top_p") != std::string::npos,
"negative top_p error names the field");
const auto large_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "1.5"}});
check(!large_top_p.ok, "top_p above 1.0 is rejected");
// NaN survives a naive "p < 0.0f || p > 1.0f" range test because every
// comparison against NaN is false. This case pins the correct form.
const auto nan_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "nan"}});
check(!nan_top_p.ok, "NaN top_p is rejected");
const auto inf_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "inf"}});
check(!inf_top_p.ok, "infinite top_p is rejected");
}
static void test_accepts_sampling_param_boundaries() {
const auto zero_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "0.0"}});
check(zero_top_p.ok, "top_p of 0.0 is accepted");
check(zero_top_p.top_p == 0.0f, "top_p of 0.0 round-trips");
const auto one_top_p = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_p", "1.0"}});
check(one_top_p.ok, "top_p of 1.0 is accepted");
check(one_top_p.top_p == 1.0f, "top_p of 1.0 round-trips");
const auto zero_top_k = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "0"}});
check(zero_top_k.ok, "top_k of 0 is accepted");
}
static void test_parses_max_frames() {
// The consumer maps a positive value onto n_predict and leaves upstream's
// 512-frame default in place when it is unset, so the sentinel matters as
// much as the parsed value.
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "120"}});
check(opts.ok, "max_frames is accepted");
check(opts.max_frames == 120, "max_frames is parsed");
const auto absent = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"top_k", "40"}});
check(absent.ok, "a request without max_frames is accepted");
check(absent.max_frames == 0, "absent max_frames leaves the unset sentinel");
const auto zero = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "0"}});
check(zero.ok, "max_frames of 0 is accepted");
check(zero.max_frames == 0, "max_frames of 0 means unset");
}
static void test_rejects_malformed_max_frames() {
const auto negative = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "-1"}});
check(!negative.ok, "negative max_frames is rejected");
check(negative.error.find("max_frames") != std::string::npos,
"negative max_frames error names the field");
const auto non_numeric = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "many"}});
check(!non_numeric.ok, "non-numeric max_frames is rejected");
check(non_numeric.error.find("max_frames") != std::string::npos,
"non-numeric max_frames error names the field");
const auto trailing = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "120abc"}});
check(!trailing.ok, "max_frames with trailing garbage is rejected");
const auto empty = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", ""}});
check(!empty.ok, "empty max_frames is rejected");
const auto overflow = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"max_frames", "99999999999"}});
check(!overflow.ok, "max_frames beyond int32 range is rejected");
}
static void test_ignores_unknown_params() {
// Unknown keys are backend-specific knobs meant for other TTS engines. A
// request routed here must not fail just because it carries them.
const auto opts = llama_grpc::parse_tts_request_options(
"Hello world", "/models/voices/ref.wav", "", {{"exaggeration", "0.7"}});
check(opts.ok, "unknown params are ignored, not rejected");
}
int main() {
test_accepts_a_minimal_valid_request();
test_rejects_empty_text();
test_rejects_missing_speaker_reference();
test_parses_sampling_params();
test_rejects_malformed_sampling_params();
test_rejects_out_of_range_sampling_params();
test_accepts_sampling_param_boundaries();
test_parses_max_frames();
test_rejects_malformed_max_frames();
test_ignores_unknown_params();
if (failures == 0) {
std::printf("tts_request_options_test: all checks passed\n");
}
return failures;
}

View File

@@ -48,6 +48,7 @@ define turboquant-build
# stays compiling against vanilla upstream.
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build/grpc-server.cpp
$(info $(GREEN)I turboquant build info:$(1)$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(TURBOQUANT_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-$(1)-build llama.cpp
@@ -86,6 +87,7 @@ turboquant-cpu-all:
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build purge
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build/grpc-server.cpp
$(info $(GREEN)I turboquant build info:cpu-all-variants$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(TURBOQUANT_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../turboquant-cpu-all-build llama.cpp

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
CRISPASR_VERSION?=fe3caf8e363b27572dbdd1a9d37083f25e6decda
CRISPASR_VERSION?=ec730908a418b6032f9e69ded6186d3f042a7747
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# stablediffusion.cpp (ggml)
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
STABLEDIFFUSION_GGML_VERSION?=db99efdd6d2a43c7937fd55b3359206c680a75b0
STABLEDIFFUSION_GGML_VERSION?=ea7f0c87cfe4c673263b4c201c596c7f1cbe2528
CMAKE_ARGS+=-DGGML_MAX_NAME=128

View File

@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
# vllm.cpp version
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
VLLM_CPP_VERSION?=9d1fad3cde0acb95eb0bb0a1025f40a0eb614147
VLLM_CPP_VERSION?=0757cac231ecd571a83c4fd2f50805c9251fc225
# MLX GEMM provider (darwin/metal only; see the metal branch below for why).
# Consumed as the prebuilt pip wheel: building MLX from source needs `xcrun

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# whisper.cpp version
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
WHISPER_CPP_VERSION?=64d57d3df5c8dacee098577257edcaa154bf5ef3
WHISPER_CPP_VERSION?=306c88f4d1286aec1bf96e544632897886af5501
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -11,6 +11,8 @@
- https://github.com/ggerganov/llama.cpp
tags:
- text-to-text
- text-to-speech
- TTS
- LLM
- CPU
- GPU
@@ -193,12 +195,22 @@
alias: "vllm-cpp"
license: apache-2.0
description: |
vllm.cpp is a from-scratch C++20 port of vLLM created and maintained by the LocalAI team.
It mirrors vLLM's V1 architecture (paged KV cache, continuous batching, prefix caching,
scheduler, sampler) on a portable tensor runtime with no Python, PyTorch or ggml at
inference time. It loads Hugging Face safetensors and GGUF checkpoints, supports
structured output (JSON schema / regex / choice / GBNF grammar) enforced in-engine,
and runs on CPU, NVIDIA CUDA (Blackwell-family), Apple Metal and Vulkan.
ALPHA development builds. Try it, but llama-cpp stays the recommendation for
production use.
vllm.cpp is an Apache-2.0 C++20 inference engine maintained by the LocalAI team,
developed in its own repository and usable without LocalAI. It began as a port of
vLLM and keeps vLLM as its reference implementation, checking output against it and
benchmarking against it, while growing a featureset of its own. It implements vLLM's
V1 architecture (paged KV cache, continuous batching, prefix caching, scheduler,
sampler) on a portable tensor runtime with no Python, PyTorch or ggml at inference
time. It loads GGUF as well as Hugging Face safetensors, supports structured output
(JSON schema / regex / choice / GBNF grammar) enforced in-engine, ships speculative
decoding and KV offload, and runs on CPU, NVIDIA CUDA (Blackwell-family), Apple
Metal and Vulkan.
The project is expected to be renamed as it diverges further from vLLM; the new
name is still to be decided.
urls:
- https://github.com/mudler/vllm.cpp
tags:

View File

@@ -271,12 +271,18 @@ func referenceVoiceCloning() *VoiceCloningCapability {
// Use NormalizeBackendName() for names with dots (e.g., "llama.cpp").
var BackendCapabilities = map[string]BackendCapability{
// --- LLM / text generation backends ---
// llama.cpp also serves Qwen3-TTS, so TTS is in the union below. It is NOT
// in DefaultUsecases: a bare GGUF served by llama.cpp is a chat model, and
// the TTS models declare known_usecases: [tts]. VoiceCloning is likewise
// narrowed per model in VoiceCloningForModel, since the vast majority of
// llama-cpp models in the gallery are text LLMs that clone nothing.
"llama-cpp": {
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodEmbedding, MethodTokenizeString, MethodScore},
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseEdit, UsecaseEmbeddings, UsecaseTokenize, UsecaseVision, UsecaseScore},
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodEmbedding, MethodTokenizeString, MethodScore, MethodTTS, MethodTTSStream},
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseEdit, UsecaseEmbeddings, UsecaseTokenize, UsecaseVision, UsecaseScore, UsecaseTTS},
DefaultUsecases: []string{UsecaseChat},
AcceptsImages: true, // requires mmproj
Description: "llama.cpp GGUF models — LLM inference with optional vision via mmproj",
VoiceCloning: referenceVoiceCloning(),
Description: "llama.cpp GGUF models: LLM inference with optional vision via mmproj, and Qwen3-TTS speech with reference-audio cloning",
},
// privacy-filter is the standalone GGML engine (backend/cpp/privacy-filter,
// wrapping privacy-filter.cpp) for the openai-privacy-filter PII/NER token
@@ -966,6 +972,20 @@ func VoiceCloningForModel(cfg *ModelConfig) *VoiceCloningCapability {
supported = strings.Contains(identity, "xtts") || strings.Contains(identity, "your_tts")
case "crispasr":
supported = strings.Contains(identity, "f5-tts") || strings.Contains(identity, "f5_tts")
case "llama-cpp":
// llama.cpp is overwhelmingly a text-LLM backend that happens to also
// serve Qwen3-TTS, so the permissive default below would advertise
// reference-audio cloning on every GGUF chat model in the gallery.
// Narrow on the declared usecase rather than the model name: the TTS
// checkpoints are the only llama-cpp models that carry
// known_usecases: [tts], name matching would have to guess at
// third-party GGUF repacks, and "base" (the substring the Qwen and
// vLLM cases key on) is a routine word in text-model names.
//
// Deliberately reads the declared bit instead of HasUsecases, which
// falls through to GuessUsecases and would hand the decision to a
// heuristic that never had a llama.cpp TTS model in mind.
supported = cfg.KnownUsecases != nil && (*cfg.KnownUsecases&FLAG_TTS) == FLAG_TTS
default:
supported = true
}

View File

@@ -247,6 +247,78 @@ var _ = Describe("VoiceCloningForModel", func() {
)
})
// llama.cpp serves Qwen3-TTS as well as the text LLMs it is known for, so the
// backend has to advertise TTS. That advertisement is what makes narrowing
// mandatory: the per-backend switch in VoiceCloningForModel ends in a
// permissive default, so an unnarrowed llama-cpp entry would offer
// reference-audio cloning on every GGUF chat model in the gallery.
var _ = Describe("llama-cpp TTS capabilities", func() {
It("advertises the TTS RPCs and usecase", func() {
capability := GetBackendCapability("llama-cpp")
Expect(capability).NotTo(BeNil())
Expect(capability.GRPCMethods).To(ContainElements(MethodTTS, MethodTTSStream))
Expect(capability.PossibleUsecases).To(ContainElement(UsecaseTTS))
})
// The gallery filter and the model importer both read DefaultUsecases, and a
// bare GGUF served by llama.cpp is a chat model, not a TTS model.
It("keeps chat as its only default usecase", func() {
Expect(GetBackendCapability("llama-cpp").DefaultUsecases).To(Equal([]string{UsecaseChat}))
})
ttsModel := func(backend string) ModelConfig {
cfg := ModelConfig{Name: "qwen3-tts-llamacpp", Backend: backend}
cfg.KnownUsecaseStrings = []string{"tts"}
cfg.syncKnownUsecasesFromString()
return cfg
}
It("resolves voice cloning for a model that declares the TTS usecase", func() {
cfg := ttsModel("llama-cpp")
cloning := VoiceCloningForModel(&cfg)
Expect(cloning).NotTo(BeNil())
Expect(cloning.AcceptedAudioFormats).To(ContainElement("audio/wav"))
})
// The spec that constrains the fix. Every one of these is an ordinary
// llama.cpp text model, and none of them may be offered in the Voice
// Library or accept a localai://voice-profiles/... reference.
DescribeTable("never resolves voice cloning for an ordinary llama.cpp model",
func(cfg ModelConfig) {
Expect(VoiceCloningForModel(&cfg)).To(BeNil())
},
Entry("plain chat model", ModelConfig{Name: "qwen3-8b", Backend: "llama-cpp"}),
Entry("auto-detected GGUF with no backend pinned", ModelConfig{Name: "mistral-7b"}),
Entry("a vision model with an mmproj", ModelConfig{Name: "gemma-3-12b", Backend: "llama-cpp", LLMConfig: LLMConfig{MMProj: "mmproj-gemma-3-12b.gguf"}}),
Entry("a chat model whose name happens to say base", ModelConfig{Name: "llama-3.1-8b-base", Backend: "llama-cpp"}),
Entry("a pinned hardware variant", ModelConfig{Name: "qwen3-8b", Backend: "cuda12-llama-cpp"}),
)
// A declared-TTS model must keep its contract through the pinned gallery
// variants an operator can put in `backend:`, the same way vibevoice-cpp
// and crispasr do.
DescribeTable("resolves through pinned gallery variants",
func(backend string) {
cfg := ttsModel(backend)
Expect(VoiceCloningForModel(&cfg)).NotTo(BeNil())
},
Entry("cuda12", "cuda12-llama-cpp"),
Entry("vulkan", "vulkan-llama-cpp"),
Entry("metal darwin arm64", "metal-darwin-arm64-llama-cpp"),
Entry("development channel", "llama-cpp-development"),
)
// tts.voice_cloning is the documented escape hatch for a custom build. It
// only ever reaches the operator once the backend carries the contract at
// all, which is precisely what the unregistered entry prevented.
It("still honours an explicit opt-out on a declared-TTS model", func() {
cfg := ttsModel("llama-cpp")
disabled := false
cfg.TTSConfig.VoiceCloning = &disabled
Expect(VoiceCloningForModel(&cfg)).To(BeNil())
})
})
var _ = Describe("IsValidUsecaseForBackend", func() {
It("accepts a backend's declared usecases", func() {
Expect(IsValidUsecaseForBackend("piper", "tts")).To(BeTrue())

View File

@@ -26,6 +26,27 @@ func reservedNonChatModel(cfg *ModelConfig) bool {
(*cfg.KnownUsecases&(FLAG_SCORE|FLAG_TOKEN_CLASSIFY)) != 0
}
// genAudioEncoderKey is the mmproj metadata flag llama.cpp's mtmd writes for a
// projector carrying the speech-synthesis pipeline (ggml-org/llama.cpp#26254).
const genAudioEncoderKey = "clip.has_gen_audio_encoder"
// HasGenAudioProjector reports whether a parsed mmproj GGUF holds a gen-audio
// pipeline (Qwen3-TTS) rather than a vision tower. This is the same flag
// mtmd_helper_gen_audio itself checks before building the pipeline, so it is
// the engine's own answer rather than a filename heuristic: an mmproj is
// otherwise indistinguishable from a vision projector by name alone, and every
// TTS repo names it mmproj-*.gguf exactly like a vision one.
func HasGenAudioProjector(f *gguf.GGUFFile) bool {
if f == nil {
return false
}
kv, ok := f.Header.MetadataKV.Get(genAudioEncoderKey)
if !ok || kv.ValueType != gguf.GGUFMetadataValueTypeBool {
return false
}
return kv.ValueBool()
}
func guessGGUFFromFile(cfg *ModelConfig, f *gguf.GGUFFile, defaultCtx int) {
// Explicit opt-in: a negative context_size (canonically -1) means "use the
// model's full trained context (n_ctx_train) from GGUF metadata". Unlike the

View File

@@ -54,13 +54,19 @@ func (c *ModelConfig) VisionSupported() bool {
if c.KnownUsecases != nil && (*c.KnownUsecases&FLAG_VISION) == FLAG_VISION {
return true
}
if c.MMProj != "" {
// A TTS model's mmproj holds a speaker encoder and code predictor, not a
// vision tower, and llama.cpp builds an mtmd context (and so reports a media
// marker on the first chat probe) for it all the same. Neither signal proves
// image input on a declared-TTS model. Callers that genuinely are both
// declare FLAG_VISION, checked above.
declaredTTS := c.KnownUsecases != nil && (*c.KnownUsecases&FLAG_TTS) == FLAG_TTS
if c.MMProj != "" && !declaredTTS {
return true
}
if c.TemplateConfig.Multimodal != "" {
return true
}
if c.MediaMarker != "" {
if c.MediaMarker != "" && !declaredTTS {
return true
}
return false

View File

@@ -35,6 +35,31 @@ var _ = Describe("Model capabilities derivation", func() {
Expect(cfg.VisionSupported()).To(BeTrue())
})
It("is false for a TTS model whose mmproj is a speaker encoder", func() {
// Qwen3-TTS on llama-cpp ships an mmproj that holds the speaker
// encoder and code predictor, not a vision tower.
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_TTS), Backend: "llama-cpp"}
cfg.MMProj = "mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf"
Expect(cfg.VisionSupported()).To(BeFalse())
})
It("is false for a TTS model whose backend reported a media marker", func() {
// llama.cpp builds an mtmd context for the speaker-encoder projector
// and reports its marker on the first chat probe, which would
// otherwise resurrect vision after the model has been used once.
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_TTS), Backend: "llama-cpp"}
cfg.MMProj = "mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf"
cfg.MediaMarker = "<__media__>"
Expect(cfg.VisionSupported()).To(BeFalse())
})
It("is still true for a TTS model that also declares vision", func() {
// An omni model can legitimately be both. The explicit bit wins.
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_TTS | FLAG_VISION), Backend: "llama-cpp"}
cfg.MMProj = "mmproj.gguf"
Expect(cfg.VisionSupported()).To(BeTrue())
})
It("does not fall for the GuessUsecases FLAG_VISION false positive", func() {
// A chat model with a chat template would make HasUsecases(FLAG_VISION)
// return true via the guess heuristic; VisionSupported must not.
@@ -42,6 +67,26 @@ var _ = Describe("Model capabilities derivation", func() {
cfg.TemplateConfig.Chat = "{{.Input}}"
Expect(cfg.VisionSupported()).To(BeFalse())
})
It("survives the loader re-syncing known_usecases from the rewritten list", func() {
// syncKnownUsecasesFromString rewrites KnownUsecaseStrings from
// HasUsecases, and the loader calls it more than once per file. If a
// guessed "vision" leaks into that list, the next pass parses it back
// into KnownUsecases as an explicit bit and the mmproj exemption above
// is bypassed. Reproduces the gallery entry qwen3-tts-llamacpp-q4.
cfg := &ModelConfig{Backend: "llama-cpp"}
cfg.KnownUsecaseStrings = []string{"tts"}
cfg.MMProj = "qwen3-tts-llamacpp-q4/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf"
cfg.TemplateConfig.UseTokenizerTemplate = true
cfg.syncKnownUsecasesFromString()
cfg.syncKnownUsecasesFromString()
Expect(cfg.KnownUsecaseStrings).NotTo(ContainElement("FLAG_VISION"))
Expect(cfg.VisionSupported()).To(BeFalse())
Expect(cfg.Capabilities()).NotTo(ContainElement(UsecaseVision))
Expect(cfg.InputModalities()).NotTo(ContainElement(ModalityImage))
})
})
Describe("AudioInputSupported / VideoInputSupported", func() {

View File

@@ -1876,6 +1876,20 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
}
}
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) {

View File

@@ -122,3 +122,52 @@ var _ = Describe("MTP auto-defaults", func() {
})
})
})
// The mmproj of a Qwen3-TTS GGUF repo is named exactly like a vision
// projector, so the gen-audio flag llama.cpp's own mtmd gates its speech
// pipeline on is the only thing that tells the two apart.
var _ = Describe("HasGenAudioProjector", func() {
mmproj := func(key string, valueType gguf.GGUFMetadataValueType, value any) *gguf.GGUFFile {
return &gguf.GGUFFile{
Header: gguf.GGUFHeader{
MetadataKV: gguf.GGUFMetadataKVs{
{Key: "general.architecture", ValueType: gguf.GGUFMetadataValueTypeString, Value: "clip"},
{Key: key, ValueType: valueType, Value: value},
},
},
}
}
It("detects the gen-audio projector Qwen3-TTS ships", func() {
f := mmproj("clip.has_gen_audio_encoder", gguf.GGUFMetadataValueTypeBool, true)
Expect(HasGenAudioProjector(f)).To(BeTrue())
})
It("is false for a vision projector", func() {
f := mmproj("clip.has_vision_encoder", gguf.GGUFMetadataValueTypeBool, true)
Expect(HasGenAudioProjector(f)).To(BeFalse())
})
// A speaker encoder alone is reference-audio INPUT. The gen-audio decoder
// is what makes the model emit speech, and Qwen3-TTS carries both.
It("is false for a projector that only encodes speaker audio", func() {
f := mmproj("clip.has_audio_encoder", gguf.GGUFMetadataValueTypeBool, true)
Expect(HasGenAudioProjector(f)).To(BeFalse())
})
It("is false when the flag is present but off", func() {
f := mmproj("clip.has_gen_audio_encoder", gguf.GGUFMetadataValueTypeBool, false)
Expect(HasGenAudioProjector(f)).To(BeFalse())
})
// ValueBool panics on a type mismatch, and this runs against arbitrary
// user-supplied repos.
It("is false, not a panic, when the flag carries the wrong type", func() {
f := mmproj("clip.has_gen_audio_encoder", gguf.GGUFMetadataValueTypeString, "true")
Expect(HasGenAudioProjector(f)).To(BeFalse())
})
It("is false for a nil file", func() {
Expect(HasGenAudioProjector(nil)).To(BeFalse())
})
})

View File

@@ -300,6 +300,12 @@ func (i *LlamaCPPImporter) Import(details Details) (gallery.ModelConfig, error)
// it after the first start.
maybeApplyMTPDefaults(&modelConfig, details, &cfg)
// llama.cpp serves Qwen3-TTS through the same GGUF + mmproj shape as a
// vision model, so without this the TTS repos import as chat models with a
// vision projector: wrong usecase, wrong modality, and no route to the
// Voice Library.
maybeApplyTTSUsecase(&modelConfig, &cfg)
data, err := yaml.Marshal(modelConfig)
if err != nil {
return gallery.ModelConfig{}, err
@@ -414,6 +420,61 @@ func maybeApplyMTPDefaults(modelConfig *config.ModelConfig, details Details, cfg
config.ApplyMTPDefaults(modelConfig, n)
}
// maybeApplyTTSUsecase probes the selected mmproj GGUF header and, when it
// carries llama.cpp's gen-audio pipeline instead of a vision tower, imports
// the model as TTS rather than chat.
//
// A probe is the only honest signal here. A Qwen3-TTS repo has exactly the
// shape of a vision repo, one backbone GGUF plus one mmproj-*.gguf, so neither
// the filename nor the repo name distinguishes them; clip.has_gen_audio_encoder
// is the key llama.cpp's own mtmd_helper_gen_audio gates the pipeline on.
//
// Failures are non-fatal, as in maybeApplyMTPDefaults: a network blip leaves
// the chat default in place rather than breaking the import.
func maybeApplyTTSUsecase(modelConfig *config.ModelConfig, cfg *gallery.ModelConfig) {
probeURL := pickMMProjProbeURL(modelConfig.MMProj, cfg)
if probeURL == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
defer func() {
if r := recover(); r != nil {
xlog.Debug("[tts-importer] panic while probing mmproj GGUF header", "uri", probeURL, "recover", r)
}
}()
f, err := gguf.ParseGGUFFileRemote(ctx, probeURL)
if err != nil {
xlog.Debug("[tts-importer] failed to read remote mmproj header for gen-audio detection", "uri", probeURL, "error", err)
return
}
if !config.HasGenAudioProjector(f) {
return
}
modelConfig.KnownUsecaseStrings = []string{config.UsecaseTTS}
xlog.Info("[tts-importer] gen-audio projector detected; importing as a TTS model", "name", modelConfig.Name)
}
// pickMMProjProbeURL returns an HTTP(S) URL for the mmproj the import selected,
// or "" when none was selected or its URI cannot be range-fetched (local path,
// OCI/Ollama artifact).
func pickMMProjProbeURL(mmproj string, cfg *gallery.ModelConfig) string {
if mmproj == "" || cfg == nil {
return ""
}
for _, f := range cfg.Files {
if f.Filename == mmproj {
return resolveHTTPProbe(f.URI)
}
}
return ""
}
// pickMTPProbeURL returns an HTTP(S) URL pointing at the main (non-mmproj)
// GGUF shard that should be inspected for an MTP head, or "" when no
// suitable URL is available. Custom URI schemes (`huggingface://`,

View File

@@ -0,0 +1,56 @@
package importers
import (
"github.com/mudler/LocalAI/core/gallery"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The gen-audio probe has to find the mmproj the import actually selected.
// modelConfig.MMProj holds the TARGET filename the file will be written to,
// which is not the URI it is fetched from, so the two have to be joined
// through cfg.Files or the probe silently never runs and every Qwen3-TTS
// import stays labelled as chat.
var _ = Describe("pickMMProjProbeURL", func() {
cfgWith := func(files ...gallery.File) *gallery.ModelConfig {
return &gallery.ModelConfig{Files: files}
}
It("resolves the URI of the selected mmproj", func() {
cfg := cfgWith(
gallery.File{Filename: "llama-cpp/models/tts/model.gguf", URI: "https://example.invalid/model.gguf"},
gallery.File{Filename: "llama-cpp/mmproj/tts/mmproj.gguf", URI: "https://example.invalid/mmproj.gguf"},
)
Expect(pickMMProjProbeURL("llama-cpp/mmproj/tts/mmproj.gguf", cfg)).To(Equal("https://example.invalid/mmproj.gguf"))
})
It("resolves a huggingface:// mmproj URI to a fetchable URL", func() {
cfg := cfgWith(gallery.File{
Filename: "mmproj.gguf",
URI: "huggingface://ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf",
})
Expect(pickMMProjProbeURL("mmproj.gguf", cfg)).To(HavePrefix("https://"))
})
It("returns nothing when the import selected no mmproj", func() {
cfg := cfgWith(gallery.File{Filename: "model.gguf", URI: "https://example.invalid/model.gguf"})
Expect(pickMMProjProbeURL("", cfg)).To(BeEmpty())
})
It("returns nothing when the mmproj is not among the files", func() {
cfg := cfgWith(gallery.File{Filename: "model.gguf", URI: "https://example.invalid/model.gguf"})
Expect(pickMMProjProbeURL("mmproj.gguf", cfg)).To(BeEmpty())
})
// OCI/Ollama artifacts are not range-fetchable as a GGUF byte stream, the
// same reason the MTP probe skips them.
It("returns nothing for an OCI artifact", func() {
cfg := cfgWith(gallery.File{Filename: "mmproj.gguf", URI: "oci://quay.io/example/model:latest"})
Expect(pickMMProjProbeURL("mmproj.gguf", cfg)).To(BeEmpty())
})
It("returns nothing for a nil config", func() {
Expect(pickMMProjProbeURL("mmproj.gguf", nil)).To(BeEmpty())
})
})

View File

@@ -60,6 +60,7 @@ type APIExchange struct {
}
var traceBuffer *circularbuffer.Queue[APIExchange]
var inFlightTraces = make(map[string]APIExchange)
var mu sync.Mutex
var logChan = make(chan traceCommand, 100)
var traceIDSeq atomic.Uint64
@@ -126,16 +127,17 @@ func initializeTracing(dataPath string, maxItems int) {
continue
}
exchange := *command.exchange
mu.Lock()
delete(inFlightTraces, exchange.ID)
if traceBuffer != nil {
traceBuffer.Enqueue(exchange)
}
mu.Unlock()
if command.store != nil {
if err := command.store.Append(exchange.ID, exchange); err != nil {
xlog.Warn("Failed to persist API trace", "error", err)
}
}
mu.Lock()
if traceBuffer != nil {
traceBuffer.Enqueue(exchange)
}
mu.Unlock()
}
}()
})
@@ -261,6 +263,38 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
// tens of MB, which then locks the admin Traces UI fetching the
// JSON dump faster than the 5s auto-refresh.
maxBodyBytes := app.ApplicationConfig().TracingMaxBodyBytes
requestHeaders := redactSensitiveHeaders(c.Request().Header)
requestBody, requestTruncated := truncateForTrace(body, maxBodyBytes)
exchange := APIExchange{
ID: nextTraceID(),
Timestamp: startTime,
ClientIP: c.RealIP(),
UserAgent: c.Request().UserAgent(),
Request: APIExchangeRequest{
Method: c.Request().Method,
Path: c.Path(),
Headers: &requestHeaders,
Body: &requestBody,
BodyTruncated: requestTruncated,
BodyBytes: len(body),
},
}
if user := auth.GetUser(c); user != nil {
exchange.UserID = user.ID
exchange.UserName = user.Name
}
mu.Lock()
inFlightTraces[exchange.ID] = exchange
mu.Unlock()
queued := false
defer func() {
if queued {
return
}
mu.Lock()
delete(inFlightTraces, exchange.ID)
mu.Unlock()
}()
// Wrap response writer to capture body
resBody := new(bytes.Buffer)
@@ -287,47 +321,27 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
// the trace endpoint is admin-only but the buffer is also reachable
// via any heap-dump-style introspection, and tokens shouldn't
// outlive the request that carried them.
requestHeaders := redactSensitiveHeaders(c.Request().Header)
requestBody, requestTruncated := truncateForTrace(body, maxBodyBytes)
responseHeaders := redactSensitiveHeaders(c.Response().Header())
responseBody := make([]byte, resBody.Len())
copy(responseBody, resBody.Bytes())
exchange := APIExchange{
ID: nextTraceID(),
Timestamp: startTime,
Duration: time.Since(startTime),
ClientIP: c.RealIP(),
UserAgent: c.Request().UserAgent(),
Request: APIExchangeRequest{
Method: c.Request().Method,
Path: c.Path(),
Headers: &requestHeaders,
Body: &requestBody,
BodyTruncated: requestTruncated,
BodyBytes: len(body),
},
Response: APIExchangeResponse{
Status: status,
Headers: &responseHeaders,
Body: &responseBody,
BodyTruncated: mw.truncated,
BodyBytes: mw.totalBytes,
},
exchange.Duration = time.Since(startTime)
exchange.Response = APIExchangeResponse{
Status: status,
Headers: &responseHeaders,
Body: &responseBody,
BodyTruncated: mw.truncated,
BodyBytes: mw.totalBytes,
}
if handlerErr != nil {
exchange.Error = handlerErr.Error()
}
if user := auth.GetUser(c); user != nil {
exchange.UserID = user.ID
exchange.UserName = user.Name
}
mu.Lock()
store := traceStore
mu.Unlock()
select {
case logChan <- traceCommand{exchange: &exchange, store: store}:
queued = true
default:
xlog.Warn("Trace channel full, dropping trace")
}
@@ -345,6 +359,10 @@ func GetTraces() []APIExchange {
return []APIExchange{}
}
traces := traceBuffer.Values()
for _, exchange := range inFlightTraces {
exchange.Duration = time.Since(exchange.Timestamp)
traces = append(traces, exchange)
}
mu.Unlock()
slices.SortFunc(traces, func(a, b APIExchange) int {

View File

@@ -0,0 +1,108 @@
// SPDX-License-Identifier: MIT
package middleware
import (
"net/http"
"net/http/httptest"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("live API traces", func() {
newApp := func(root string) *application.Application {
app, err := application.New(
config.EnableTracing,
config.WithDataPath(root),
config.WithDisableLocalAIAssistant(true),
config.WithDisableStats(true),
config.WithSystemState(&system.SystemState{
Model: system.Model{ModelsPath: root},
Backend: system.Backend{BackendsPath: root},
}),
)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { Expect(app.Shutdown()).To(Succeed()) })
ClearTraces()
return app
}
It("lists a request while its handler is still running", func() {
root := GinkgoT().TempDir()
app := newApp(root)
started := make(chan struct{})
release := make(chan struct{})
DeferCleanup(func() {
select {
case <-release:
default:
close(release)
}
})
handler := TraceMiddleware(app)(func(c echo.Context) error {
close(started)
<-release
return c.NoContent(http.StatusNoContent)
})
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/slow", http.NoBody)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
ctx.SetPath("/slow")
done := make(chan error, 1)
go func() {
done <- handler(ctx)
}()
<-started
var running APIExchange
Eventually(func() bool {
traces := GetTraces()
if len(traces) != 1 {
return false
}
running = traces[0]
return running.Request.Path == "/slow"
}).Should(BeTrue())
Expect(running.Response.Status).To(Equal(0))
Expect(running.Duration).To(BeNumerically(">", 0))
close(release)
Expect(<-done).To(Succeed())
Eventually(func() []APIExchange { return GetTraces() }).Should(ConsistOf(
And(
HaveField("ID", running.ID),
HaveField("Response.Status", http.StatusNoContent),
HaveField("Duration", BeNumerically(">", time.Duration(0))),
),
))
})
It("removes an in-flight trace when the handler panics", func() {
app := newApp(GinkgoT().TempDir())
handler := TraceMiddleware(app)(func(echo.Context) error {
panic("handler panic")
})
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/panic", http.NoBody)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
ctx := e.NewContext(req, httptest.NewRecorder())
ctx.SetPath("/panic")
func() {
defer func() { _ = recover() }()
_ = handler(ctx)
}()
Expect(GetTraces()).To(BeEmpty())
})
})

View File

@@ -0,0 +1,22 @@
import { test, expect } from './coverage-fixtures.js'
test('marks an API trace with no response status as in progress', async ({ page }) => {
await page.route('**/api/traces?*', route => route.fulfill({
json: [{
id: 'running-1',
timestamp: '2026-08-05T02:00:00Z',
duration: 2_000_000_000,
request: { method: 'POST', path: '/v1/chat/completions' },
response: { status: 0 },
}],
headers: { 'X-Total-Count': '1' },
}))
await page.route('**/api/backend-traces?*', route => route.fulfill({ json: [] }))
await page.goto('/app/traces')
const row = page.locator('tbody tr').filter({ hasText: '/v1/chat/completions' })
await expect(row.getByText('Running', { exact: true })).toBeVisible()
await expect(row.locator('[title="In progress"]')).toBeVisible()
await expect(row.locator('.fa-check-circle')).toHaveCount(0)
})

View File

@@ -664,10 +664,16 @@ export default function Traces() {
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
<td className="text-mono text-sm">{trace.request?.path || '-'}</td>
<td className="text-sub cell-clip" title={trace.user_name || trace.user_id || ''}>{trace.user_name || trace.user_id || '-'}</td>
<td><span className={`badge ${(trace.response?.status || 0) < 400 ? 'badge-success' : 'badge-error'}`}>{trace.response?.status || '-'}</span></td>
<td>
{trace.response?.status === 0
? <span className="badge badge-info">Running</span>
: <span className={`badge ${trace.response.status < 400 ? 'badge-success' : 'badge-error'}`}>{trace.response.status}</span>}
</td>
<td><LatencyCell ns={trace.duration} max={slowestTrace} /></td>
<td className="text-center">
{trace.error
{trace.response?.status === 0
? <i className="fas fa-spinner fa-spin text-primary" title="In progress" />
: trace.error
? <i className="fas fa-times-circle text-error" title={trace.error} />
: <i className="fas fa-check-circle text-success" />}
</td>

View File

@@ -11,3 +11,21 @@ func TestUsecaseFiltersIncludes3D(t *testing.T) {
g := gomega.NewWithT(t)
g.Expect(usecaseFilters[config.Usecase3D]).To(gomega.Equal(config.FLAG_3D))
}
// GET /api/backends/usecases projects each backend's PossibleUsecases through
// usecaseFilters, and the gallery greys out any filter a selected backend does
// not report. llama-cpp serves Qwen3-TTS, so the TTS filter has to survive that
// projection or the gallery hides the very entries the backend can run.
func TestBackendUsecasesReportsTTSForLlamaCpp(t *testing.T) {
g := gomega.NewWithT(t)
var keys []string
for _, uc := range config.BackendCapabilities["llama-cpp"].PossibleUsecases {
if _, ok := usecaseFilters[uc]; ok {
keys = append(keys, uc)
}
}
g.Expect(keys).To(gomega.ContainElement(config.UsecaseTTS))
g.Expect(keys).To(gomega.ContainElement(config.UsecaseChat))
}

View File

@@ -72,6 +72,44 @@ tags:
- "text-generation"
```
### Verifying OCI Backends
Backend galleries can require keyless Sigstore signatures for every OCI image
they provide. Add a `verification` policy to the gallery configuration, then
enable strict integrity mode:
```bash
export LOCALAI_BACKEND_GALLERIES='[{"name":"localai","url":"github:mudler/LocalAI/backend/index.yaml@master","verification":{"issuer":"https://token.actions.githubusercontent.com","identity_regex":"^https://github\\.com/mudler/LocalAI/\\.github/workflows/backend_merge\\.yml@refs/(heads/master|tags/.+)$"}}]'
export LOCALAI_REQUIRE_BACKEND_INTEGRITY=1
local-ai run
```
The policy pins the Fulcio issuer and the GitHub Actions workflow identity that
signed the image. The identity expression covers development images produced
from `master` and release images produced from tags. Use a narrower expression
if your deployment only accepts one release channel.
Without strict mode, an OCI gallery without a verification policy installs
with a warning. With strict mode, LocalAI refuses galleries without a policy,
images without a compatible Sigstore bundle, and signatures that do not match
the configured identity. Existing images published before bundle signing was
enabled must be rebuilt or re-signed before strict deployments can install
them.
An optional `not_before` RFC3339 value revokes signatures logged before that
time. Advance it after a signing-workflow compromise, then rebuild or re-sign
the trusted images:
```json
{
"verification": {
"issuer": "https://token.actions.githubusercontent.com",
"identity_regex": "^https://github\\.com/mudler/LocalAI/\\.github/workflows/backend_merge\\.yml@refs/(heads/master|tags/.+)$",
"not_before": "2026-08-05T00:00:00Z"
}
}
```
## Pre-installing Backends
You can pre-install backends when starting LocalAI using the `LOCALAI_EXTERNAL_BACKENDS` environment variable:

View File

@@ -130,6 +130,7 @@ When a saved profile is selected, LocalAI supplies both its private WAV and exac
| --- | --- |
| `chatterbox`, `faster-qwen3-tts`, `fish-speech`, `moss-tts-cpp`, `neutts`, `omnivoice-cpp`, `pocket-tts`, `voxcpm` | Reference-audio cloning models served by these dedicated backends. |
| `qwen-tts`, `qwen3-tts-cpp`, `vllm-omni` | Base or VoiceClone variants. CustomVoice and VoiceDesign variants are not raw reference-audio models. |
| `llama-cpp` | Models that declare `known_usecases: [tts]`, which the Qwen3-TTS gallery entries (`qwen3-tts-llamacpp`, `qwen3-tts-llamacpp-q4`) do. A reference clip is required, since the Base checkpoints have no built-in speaker. Ordinary GGUF chat and vision models served by this backend are excluded. |
| `vibevoice-cpp` | 1.5B reference-WAV variants. The realtime 0.5B preset-prompt model is excluded. |
| `coqui` | XTTS and YourTTS variants. |
| `crispasr` | F5-TTS variants. ASR, Piper, Orpheus, and other CrispASR model families are excluded. |
@@ -170,7 +171,7 @@ curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
}' > output.wav
```
Note: Streaming TTS is currently supported by the `voxcpm` backend. Other backends will fall back to non-streaming mode if streaming is not supported.
Note: Streaming TTS is implemented by the `audio-cpp`, `crispasr`, `llama-cpp`, `magpie-tts-cpp`, `moss-tts-cpp`, `omnivoice-cpp`, `qwen3-tts-cpp`, `sherpa-onnx`, `supertonic`, `vibevoice-cpp` and `voxcpm` backends. Other backends will fall back to non-streaming mode if streaming is not supported.
## Backends
@@ -535,6 +536,59 @@ tts:
audio_path: voices/default-reference.wav # optional fallback
```
#### llama.cpp gallery variants
llama.cpp gained native Qwen3-TTS support in [ggml-org/llama.cpp#26254](https://github.com/ggml-org/llama.cpp/pull/26254), so the `llama-cpp` backend can serve it on the same accelerator matrix it already uses for text generation: CUDA, ROCm, SYCL, Vulkan and Metal.
Install `qwen3-tts-llamacpp` (Q8_0 backbone) or `qwen3-tts-llamacpp-q4` (Q4_K_M backbone) from the Model gallery, or run `local-ai models install qwen3-tts-llamacpp-q4`.
These models load two files: the backbone GGUF and a multimodal projector holding the speaker encoder and code predictor. A hand-written configuration must point at both:
```yaml
name: qwen3-tts-llamacpp
backend: llama-cpp
known_usecases:
- tts
mmproj: qwen3-tts-llamacpp/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
parameters:
model: qwen3-tts-llamacpp/Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
```
`known_usecases: [tts]` is not optional here. It is how LocalAI tells a Qwen3-TTS checkpoint apart from the text and vision GGUFs the same backend serves: without it the model is treated as a chat model, its projector is read as a vision tower, and Voice Library profiles are refused.
Importing such a repo through the Models page or `local-ai models import` writes that declaration for you. The importer reads the projector's header and recognises the speech-synthesis pipeline, so `ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF` imports as a TTS model rather than as a chat model with a vision projector.
The upstream checkpoints are Base variants with no built-in speaker, so `voice` is **required** on every request. Pass either a path to a reference clip or a saved Voice Library profile. A request without one is rejected rather than served in an arbitrary voice:
```bash
curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
"model": "qwen3-tts-llamacpp",
"input": "Hello world, this is a test.",
"voice": "voices/my-reference.wav"
}' > output.wav
```
Output is always 24 kHz mono 16-bit WAV. Streaming works on this backend, so `"stream": true` returns audio chunk by chunk as it is generated.
Set `language` to an ISO 639-1 code to pin the output language. The supported codes are `zh`, `en`, `de`, `it`, `pt`, `es`, `ja`, `ko`, `fr` and `ru`.
Three optional knobs travel in `params`: `top_k` and `top_p` adjust sampling, and `max_frames` caps how much audio a single request may generate. The model runs at 12.5 frames per second, so one frame is 0.08 seconds and the maximum duration in seconds is `max_frames / 12.5`. Leave it unset for the engine default of 512 frames, which is 40.96 seconds.
`max_frames` exists because generation occasionally fails to stop on its own. The model normally ends an utterance by emitting its end-of-speech token, but once in a while it does not, and the request then runs to the cap and returns far more audio than the text called for. It is uncommon, and it happens more on short inputs than on long ones. If you are synthesising predictable text and want a hard bound, allow roughly 8 frames per word: about 100 frames (8 seconds) for a short sentence, about 300 frames (24 seconds) for a paragraph.
```bash
curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
"model": "qwen3-tts-llamacpp",
"input": "Hello world, this is a test.",
"voice": "voices/my-reference.wav",
"params": {"max_frames": "100"}
}' > output.wav
```
This backend accepts but ignores `instructions`, `speed` and `sample_rate`. The Base checkpoints have no expressive-style or rate control, and the output rate is fixed at 24 kHz.
Note that `qwen3-tts-cpp` (qwentts.cpp) remains available and is unaffected. It is a separate, independently maintained path to the same family of weights, not something this replaces.
#### Usage
Use the tts endpoint by specifying the qwen-tts backend:

View File

@@ -9,6 +9,11 @@ LocalAI can retain recent API exchanges and backend operations for inspection
on the **Traces** page in the management interface. Enable tracing in runtime
settings or with the existing tracing configuration.
API requests appear while they are still running. Their elapsed duration
updates when the page refreshes, and the result column marks them as in
progress until the response completes. In-flight requests live only in memory;
the completed exchange is what LocalAI adds to the bounded, persistent history.
API and backend trace histories are persisted in separate directories below
the configured data path. They are restored after a clean service restart,
whether or not authentication is enabled.

View File

@@ -1992,7 +1992,7 @@
files:
- filename: ds4flash.gguf
uri: https://huggingface.co/unsloth/DeepSeek-V4-Flash-GGUF
sha256: 1bfdafd1c288eb1b2bcb629ee9e1b7567dcf0abbe4d20995905a3c3465e9bd1e
sha256: ba1d64ad8d77038124839956b614db2e889daa1a4ddc83060bb06ccb5a1d7461
- name: "qwopus3.6-35b-a3b-coder-mtp"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -2091,6 +2091,83 @@
- filename: llama-cpp/models/Qwen-AgentWorld-35B-A3B-GGUF/Qwen-AgentWorld-35B-A3B-UD-Q4_K_M.gguf
sha256: e7a8eafdd8013443b6bcc4b6fb47b2d2025f772d359650b9ceb7d75971e22cad
uri: https://huggingface.co/unsloth/Qwen-AgentWorld-35B-A3B-GGUF/resolve/main/Qwen-AgentWorld-35B-A3B-UD-Q4_K_M.gguf
- &agents-a1-4b
name: "agents-a1-4b"
variants:
- model: agents-a1-4b-q8
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/InternScience/Agents-A1-4B
- https://huggingface.co/InternScience/Agents-A1-4B-Q4_K_M-GGUF
description: |
Agents-A1-4B is InternScience's Apache-2.0 dense 4B agentic model, based on
Qwen3.5. It is trained for long-horizon search, engineering and scientific
research, instruction following, tool use, and multimodal tasks. This entry
uses the official Q4_K_M GGUF quantization and vision projector.
license: "apache-2.0"
tags:
- llm
- gguf
- vision
- multimodal
- gpu
- cpu
icon: https://huggingface.co/InternScience/Agents-A1-4B/resolve/main/figures/logo_nobg.png
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
mmproj: llama-cpp/mmproj/Agents-A1-4B-Q4_K_M/Agents-A1-4B-mmproj.gguf
options:
- use_jinja:true
parameters:
model: llama-cpp/models/Agents-A1-4B-Q4_K_M/Agents-A1-4B-Q4_K_M.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/Agents-A1-4B-Q4_K_M/Agents-A1-4B-Q4_K_M.gguf
sha256: d93c393a9bd5139a4b5cfe24d31ef553c5a497bfb8afec178a354ecbf508f062
uri: huggingface://InternScience/Agents-A1-4B-Q4_K_M-GGUF/Agents-A1-4B-Q4_K_M.gguf
- filename: llama-cpp/mmproj/Agents-A1-4B-Q4_K_M/Agents-A1-4B-mmproj.gguf
sha256: 254145e7e03e9e8d3120813fac8033ffa04e411eb6d70a198833504935681084
uri: huggingface://InternScience/Agents-A1-4B-Q4_K_M-GGUF/Agents-A1-4B-mmproj.gguf
- !!merge <<: *agents-a1-4b
name: "agents-a1-4b-q8"
variants: []
urls:
- https://huggingface.co/InternScience/Agents-A1-4B
- https://huggingface.co/InternScience/Agents-A1-4B-Q8_0-GGUF
description: |
Agents-A1-4B is InternScience's Apache-2.0 dense 4B agentic model, based on
Qwen3.5. It is trained for long-horizon search, engineering and scientific
research, instruction following, tool use, and multimodal tasks. This entry
uses the official Q8_0 GGUF quantization and vision projector.
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
mmproj: llama-cpp/mmproj/Agents-A1-4B-Q8_0/Agents-A1-4B-mmproj.gguf
options:
- use_jinja:true
parameters:
model: llama-cpp/models/Agents-A1-4B-Q8_0/Agents-A1-4B-Q8_0.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/Agents-A1-4B-Q8_0/Agents-A1-4B-Q8_0.gguf
sha256: c327f66e820dae550bd230394595071c79f48c88d411b452d013ee4b5999fcea
uri: huggingface://InternScience/Agents-A1-4B-Q8_0-GGUF/Agents-A1-4B-Q8_0.gguf
- filename: llama-cpp/mmproj/Agents-A1-4B-Q8_0/Agents-A1-4B-mmproj.gguf
sha256: 254145e7e03e9e8d3120813fac8033ffa04e411eb6d70a198833504935681084
uri: huggingface://InternScience/Agents-A1-4B-Q8_0-GGUF/Agents-A1-4B-mmproj.gguf
- name: "ornith-1.0-9b"
variants:
- model: ornith-1.0-9b-mtp
@@ -2614,6 +2691,83 @@
- filename: llama-cpp/models/LFM2.5-1.2B-Instruct-GGUF/LFM2.5-1.2B-Instruct-Q4_K_M.gguf
sha256: b1b3de114215d9507409a662a501a631095a479a419584e8a2ded6304b19b4f5
uri: https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF/resolve/main/LFM2.5-1.2B-Instruct-Q4_K_M.gguf
- &lfm2-5-2-6b
name: "lfm2.5-2.6b"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/LiquidAI/LFM2.5-2.6B
- https://huggingface.co/LiquidAI/LFM2.5-2.6B-GGUF
description: |
LFM2.5-2.6B is LiquidAI's compact, text-only reasoning model for on-device
agentic workloads. It has 2.69B parameters, a 128K-token context window,
multilingual support, and post-training for tool use, instruction following,
data extraction, RAG, and multi-step agents. This entry uses the recommended
Q4_K_M GGUF quantization from LiquidAI's official repository.
license: "other"
tags:
- llm
- gguf
- reasoning
- cpu
- gpu
icon: https://cdn-uploads.huggingface.co/production/uploads/61b8e2ba285851687028d395/2b08LKpev0DNEk6DlnWkY.png
variants:
- model: lfm2.5-2.6b-q8
overrides:
backend: llama-cpp
context_size: 131072
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
- completion
options:
- use_jinja:true
parameters:
model: llama-cpp/models/LFM2.5-2.6B-GGUF/LFM2.5-2.6B-Q4_K_M.gguf
repeat_penalty: 1.1
temperature: 0.1
top_k: 50
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/LFM2.5-2.6B-GGUF/LFM2.5-2.6B-Q4_K_M.gguf
sha256: 79fdf00351b46cf26f020aead28d01889886be87c55fa0eb907e6f9b00bfee14
uri: https://huggingface.co/LiquidAI/LFM2.5-2.6B-GGUF/resolve/main/LFM2.5-2.6B-Q4_K_M.gguf
- !!merge <<: *lfm2-5-2-6b
name: "lfm2.5-2.6b-q8"
description: |
LFM2.5-2.6B is LiquidAI's compact, text-only reasoning model for on-device
agentic workloads. It has 2.69B parameters, a 128K-token context window,
multilingual support, and post-training for tool use, instruction following,
data extraction, RAG, and multi-step agents. This entry uses the higher-quality
Q8_0 GGUF quantization from LiquidAI's official repository.
variants: null
overrides:
backend: llama-cpp
context_size: 131072
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
- completion
options:
- use_jinja:true
parameters:
model: llama-cpp/models/LFM2.5-2.6B-GGUF/LFM2.5-2.6B-Q8_0.gguf
repeat_penalty: 1.1
temperature: 0.1
top_k: 50
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/LFM2.5-2.6B-GGUF/LFM2.5-2.6B-Q8_0.gguf
sha256: 36587fdf27bdfc69caf2637273679a0870ec155162161bde6fd16e8c70bdb757
uri: https://huggingface.co/LiquidAI/LFM2.5-2.6B-GGUF/resolve/main/LFM2.5-2.6B-Q8_0.gguf
- name: "qwopus3.6-27b-coder-compat-mtp"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -4931,6 +5085,12 @@
known_usecases:
- chat
- completion
- vision
known_input_modalities:
- text
- image
- video
- audio
parameters:
min_p: 0.01
model: mudler/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-APEX-GGUF
@@ -8726,6 +8886,69 @@
- filename: qwen3-tts-cpp-1.7b-voicedesign-q4/qwen-tokenizer-12hz-Q4_K_M.gguf
sha256: cf3788b4d50aaa665fb6e57c170396aae03a3555fea52d2b5d0cda902d658039
uri: huggingface://Serveurperso/Qwen3-TTS-GGUF/qwen-tokenizer-12hz-Q4_K_M.gguf
- &qwen3ttsllamacpp_gallery
name: qwen3-tts-llamacpp
variants:
- model: qwen3-tts-llamacpp-q4
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:
- https://huggingface.co/ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF
- https://github.com/ggml-org/llama.cpp/pull/26254
description: |
Qwen3-TTS 1.7B Base served by the llama.cpp backend, using upstream's own
GGUF conversion. Runs on the full llama-cpp accelerator matrix (CUDA, ROCm,
SYCL, Vulkan, Metal). Streaming output and zero-shot voice cloning: set
`voice` to a reference clip or a saved Voice Library profile, which is
required since the Base checkpoint has no built-in speaker. 24kHz mono,
10 languages. Q8_0 backbone (~1.8 GB) plus a Q8_0 projector.
license: apache-2.0
icon: https://avatars.githubusercontent.com/u/12608286?s=200&v=4
tags:
- tts
- text-to-speech
- voice-cloning
- streaming
- qwen3-tts
- llama-cpp
- gguf
last_checked: "2026-08-05"
overrides:
backend: llama-cpp
known_usecases:
- tts
name: qwen3-tts-llamacpp
mmproj: qwen3-tts-llamacpp/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
parameters:
model: qwen3-tts-llamacpp/Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
files:
- filename: qwen3-tts-llamacpp/Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
sha256: ac7931aeb2e7aad1a6ed6602d353a5679c9d096b18ce8204ac730a8408d572e1
uri: huggingface://ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF/Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
- filename: qwen3-tts-llamacpp/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
sha256: 6fd65188839bcd6ecc91b277ad471e22a0edfada4699a0fe82f1165c18cfcce2
uri: huggingface://ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
- !!merge <<: *qwen3ttsllamacpp_gallery
name: qwen3-tts-llamacpp-q4
variants: []
description: |
Qwen3-TTS 1.7B Base served by the llama.cpp backend, Q4_K_M backbone
(~1.1 GB) plus a Q8_0 projector. Streaming and voice cloning, 24kHz mono,
10 languages. A `voice` reference clip is required.
overrides:
backend: llama-cpp
known_usecases:
- tts
name: qwen3-tts-llamacpp-q4
mmproj: qwen3-tts-llamacpp-q4/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
parameters:
model: qwen3-tts-llamacpp-q4/Qwen3-TTS-12Hz-1.7B-Base-Q4_K_M.gguf
files:
- filename: qwen3-tts-llamacpp-q4/Qwen3-TTS-12Hz-1.7B-Base-Q4_K_M.gguf
sha256: 8d18c94acb2addd042f97da63c98be144eafa76d0d9495177eab65130cf85129
uri: huggingface://ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF/Qwen3-TTS-12Hz-1.7B-Base-Q4_K_M.gguf
- filename: qwen3-tts-llamacpp-q4/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
sha256: 6fd65188839bcd6ecc91b277ad471e22a0edfada4699a0fe82f1165c18cfcce2
uri: huggingface://ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF/mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf
- &mossttscpp_gallery
name: moss-tts-cpp-v1_5-q8_0
variants:

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
WORKFLOW="$(dirname "$(realpath "$0")")/../../.github/workflows/backend_merge.yml"
sign_commands=$(grep -Ec -- '^[[:space:]]+cosign sign([[:space:]]|$)' "$WORKFLOW" || true)
bundle_flags=$(grep -Ec -- '^[[:space:]]+--new-bundle-format([[:space:]]|$)' "$WORKFLOW" || true)
if [ "$sign_commands" -ne 2 ] || [ "$bundle_flags" -ne "$sign_commands" ]; then
echo "FAIL: every backend signing command must request the new bundle format (commands=$sign_commands flags=$bundle_flags)"
exit 1
fi
echo "PASS: backend signing emits Sigstore bundles for both registries"

View File

@@ -436,27 +436,6 @@ export const SHARED_BUILD_INPUTS = [
linux: always,
darwin: always,
},
{
// Same posture as the scripts/build/ catch-all above, and for the same
// reason. .docker/ holds the per-backend compile and build-target scripts
// (llama-cpp, turboquant, bonsai, ik-llama-cpp) plus inputs every
// Dockerfile consumes (apt-mirror.sh, install-base-deps.sh). Nothing
// matched any of them before, which is how #11346 shipped without a single
// backend job: it changed how ROCm llama.cpp compiles, touching only
// .docker/llama-cpp-build-target.sh and a `*_test.sh` that the rule above
// deliberately carves out, so the filter selected zero entries and every
// backend job reported "skipping".
//
// A rule cannot see which file matched it, only the matrix entry, so
// narrowing `.docker/<name>-compile.sh` to the backend named by its prefix
// would mean threading the filename through matchedSharedRules. Until
// someone wants that, take the full matrix: these files are edited a
// handful of times a release, and a shared build input silently shipping
// to nothing is the failure this list exists to prevent.
matches: file => file.startsWith(".docker/"),
linux: always,
darwin: always,
},
];
// The matrix stores dockerfiles as "./backend/Dockerfile.python"; changed-file

View File

@@ -204,29 +204,6 @@ test("an unclassified scripts/build/ file conservatively rebuilds everything", (
assert.equal(filteredDarwin.length, includesDarwin.length);
});
// #11346 changed how ROCm llama.cpp compiles and built nothing: it touched only
// .docker/llama-cpp-build-target.sh and a *_test.sh, no rule matched either, so
// every backend job reported "skipping".
test("a .docker/ compile script rebuilds the backends it compiles", () => {
const { filtered } = run([
".docker/llama-cpp-build-target.sh",
"scripts/build/llama-cpp-build-target_test.sh",
]);
assert.ok(filtered.length > 0, ".docker/ change selected no entries");
assert.ok(
filtered.some(e => e.backend === "llama-cpp"),
"llama-cpp was not selected by a change to its own compile script",
);
});
test("a shared .docker/ input rebuilds everything", () => {
const { filtered, filteredDarwin } = run([".docker/apt-mirror.sh"]);
assert.equal(filtered.length, includes.length);
assert.equal(filteredDarwin.length, includesDarwin.length);
});
test("tests for the packaging scripts do not rebuild anything", () => {
const { filtered, filteredDarwin } = run([
"scripts/build/package-gpu-libs_test.sh",

View File

@@ -1,14 +1,14 @@
---
title: "What landed in LocalAI 4.8"
date: 2026-08-01
date: 2026-08-04
author: "Ettore Di Giacinto"
category: "Release"
tags: ["release", "vllm.cpp", "audio.cpp", "3d", "gallery", "distributed", "performance"]
summary: "A new inference engine, 3D generation, one backend that serves six audio endpoints, and a web interface 3.48x lighter. 321 pull requests in eighteen days."
tags: ["release", "vllm.cpp", "audio.cpp", "3d", "agent", "gallery", "distributed", "performance"]
summary: "A new inference engine, a terminal agent in the CLI, 3D generation, and a web interface 3.48x lighter. 386 pull requests in twenty-two days."
extracss: ["blog.css"]
---
LocalAI 4.8.0 is out. It took eighteen days and 321 merged pull requests, and it pulls in two directions at once: three new things LocalAI can do that it could not do before, and a long list of places where it now does the old things without lying to you.
LocalAI 4.8.0 is out, after twenty-two days and 386 merged pull requests. There are four new things LocalAI can do, and a lot of repair work on things it already did.
The full notes list everything. This post covers the parts that change what you do day to day, with the pull request numbers so you can read the diffs.
@@ -36,6 +36,11 @@ The third one was `/api/traces` returning a 21 MB unpaginated blob that the UI p
## One gallery entry, several builds
<figure>
<img src="/media/v4-8-0-ui-model-variants.png" alt="The model detail pane listing every variant">
<figcaption>One entry, four builds. LocalAI picks the largest that fits and marks it auto-selected.</figcaption>
</figure>
Installing a model no longer means reading a list of quantizations and guessing which one your card will hold. A gallery entry can now declare `variants:`, a list of references to other entries that are alternative builds of the same weights:
```yaml
@@ -55,12 +60,43 @@ Every surface can override the choice: `variant` on `POST /models/apply`, `local
One gap worth knowing about: in distributed mode `InstallModel` resolves against the frontend rather than the worker that will serve the model, so a cluster with a small frontend and large workers selects conservatively. PRs [#10943](https://github.com/mudler/LocalAI/pull/10943), [#10983](https://github.com/mudler/LocalAI/pull/10983), [#10992](https://github.com/mudler/LocalAI/pull/10992), [#11027](https://github.com/mudler/LocalAI/pull/11027) and [#11139](https://github.com/mudler/LocalAI/pull/11139).
## A new engine: vllm.cpp
## A new engine: vllm.cpp (alpha)
[vllm.cpp](https://github.com/mudler/vllm.cpp) is a from-scratch C++20 port of vLLM, written and maintained by the LocalAI team under Apache-2.0, and it ships here as the `vllm-cpp` backend ([#11100](https://github.com/mudler/LocalAI/pull/11100)). It mirrors vLLM's V1 architecture, so paged KV cache, continuous batching, prefix caching, scheduler and sampler, on a portable tensor runtime with no Python, no PyTorch and no ggml at inference. It loads Hugging Face safetensors and GGUF, enforces structured output inside the engine (JSON schema, regex, choice, GBNF), and builds for CPU amd64 and arm64, CUDA 12 and 13 including Blackwell, L4T for GB10, Vulkan and Darwin Metal.
[vllm.cpp](https://github.com/mudler/vllm.cpp) is Apache-2.0 and maintained by the LocalAI team. We want it community-first rather than a LocalAI-only engine, so it lives in its own repository with its own docs, benchmark record and issue tracker, and it runs without LocalAI anywhere in the picture. It began as a C++20 port of vLLM. It ships here as the `vllm-cpp` backend ([#11100](https://github.com/mudler/LocalAI/pull/11100)). It implements vLLM's V1 architecture, so paged KV cache, continuous batching, prefix caching, scheduler and sampler, on a portable tensor runtime with no Python, no PyTorch and no ggml at inference. vLLM stays its reference implementation: correctness is checked by comparing output against it, and the benchmark scoreboard is kept against it.
It has grown features vLLM does not have, which is most of the reason the port exists. It loads GGUF as well as safetensors, runs on CPU, Apple Metal and Vulkan alongside CUDA 12 and 13 and L4T for GB10, and ships speculative decoding and KV offload. Its benchmark page now measures against llama.cpp, MLX-LM and DwarfStar as well as vLLM, because on that hardware those are the engines it competes with. The project is expected to be renamed, with the new name still to be decided; it is drifting far enough that vllm.cpp will eventually mislead.
Tool calling is at llama.cpp parity by construction, because chat deliberately reuses the same autoparser path: full minja chat templates, `tool_choice: auto` lowered to a lazy structural-tag decode constraint, 30 tool dialects, 7 reasoning parsers, and streamed `ChatDelta` and `ToolCallDelta`.
<figure>
<img src="/media/v4-8-0-vllm-cpp-scoreboard.png" alt="Throughput of vllm.cpp relative to each reference engine, drawn as deviation from parity">
<figcaption>llama.cpp is left out because its 1.18x is a prefill ratio, and putting that on the same axis as throughput would compare two different measurements.</figcaption>
</figure>
Numbers from the project's own [scoreboard](https://github.com/mudler/vllm.cpp/blob/master/docs/BENCHMARKS.md), which calls ties ties and losses losses. Above 1.0 means vllm.cpp is ahead:
<div class="tw">
<table>
<thead><tr><th>Reference</th><th>Workload</th><th>Result</th></tr></thead>
<tbody>
<tr><td>vLLM</td><td>Qwen3.6-27B NVFP4, GB10</td><td>1.045x at concurrency 1, 1.007x to 1.017x from c2 to c32, output token-for-token identical</td></tr>
<tr><td>vLLM</td><td>Qwen3.6-35B-A3B NVFP4, GB10</td><td>1.010x at c16 and 1.013x at c32, behind from c1 to c8 (0.817x at c1)</td></tr>
<tr><td>llama.cpp</td><td>Qwen3.5-2B GGUF, CPU aarch64</td><td>prefill 1.18x, decode a tie, memory parity</td></tr>
<tr><td>MLX-LM</td><td>Qwen3-0.6B, Apple M4</td><td>97.6% of warm total, prefill ahead</td></tr>
<tr><td>DwarfStar (ds4)</td><td>DeepSeek-V4-Flash IQ2_XXS, one DGX Spark</td><td>18.69 vs 16.33 tok/s decode, <b>1.144x</b>, same output</td></tr>
<tr><td>vLLM</td><td>Laguna-XS-2.1 NVFP4, GB10</td><td>44.46 vs 43.10 tok/s, <b>1.03x</b>, same output</td></tr>
</tbody>
</table>
</div>
The upstream page is careful about its own noise: on the 27B grid the run-to-run spread is 0.5% and c2 through c32 land between 0.7% and 1.7%, so it calls those five ties rather than wins. The concurrency-1 result is the one it stands behind.
The DeepSeek-V4-Flash row is the one that shows how far this has moved from being a vLLM port. It runs DeepSeek-V4-Flash at roughly 2-bit (IQ2_XXS mixed, about 80 GB) on a single DGX Spark, decoding at 18.69 tok/s against DwarfStar's 16.33. At 300B+ total parameters even a 4-bit checkpoint is 156 GB or more, so a 2-bit GGUF is what fits inside the Spark's 119 GiB unified pool, and reading GGUF is what makes that possible.
That number moved twice in a week, and the second move came from one lever. The dense Q8_0 projection tower was being read from the GGUF mmap over unified memory, which the GB10 reads about 20% slower per-GEMV than device memory. Staging that 6 GiB tower device-resident once at load, same bytes and same kernels, took decode from 16.23 to 18.69, generating the same tokens and using no more peak memory. The same change took Laguna-XS-2.1 from 87% of vLLM to 1.03x ahead of it.
Speculative decoding is in similar shape: MTP on Qwen3.6-27B NVFP4 generates the same tokens as vLLM's MTP and runs about 4% faster at concurrency 1.
Configuration is a normal backend install:
```yaml
@@ -73,9 +109,24 @@ options:
- max_num_seqs:16 # also: block_size:<n>, num_blocks:<n>
```
The CPU path is verified end to end against `Qwen3.5-2B-UD-Q8_K_XL.gguf` with the full Ginkgo suite, covering blocking and streaming byte-parity, greedy determinism, stop words, GBNF-constrained generation, concurrent streams, reasoning split and both `required` and `auto` tool calls. The maturity statement from the release notes is worth repeating in full:
**Treat these as alpha development builds, not a released backend.** vllm.cpp is early, and shipping it in 4.8 is about getting it in front of people who want to try it, not about recommending it for anything you care about. `llama-cpp` stays the default for real use.
> The GPU images build and ship, but their runtime behavior has not been through the same e2e gate yet. This is a first release of a young engine: no throughput comparison against upstream vLLM is claimed here, and `llama-cpp` remains the default recommendation for general use. Try it, and please report what breaks.
The CPU path is verified end to end against `Qwen3.5-2B-UD-Q8_K_XL.gguf` with the full Ginkgo suite, covering blocking and streaming byte-parity, greedy determinism, stop words, GBNF-constrained generation, concurrent streams, reasoning split and both `required` and `auto` tool calls. The GPU images build and ship, but their runtime behavior has not been through that gate. No throughput comparison against upstream vLLM is claimed. Expect rough edges, and please report what breaks.
On Apple Silicon the image now ships vllm.cpp's MLX GEMM provider ([#11137](https://github.com/mudler/LocalAI/pull/11137)). Upstream keeps it off by default because it adds about 124 MB, so we measured before turning it on. Qwen3-1.7B-bf16 on an M4, p=512 g=128, both arms toggled on one binary so a build difference cannot explain the gap:
<div class="tw">
<table>
<thead><tr><th>Batch</th><th>MLX tok/s</th><th>native tok/s</th><th>speedup</th><th>MLX TTFT</th><th>native TTFT</th></tr></thead>
<tbody>
<tr><td>1</td><td>5.79</td><td>3.08</td><td><b>1.88x</b></td><td>3.32 s</td><td>7.68 s</td></tr>
<tr><td>4</td><td>15.75</td><td>10.24</td><td><b>1.54x</b></td><td>9.63 s</td><td>18.77 s</td></tr>
<tr><td>16</td><td>38.65</td><td>17.69</td><td><b>2.19x</b></td><td>18.33 s</td><td>54.48 s</td></tr>
</tbody>
</table>
</div>
Two reps, with rep spread reaching 9.4%, so treat the multipliers as +/-10%. Time to first token roughly halves across the range.
<figure>
<video src="/media/vllm-race.mp4" muted loop playsinline preload="none" data-lazy aria-label="vllm.cpp generating tokens"></video>
@@ -84,7 +135,7 @@ The CPU path is verified end to end against `Qwen3.5-2B-UD-Q8_K_XL.gguf` with th
## LocalAI generates 3D models now
This is a new modality rather than a new backend under an existing one, so it goes through the whole stack: a `Generate3D` RPC in `backend.proto`, a `FLAG_3D` capability so the loader knows which backends can serve it, and `POST /v1/3d/generations`.
3D generation is a new modality, so it had to be wired through the whole stack: a `Generate3D` RPC in `backend.proto`, a `FLAG_3D` capability so the loader knows which backends can serve it, and `POST /v1/3d/generations`.
The first engine behind it is `trellis2cpp`, an image-to-3D backend over TRELLIS.2. You give it an image, you get a GLB back. The web UI has a page for it with a native GLB viewer, so you can turn the result around in the browser instead of downloading it to find out whether it worked, history kept in IndexedDB so a reload does not lose your generations, and previewable print remeshing for output you actually intend to send to a printer ([#10979](https://github.com/mudler/LocalAI/pull/10979)).
@@ -93,9 +144,23 @@ The first engine behind it is `trellis2cpp`, an image-to-3D backend over TRELLIS
<figcaption>trellis2-4b, 2,502,928 vertices and 5,012,118 triangles, turning in the browser. The remesh slider below it is the print path.</figcaption>
</figure>
## `local-ai chat` stopped being a REPL
`local-ai chat` used to be a chat prompt in a terminal. It is now an agent, and it is the [nib](https://github.com/mudler/nib) harness compiled straight into the binary: tool use behind an approval gate, sub-agents, MCP servers, plugins and skills, auto-configured against your own instance. Nothing extra to install.
```bash
local-ai chat # the agent, pointed at your models
echo "what is 2+2" | local-ai chat --cli
local-ai chat --init zsh # Ctrl+Space from any shell prompt
```
That last one prints a shell integration script (zsh, bash or fish), so you can pull the agent up from wherever you already are instead of opening something else.
It runs shell commands now, so every tool call goes through an approval prompt you control, and read-only ones like `ls` and `cat` run without asking. If you had habits around the old REPL, a few things moved: `/clear` is gone and `/compact` is the closest thing, `/models` and `/model <name>` mean what they always meant, and switching model keeps the conversation instead of starting over ([#11291](https://github.com/mudler/LocalAI/pull/11291)).
## One backend, six audio endpoints
The usual shape for audio is one backend per model family, which means a process per capability and a config file for each. `audio-cpp` wraps [audio.cpp](https://github.com/0xShug0/audio.cpp), a multi-family ggml audio engine, and inverts that: one backend process serves several unrelated families through a single runtime vocabulary, and works out which family a checkpoint belongs to from the GGUF's own `audiocpp.model_spec.family` metadata key. There is nothing backend-specific to write in the model config.
The usual shape for audio is one backend per model family, which means a process per capability and a config file for each. `audio-cpp` wraps [audio.cpp](https://github.com/0xShug0/audio.cpp), a multi-family ggml audio engine. One backend process serves several unrelated families through a single runtime vocabulary, and works out which family a checkpoint belongs to from the GGUF's own `audiocpp.model_spec.family` metadata key. There is nothing backend-specific to write in the model config.
<div class="tw">
<table>
@@ -130,7 +195,12 @@ The `bonsai` backend serves the 1-bit (Q1_0) and ternary (Q2_0) Bonsai quantizat
## The operations bar became a page
The old operations bar rendered one row per in-flight operation above every page. Queue four model installs and a backend and it took most of the viewport, on every route, until the last one finished. Two things were conflated there: a global "something is happening" signal, which needs one line, and the detail of what is happening, which needs somewhere to put it.
<figure>
<img src="/media/v4-8-0-ui-activity.png" alt="The Activity page with four installs running">
<figcaption>Four backend installs in flight, and the record of what already finished.</figcaption>
</figure>
The old operations bar rendered one row per in-flight operation above every page. Queue four model installs and a backend and it took most of the viewport, on every route, until the last one finished. It was doing two jobs at once. A global "something is happening" signal only needs one line, and the detail of what is happening needs a page of its own.
The strip is now one line, permanently, showing a failure first and otherwise the least-advanced running operation, with a `+N more` pill. Its `✕` hides the strip and no longer cancels anything. That is a deliberate behavior change worth knowing about before you click it out of habit: the same glyph used to cancel a 17 GB download in one row and dismiss a message in the next. Cancelling moved to the new page, behind a button that says so.
@@ -179,6 +249,6 @@ Valkey Search joins the vector store options as the `valkey-store` backend ([#11
This is also the release where localai.io split in two: the project site at the root, and the documentation under `/docs/`. Every URL that was published before still resolves, through 214 generated redirect stubs, because GitHub Pages has no server-side rewrites to do it properly ([#11243](https://github.com/mudler/LocalAI/pull/11243)).
Twenty-four people contributed to this release, eleven of them for the first time. The gallery went from 1,221 entries to 1,505.
Twenty-five people contributed to this release, eleven of them for the first time. The gallery went from 1,221 entries to 1,515.
To upgrade, pull `localai/localai:latest` or re-run the install script. The [full changelog](https://github.com/mudler/LocalAI/compare/v4.7.1...v4.8.0) has everything this post left out.

View File

@@ -19,7 +19,7 @@
<div><b class="tnum" data-count="{{ .Site.Data.stats.stars }}">0</b><span>GitHub stars</span></div>
<div><b class="tnum" data-count="73">0</b><span>Backends</span></div>
<div><b class="tnum" data-count="{{ len .Site.Data.engines.engines }}">0</b><span>Engines we wrote</span></div>
<div><b class="tnum" data-count="1585">0</b><span>Models, one click</span></div>
<div><b class="tnum" data-count="1255">0</b><span>Models, one click</span></div>
</div>
</div>
<div class="fd">
@@ -39,7 +39,8 @@
<p class="kicker rv">The runtime</p>
<h2 class="rv mt1" style="max-width:21ch">Everything else plugs into LocalAI.</h2>
<p class="lede rv mt2">One binary with an OpenAI-compatible API in front of it. Point an existing client at it and the calls keep working, except now the model is on your machine. It also speaks the Anthropic, Ollama and ElevenLabs APIs, so most tools need a URL change and nothing else.</p>
<p class="lede rv mt2">Underneath, a small core pulls each engine in as a separate backend, only when a model asks for it. That is why one install covers this much ground without becoming a 9 GB download.</p>
<p class="lede rv mt2">The engine behind that API is swappable. One model can run on llama.cpp while the next loads on vLLM, SGLang or MLX, and the client never notices: same endpoint, same request, different engine underneath. Switching is one line in the model's config.</p>
<p class="lede rv mt2">A small core pulls each engine in as a separate backend, only when a model asks for it. That is why one install covers this much ground without becoming a 9 GB download.</p>
<div class="apis rv">
<span>OpenAI API</span><span>Anthropic API</span><span>Ollama API</span><span>ElevenLabs API</span><span>Realtime over WebRTC</span>
</div>
@@ -57,7 +58,7 @@
</div>
<div class="duo__m rv">
<figure class="screen" style="margin:0">
<figcaption class="screen__bar"><i></i> localai · model gallery <b>1,585 models</b></figcaption>
<figcaption class="screen__bar"><i></i> localai · model gallery <b>1,255 models</b></figcaption>
<video src="/media/gallery.mp4" muted loop playsinline preload="none" data-lazy aria-label="Installing a model from the LocalAI gallery"></video>
</figure>
</div>
@@ -327,7 +328,7 @@
<div class="shell">
<div class="bars rv" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
<p class="kicker rv">The gallery</p>
<h2 class="rv mt1" style="max-width:20ch">1,585 models. No notebook, no conversion script.</h2>
<h2 class="rv mt1" style="max-width:20ch">1,255 models. No notebook, no conversion script.</h2>
<div class="cards">
<a class="cd rv" href="/docs/getting-started/models/"><p class="cd__k">Quantizations</p><h3>201 APEX builds</h3>
<p>Every tier of every model we quantize, ranked against the hardware you actually have and installed with one click.</p><span class="cd__go">Browse the gallery →</span></a>

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -0,0 +1,100 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
/* palette lifted from the two logos:
LocalAI #0E2632 navy, #385360 slate, #469AAF teal, #90A8AE haze
vllm.cpp #3AB4CA teal, #95C4D1 light */
:root{
--bg:#0b1c25; --ink:#e8f1f4; --dim:#90a8ae; --faint:#5d757f;
--teal:#3ab4ca; --teal-hi:#7fd4e2; --amber:#e0a944; --rule:#1d3440;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:1600px;height:900px}
body{
background:radial-gradient(1250px 720px at 80% -12%, #143140 0%, var(--bg) 62%);
color:var(--ink);
font-family:-apple-system,"SF Pro Display","Segoe UI",Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased; padding:58px 84px; position:relative;
}
.eyebrow{display:flex;align-items:center;gap:14px;color:var(--teal);
font-weight:600;font-size:23px;letter-spacing:.14em;text-transform:uppercase}
.eyebrow .dot{width:11px;height:11px;border-radius:50%;background:var(--teal);
box-shadow:0 0 16px 2px var(--teal)}
h1{font-size:56px;line-height:1.06;font-weight:760;margin:16px 0 6px;letter-spacing:-.02em}
h1 .grad{background:linear-gradient(92deg,var(--teal),var(--teal-hi));
-webkit-background-clip:text;background-clip:text;color:transparent}
.sub{color:var(--dim);font-size:23px;margin-bottom:14px}
svg{width:100%;height:auto;display:block}
.foot{position:absolute;left:84px;right:84px;bottom:40px;display:flex;
justify-content:space-between;align-items:center;color:var(--faint);
font-size:21px;border-top:1px solid var(--rule);padding-top:16px}
.foot .link{color:var(--ink);font-weight:600}
</style>
</head>
<body>
<div class="eyebrow"><span class="dot"></span>vllm.cpp &middot; throughput vs the reference engine</div>
<h1>Measured against <span class="grad">what each workload actually runs on</span></h1>
<div class="sub">Throughput relative to the reference. 1.00 is parity, bars run from it. Higher is faster.</div>
<svg id="c" viewBox="0 0 1432 585"></svg>
<div class="foot">
<span class="link">github.com/mudler/vllm.cpp</span>
<span>GB10 unless noted &middot; greedy, reference in its own production config &middot; docs/BENCHMARKS.md</span>
</div>
<script>
const rows = [
{ref:'DwarfStar (ds4)', work:'DeepSeek-V4-Flash IQ2_XXS', v:1.144, note:'18.69 vs 16.33 tok/s'},
{ref:'vLLM', work:'Qwen3.6-27B NVFP4, c1', v:1.045, note:'86.05 vs 82.32 tok/s'},
{ref:'vLLM', work:'Laguna-XS-2.1 NVFP4', v:1.030, note:'44.46 vs 43.10 tok/s'},
{ref:'vLLM', work:'Qwen3.6-35B-A3B, c32', v:1.013, note:'3030.5 vs 2993.0 tok/s'},
{ref:'MLX-LM', work:'Qwen3-0.6B, Apple M4', v:0.976, note:'97.6% of warm total'},
];
const W=1432, H=585;
const AX=64; // axis strip reserved at the bottom
const LBL=470; // left label gutter
const R=150; // right gutter for the value
const lo=-0.055, hi=0.165; // deviation domain around parity
const pw=W-LBL-R;
const x = d => LBL + pw*((d-lo)/(hi-lo));
const zero = x(0);
const rowH = (H-AX)/rows.length;
const barH = 46;
let g='';
// faint engineering grid at 2% steps
for(let d=-0.04; d<=0.16001; d+=0.02){
const gx=x(d), on0=Math.abs(d)<1e-9;
g+=`<line x1="${gx}" y1="4" x2="${gx}" y2="${H-AX+10}" stroke="${on0?'#4a6b78':'#16303c'}" stroke-width="${on0?2:1}"/>`;
g+=`<text x="${gx}" y="${H-22}" fill="${on0?'#90a8ae':'#4d6570'}" font-size="17" text-anchor="middle"
font-weight="${on0?'700':'400'}">${(1+d).toFixed(2)}</text>`;
}
rows.forEach((r,i)=>{
const cy = i*rowH + rowH/2;
const d = r.v-1;
const ahead = d>=0;
const col = ahead ? '#3ab4ca' : '#e0a944';
const x0 = ahead ? zero : x(d);
const w = Math.abs(x(d)-zero);
// reference + workload, two weights on one line
g+=`<text x="${LBL-26}" y="${cy-4}" fill="#e8f1f4" font-size="25" font-weight="670" text-anchor="end">${r.ref}</text>`;
g+=`<text x="${LBL-26}" y="${cy+22}" fill="#5d757f" font-size="19" text-anchor="end">${r.work}</text>`;
g+=`<rect x="${x0}" y="${cy-barH/2}" width="${Math.max(w,2)}" height="${barH}" rx="4" fill="${col}" opacity="0.92"/>`;
// value, then the raw measurement under it
const vx = ahead ? x(d)+18 : zero+18;
g+=`<text x="${vx}" y="${cy+1}" fill="${col}" font-size="27" font-weight="700"
font-variant-numeric="tabular-nums">${r.v.toFixed(3)}&times;</text>`;
g+=`<text x="${vx}" y="${cy+23}" fill="#5d757f" font-size="17">${r.note}</text>`;
});
document.getElementById('c').innerHTML=g;
</script>
</body>
</html>

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB