Compare commits

..
Author SHA1 Message Date
Ettore Di Giacinto 64628da9dd chore(deps): bump llvmlite 0.43→0.49, numba 0.60→0.67, numpy>=2.5.2 in transformers
Merge dependabot PRs #11639, #11640, #11641 together:
- #11639: llvmlite 0.43.0 → 0.49.0 (alone fails: numba 0.60 requires <0.44)
- #11640: numba 0.60.0 → 0.67.0 (alone fails: requires llvmlite >=0.49)
- #11641: numpy >=2.0.0 → >=2.5.2 (was blocked by scipy 1.15.1 <2.5, now
  resolved since scipy was bumped to 1.18.0 in #11399)

Also bump outetts backend to match (same numba/llvmlite conflict).

Signed-off-by: localai-org-maint-bot <bot@localai.io>
Assisted-by: MAKI:GLM5.2 [gh]
2026-09-11 23:34:59 +00:00
Ettore Di Giacinto 1dfa25e681 fix(qwen-tts): limit CUDA compiler threads
The CUDA 13 FlashAttention build still exhausts hosted-runner memory with a single ninja worker because nvcc can compile multiple threads internally. Limit nvcc to one thread for that profile and guard the setting in the backend test script.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 23:19:28 +00:00
Ettore Di Giacinto 43c54bc9ba fix(llama-cpp): stop a generation whose stream is gone
grpc::ServerWriter::Write() returns false once the peer is gone, and
PredictStream ignored that result at every call site. The handler kept
pulling decoded tokens and writing them into a dead stream, so the
llama.cpp slot stayed busy until the generation ended on its own terms.

A model configured with max_tokens 0 and a large context ends on its own
terms only at the context limit. On a 35B model at ~41 t/s a 120k context
is about fifty minutes, and a slot held that long is a slot every other
request for that model queues behind. Two abandoned requests were enough
to make a node with free VRAM and a healthy control plane serve nothing:
new requests timed out waiting for a slot, each timeout abandoned another
generation, and the node fell further behind the longer it ran.

Track the peer instead. The first failed write retires it for good, since
a stream never recovers, and the RPC's own cancellation flag folds into
the same predicate so the loop has one condition to test. Returning early
is what frees the slot: ~server_response_reader() posts
SERVER_TASK_TYPE_CANCEL for whatever is still decoding.

TTSStream already checked Write(); this brings PredictStream in line.
Cancellation stays cooperative and is checked between decoded results, so
a batch already in flight may finish before the request stops.

Assisted-by: Claude:claude-opus-5
2026-09-11 23:13:32 +00:00
Adira Denis Muhando 869000ceb7 fix(distributed): exclude pinned models from cluster eviction and idle scale-down
pinned: true was only honoured by the per-node watchdog. Every distributed
eviction path was pinned-blind: the router's LRU eviction (EvictLRU,
evictLRUAndFreeNode) and the replica reconciler's idle scale-down would
happily unload a pinned model — and since eviction is gated on
in_flight = 0, a pinned model became eviction-eligible the instant each
response completed. Under capacity pressure that surfaces as the backend
being freed immediately after every request (#11101).

Wire the model config loader into the router and reconciler through a new
PinnedModelResolver seam (mirroring ConcurrencyConflictResolver):

- EvictLRU passes the pinned set into FindLRUModel's query so the
  next-oldest unpinned model is selected instead of the attempt failing
- evictLRUAndFreeNode filters pinned models inside its locked selection
- scaleDownIdle skips pinned models entirely: trimming to the floor still
  means requests beyond the survivor's capacity pay a cold reload

Deliberate teardown (admin unload, model delete, node drain) intentionally
still applies to pinned models, as does dead-row reaping (state correction,
not eviction).

Regression specs verified to fail with the exclusion disabled.

Addresses the cluster-side eviction gap in #11101

Assisted-by: Claude Code:claude-fable-5 [Claude Code]
Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
2026-09-11 23:08:26 +00:00
dependabot[bot] 40c4d5990e chore(deps): bump scipy in /backend/python/transformers
Bumps [scipy](https://github.com/scipy/scipy) from 1.15.1 to 1.18.0.
- [Release notes](https://github.com/scipy/scipy/releases)
- [Commits](https://github.com/scipy/scipy/compare/v1.15.1...v1.18.0)

---
updated-dependencies:
- dependency-name: scipy
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-11 23:06:18 +00:00
localai-org-maint-bot 7dc617c7c1 fix(transformers): use Python 3.12
SciPy 1.18 requires Python 3.12 or newer. Keep this backend on a
compatible portable Python for Linux and macOS builds.

Assisted-by: Codex:gpt-5.6 [Codex]
(cherry picked from commit 70bf6d4a3a)
2026-09-11 23:05:33 +00:00
81041d8b1b chore(deps): bump LocalAGI and localrecall to v0.6.5 (#11985)
* chore(deps): bump github.com/mudler/localrecall to v0.6.5

Picks up two Postgres engine fixes: the RRF fusion no longer scores
every hybrid-search candidate 0 through integer division, and the
search_vector text config is no longer pinned to 'simple' for the life
of the process after one transient lookup failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANmTgdYikmzBq67jVJ1CgX

* chore(deps): bump github.com/mudler/LocalAGI to d93d478

Picks up mudler/LocalAGI#493, which bumps localrecall to v0.6.5 there
too, so the direct pin in this module and the version arriving through
LocalAGI agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANmTgdYikmzBq67jVJ1CgX

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 01:05:13 +02:00
dependabot[bot] 1f3d89fc4a chore(deps): update transformers requirement
Updates the requirements on [transformers](https://github.com/huggingface/transformers) to permit the latest version.
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.15.0...v5.15.1)

---
updated-dependencies:
- dependency-name: transformers
  dependency-version: 5.15.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-11 22:51:12 +00:00
Ettore Di Giacinto c9acb41903 fix(gallery): use tokenizer templates for Gemma
Let the model-provided tokenizer template format Gemma conversations instead of maintaining a shared inline prompt template.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 22:44:39 +00:00
Ettore Di Giacinto db12122329 fix(reasoning): ignore preclosed prompt markers
Do not seed streaming reasoning state when the latest prompt thinking marker is already followed by its matching closing marker. This keeps direct Gemma 4 output in content when its template disables thinking with a preclosed channel.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 22:44:39 +00:00
Ettore Di Giacinto bbd5488871 fix(distributed): make backend.stop report what it stopped
backend.stop was the one lifecycle subject a worker never answered. The
controller published and returned nil as soon as the local publish
succeeded, so a stop that killed nothing, and a stop that failed
outright, were indistinguishable from one that worked.

The unload endpoint calls model.unload and then StopBackend. Only the
first is acknowledged, so the endpoint answered 200 while the backend
kept running and held its VRAM, and its own "backend stop failed" branch
could never run. The worker logged the failure and nobody saw it.

Give the subject a reply. The worker now enumerates the process keys it
terminated and reports any per-process error, so StopBackend fails when
the stop failed. Resolving to nothing stays a success: stopping a backend
that is not running leaves the caller in the state it asked for, and
eviction paths stop already-gone models routinely. The empty list is what
says nothing matched, and ReportsStoppedProcesses is what makes that
emptiness trustworthy, the same way BackendDeleteReply handles it.

A worker built before this reply still receives the request and still
stops the backend, it only stays silent, so a timeout degrades to the old
assumption rather than failing every stop on a fleet mid-upgrade. Only
silence degrades: a transport error is still reported, because
UnloadRemoteModel skips its registry cleanup for a node it could not
reach and needs to keep hearing about that.

Assisted-by: Claude:claude-opus-5 golangci-lint
2026-09-11 22:44:11 +00:00
Ettore Di Giacinto 51c0a44bca gallery: apply PR #11574
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:43:46 +00:00
Ettore Di Giacinto d385a0bf8e gallery: apply PR #11494
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:43:42 +00:00
Ettore Di Giacinto a398c559b3 fix(gallery): persist inference defaults under parameters
Gallery installs merged family defaults at the YAML root and only re-marshaled them on the artifact path. Persist the defaults in the loader-visible parameters map for every install path while preserving authored overrides.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 22:43:10 +00:00
Ettore Di Giacinto 14103f1d78 gallery: apply PR #11983
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:56 +00:00
Ettore Di Giacinto 81f6898b69 gallery: apply PR #11960
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:52 +00:00
Ettore Di Giacinto c99d0d44cd gallery: apply PR #11958
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:48 +00:00
Ettore Di Giacinto ada207679a gallery: apply PR #11951
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:43 +00:00
Ettore Di Giacinto f528bf07dd gallery: apply PR #11949
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:38 +00:00
Ettore Di Giacinto c7ff1aabf0 gallery: apply PR #11947
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:33 +00:00
Ettore Di Giacinto 8b003539db gallery: apply PR #11946
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:29 +00:00
Ettore Di Giacinto d95b5d787f gallery: apply PR #11943
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:24 +00:00
Ettore Di Giacinto 9f100e9062 gallery: apply PR #11930
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:19 +00:00
Ettore Di Giacinto 08a87788b4 gallery: apply PR #11927
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:15 +00:00
Ettore Di Giacinto ab191912e9 gallery: apply PR #11926
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:11 +00:00
Ettore Di Giacinto 16fb9e81bd gallery: apply PR #11923
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:06 +00:00
Ettore Di Giacinto 075d0d3fa4 gallery: apply PR #11922
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:37:00 +00:00
Ettore Di Giacinto 9401844c6b gallery: apply PR #11913
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:36:55 +00:00
Ettore Di Giacinto 43cf7ed77f gallery: apply PR #11909
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:36:50 +00:00
Ettore Di Giacinto 29347bad67 gallery: apply PR #11905
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:36:46 +00:00
Ettore Di Giacinto 5a62ed1614 gallery: apply PR #11900
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:36:40 +00:00
Ettore Di Giacinto b9902148ae gallery: apply PR #11873
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:36:35 +00:00
Ettore Di Giacinto d81d7f821e gallery: apply PR #11841
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:36:30 +00:00
Ettore Di Giacinto c70e18392e gallery: apply PR #11832
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:36:26 +00:00
Ettore Di Giacinto 9e86521698 gallery: apply PR #11705
Assisted-by: localai-org-maint-bot:glm5.2 [gh]
2026-09-11 22:36:21 +00:00
Ettore Di Giacintoandxingyifeng df70ff311d feat: add FunASR speech recognition backend (#10090)
Adds FunASR/SenseVoice as a Python backend for speech-to-text with
support for CPU, CUDA 12/13, ROCm, Intel SYCL, L4T, and Apple MPS.

Co-authored-by: xingyifeng <xingyifeng@users.noreply.github.com>
2026-09-11 22:06:26 +00:00
Ettore Di Giacinto 6983477a71 fix(agent-ui): keep chat open for status
Agent Status replaced the chat route and unmounted its EventSource. Any response still in flight could then disappear from the conversation.\n\nOpen status in a separate tab so the chat keeps its live connection until the response completes.\n\nAssisted-by: Codex:gpt-5 [eslint]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:56:08 +00:00
Ettore Di Giacinto db09452d54 feat(tts): support multi-reference personalities
Saved profiles previously resolved to one audio path and transcript, so
cloning backends could not use several examples of one personality.

Store ordered audio and transcript pairs while preserving the legacy
first-reference fields. Fish Speech and audio.cpp receive all pairs,
including on distributed workers. Other backends retain their
single-reference behavior.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:56:08 +00:00
Ettore Di Giacinto ddda0436a3 fix(ui): exclude navigation from the TTS mock
The API mock also matched navigation to /app/tts and returned a WAV download instead of the React page. Let non-POST requests reach the test server.

Assisted-by: Codex:gpt-5 [Playwright]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:56:02 +00:00
Ettore Di Giacinto 1bcbff586b fix(ui): target the TTS input in history tests
TTS instructions add a second textarea to the page. Target the speech input by its placeholder so the history test does not depend on the page having one textarea.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:56:02 +00:00
Ettore Di Giacinto 919b5c96fa feat(ui): add per-request TTS instructions
Let studio users guide speech delivery for backends that support request instructions. Blank guidance stays out of requests and media history.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:56:02 +00:00
Ettore Di Giacinto d55474a149 feat(audio): list available TTS voices
Clients cannot discover the named voices that an installed TTS model accepts without consulting backend-specific documentation. Expose voice metadata through the audio API and let custom model configs declare their own catalog.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:55:21 +00:00
Ettore Di Giacinto 21e81434b3 fix(llama-cpp): stage model load diagnostics
The generated gRPC source tree omitted the new header and test. Every llama.cpp-derived backend therefore failed when grpc-server.cpp included the missing header.

Assisted-by: Codex:gpt-5.6 [systematic-debugging]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:55:14 +00:00
Ettore Di Giacinto a6519182db fix(llama-cpp): explain tensor count mismatch
llama.cpp reports the same tensor-count error for unsupported model layouts and damaged GGUF files. Add a focused hint so operators can update the backend or verify the model without losing the upstream diagnostic.

Assisted-by: Codex:gpt-5.6
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:55:14 +00:00
Ettore Di Giacinto cce7a9e441 fix(fish-speech): resolve relocated source
The editable install records the backend build path, which does not exist after LocalAI relocates the packaged backend. Add the runtime source directory to PYTHONPATH so inference modules remain importable.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:55:10 +00:00
Ettore Di Giacinto f5eb09f329 fix(audio-cpp): bundle rocRoller for ROCm
ROCm 7.2 links rocBLAS consumers to librocroller.so.1. Add that runtime family to the ROCm bundle so packaged backends resolve the dependency.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:53:43 +00:00
Ettore Di Giacinto bfcf1e37ec fix(audio-cpp): install rocBLAS headers
The HIP build reaches ggml configuration and requires the rocBLAS CMake package. Install its development package with the existing hipBLAS dependency.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:53:43 +00:00
Ettore Di Giacinto db0b00a5ac fix(audio-cpp): normalize HIP target list
audio.cpp forwards GPU_TARGETS to CMake as a semicolon-delimited list. The comma-delimited LocalAI value was treated as one invalid HIP architecture during configuration.

Assisted-by: Codex:gpt-5.6-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:53:43 +00:00
Ettore Di Giacinto 70be2c49a1 fix(audio-cpp): install hipBLAS headers
The ROCm builder lacks the CMake package metadata that ggml requires. Install the development package only for hipBLAS builds.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:53:43 +00:00
Ettore Di Giacinto 878b99384f feat(audio-cpp): add ROCm backend image
The pinned audio.cpp revision supports HIP, but LocalAI neither builds a ROCm image nor accepts its backend option. AMD hosts therefore fall back to the CPU image.

Build and publish the HIP variant, connect it to AMD capability selection, and accept both upstream HIP names.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:53:43 +00:00
Ettore Di Giacinto a15780858e feat(diffusers): add AudioLDM2 generation
Expose diffusers audio pipelines through the existing sound-generation RPC. AudioLDM2 can now return PCM WAV output from the model gallery without a separate backend.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:53:37 +00:00
Ettore Di Giacinto 783556bc93 feat(prefixcache): index reported KV residency
Add a NATS event contract and exact-residency provider for backend KV cache reports. Keep guessed request observations as the default routing source while maintaining the reported index for future producers.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:53:33 +00:00
Ettore Di Giacinto f884d4b456 fix(mlx-video): use supported Python on Darwin
The pinned mlx-video package requires Python 3.11 or newer, while an empty PYTHON_VERSION selected the backend helper default of 3.10. Pin the available 3.11.13 portable runtime for the Darwin package build.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:52:41 +00:00
Ettore Di Giacinto 21df4a120a feat(mlx): add Apple Silicon video backend
Add a Darwin-only MLX-Video backend for LTX-2 and converted Wan checkpoints, expose it through the existing video API, and wire packaging, discovery, tests, docs, and an example.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:52:41 +00:00
Ettore Di Giacinto 522644e4b6 docs: add RKLLM Rockchip NPU integration
Document how to connect LocalAI to the upstream RKLLM OpenAI-compatible server through cloud-proxy, including supported SoCs and current limitations.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:49:51 +00:00
Ettore Di Giacinto f5ea6e4eab fix(fish-speech): use CUDA toolkit ptxas
Prefer an explicitly configured Triton assembler, otherwise use the executable ptxas from CUDA_HOME so torch.compile can target GPU architectures newer than Triton bundled tooling.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:48:54 +00:00
Ettore Di Giacinto f8e228e0ba fix(fish-speech): support CUDA 13 on arm64
Keep the relocated upstream source importable, select CUDA 13 PyTorch wheels instead of the aarch64 CPU fallback, and decode reference audio without torchcodec, which has no Linux arm64 wheels.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:48:07 +00:00
Ettore Di Giacinto 3535675fd4 fix(whisper-medusa): pin available portable Python
The standalone Python release used by libbackend does not publish a 3.11.18 artifact. Pin Whisper-Medusa to the available 3.11.13 build and cover the generated download URL.

Assisted-by: Codex:gpt-5 [systematic-debugging]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:48:07 +00:00
Ettore Di Giacinto cec62c3dd3 feat(backends): add Whisper-Medusa transcription
Add a dedicated Python gRPC backend for aiola Whisper-Medusa checkpoints, including mono 16 kHz preprocessing, bounded clip validation, CPU/CUDA 12 images, backend gallery metadata, and user documentation.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:48:07 +00:00
Ettore Di Giacinto a64fc865ba fix(distributed): aggregate prefix cache pressure
Broadcast forced-disturb events and successful scale-up resets so every frontend shares the same rolling autoscale signal. Deduplicate NATS echoes, expose an origin-only Prometheus counter, and document cluster behavior.

Closes #10083

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:48:07 +00:00
Ettore Di Giacinto 3e4a44be9d fix(diffusers): forward original config for single files
Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:47:55 +00:00
Ettore Di Giacinto 419375baae fix(config): register original diffusers config field
Expose original_config_file through the model configuration metadata registry so registry completeness checks and generated configuration surfaces stay in sync.

Assisted-by: Codex:gpt-5 [systematic-debugging]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:47:12 +00:00
Ettore Di Giacinto a9f8062dd5 feat(nodes): add pluggable routing pipeline
Route prefix-cache replica selection through composable filters, weighted scorers, and a replaceable picker. Preserve the existing load guard and deterministic selection policy while exposing per-model scorer weights through scheduling configuration.

Assisted-by: Codex:gpt-5 [go-vet]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:47:08 +00:00
Ettore Di Giacinto 155804e144 docs: clarify model configuration precedence
Assisted-by: Codex:gpt-5 [Codex]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:40:10 +00:00
Andreas Egliandlocalai-org-maint-bot 9803034e21 fix(faster-whisper): manually install ctranslate2 with rocm support before installing other dependencies (#9839)
* fix(faster-whisper): manually install ctranslate2 with rocm support before installing other dependencies

Signed-off-by: Andreas Egli <github@kharan.ch>

* feat(faster-whisper): wire version into bump-deps workflow

Assisted-by: opencode:gpt-5.5
Signed-off-by: Andreas Egli <github@kharan.ch>

---------

Signed-off-by: Andreas Egli <github@kharan.ch>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-09-11 18:11:51 +02:00
9bd7d17ff6 [model-config] feat: add environment variables support for backends (#10721)
* feat: add environment variables support for backends in model configurations

- Add field to model configuration to pass environment variables to backend processes
- Update backend options and model configuration handling
- Add documentation for environment variables configuration with examples including CUDA_VISIBLE_DEVICES

Assisted-by: qwen-agentworld-35b-a3b
Signed-off-by: nold <nold42@pm.me>

* fix(test):  Test environment variables configuration parsing from YAML

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: nold <Nold360@users.noreply.github.com>

---------

Signed-off-by: nold <nold42@pm.me>
Signed-off-by: nold <Nold360@users.noreply.github.com>
Co-authored-by: nold <nold42@pm.me>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-11 18:02:08 +02:00
Shayan khan 864db8e3d3 fix(backends): honor enable_thinking=false in mlx and vllm-omni (#11962)
The mlx, mlx-vlm, mlx-distributed and vllm-omni backends only forwarded
enable_thinking when the metadata value was "true". A "false" value never
reached apply_chat_template, so requests with thinking disabled (for
example a realtime pipeline with disable_thinking: true) still used the
chat template default. Apply the same coerce that #11715 added to sglang
and vllm.

Assisted-by: Claude:claude-opus-5

Signed-off-by: devv-shayan <shayankhanx1x@gmail.com>
2026-09-11 18:01:51 +02:00
Leoy aeac5e86dc fix(auth): bypass API-key auth for CORS preflight (OPTIONS) requests (#11113)
* fix(auth): bypass API-key auth for CORS preflight (OPTIONS) requests

When API-key auth is enabled, a browser making a cross-origin API call
first sends an OPTIONS CORS preflight, which cannot carry credentials by
HTTP spec. The auth middleware is registered (app.go:324) before the CORS
middleware (app.go:337-347), so the preflight hit auth first and returned
401 before the CORS middleware could answer it, blocking the actual call.

Bypass auth for OPTIONS so the request reaches the CORS middleware, which
answers the preflight with 200 + headers. Real API requests (GET/POST/etc.)
still require auth. Regression test added (red on master, green on branch).

Refs #4576

Signed-off-by: supermario_leo <leo.stack@outlook.com>

* fix(auth): exempt CORS preflights via publicRouteRegistry instead of middleware bypass

Route the global OPTIONS exemption through publicRouteRegistry
(OPTIONS on every path, replacing the OPTIONS-under-/api/auth/ rule
it subsumes) instead of a hardcoded method check inside Middleware,
so "which requests skip auth" has one mechanism. Preflights now flow
through the same authenticate-then-public-rules path as other public
routes, which also lets a credentialed OPTIONS request keep its user
context.

Update the route-coverage allowlist and the near-prefix lookalike
table for the new semantics (OPTIONS is public on every path by
design; near-prefix privacy stays pinned by the non-OPTIONS entries),
and fix the authentication docs' exempt-route enumeration, which
still described OPTIONS as an /api/auth/-only exemption.

Signed-off-by: supermario_leo <leo.stack@outlook.com>

---------

Signed-off-by: supermario_leo <leo.stack@outlook.com>
2026-09-11 17:57:09 +02:00
Som Samantray d27fb3202c fix(whisper): honour positional listen address argument (#11652)
* fix(whisper): honour positional listen address argument

The whisper backend parsed its gRPC listen address exclusively through
Go's flag package, while run.sh forwards launcher arguments verbatim.
A bare positional address was silently dropped by flag.Parse(), so the
server always bound the default localhost:50051 instead of the port its
caller allocated — LocalAI then failed to reach it with a misleading
'error reading from server: EOF'.

Fall back to the first positional argument when no explicit -addr value
was given, keeping the default for no-argument launches.

Fixes #11623

Assisted-by: ox-alpha:ox-alpha [go test]

Signed-off-by: Som Samantray <som.samantray@gmail.com>

* fix(whisper): track explicit -addr via flag.Visit and adopt Ginkgo test style

Review follow-up:
- Detect an explicitly set -addr with flag.FlagSet.Visit instead of
  comparing against the default sentinel, so '-addr localhost:50051'
  plus a positional argument keeps the flag value.
- Treat an explicitly empty -addr as unset rather than binding the
  empty address (OS-chosen port on all interfaces).
- Rewrite addr_test.go as Ginkgo v2 specs per .agents/coding-style.md;
  stdlib t.Run/t.Errorf are forbidden by .golangci.yml forbidigo.

Assisted-by: ox-alpha:ox-alpha [go test]

Signed-off-by: Som Samantray <som.samantray@gmail.com>

---------

Signed-off-by: Som Samantray <som.samantray@gmail.com>
2026-09-11 17:31:43 +02:00
QiuLG e588d463dc fix(ui): preserve percent signs in route parameters (#11883)
fix(ui): preserve decoded route parameters

React Router already decodes dynamic path segments before exposing them through useParams. Decoding those values again crashes pages for names containing a literal percent sign and mutates escape-like substrings.

Use route parameters as-is, encode the model editor API path at the outbound boundary, and cover all affected pages with Playwright.

Fixes #11882

Assisted-by: Codex:gpt-5 eslint playwright

Signed-off-by: QiuLG <l237455523@outlook.com>
2026-09-11 16:54:56 +02:00
Abdullah Mansour | عبد الله منصور acc7284526 fix(openresponses): support Codex WebSocket warm-up (#11608)
* fix(openresponses): support Codex WebSocket warmup

Assisted-by: ChatGPT:GPT-5.6-Sol golangci-lint
Signed-off-by: Abdullah Mansour <abdullahmansour.marketing@gmail.com>

* docs(openresponses): document WebSocket responses

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

* fix(openresponses): harden WebSocket response lifecycle

Ensure response ownership, continuation storage, error sequencing, and connection-local resource limits remain correct across HTTP and WebSocket transports.

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

---------

Signed-off-by: Abdullah Mansour <abdullahmansour.marketing@gmail.com>
2026-09-11 16:54:42 +02:00
localai-org-maint-botandlocalai-org-maint-bot c0993e580a feat(gallery): add Huihui Qwen3.8 Flash Next (#11921)
Add the published UD-Q4_K_XL shards and BF16 vision projector.
Document installation and the default context size.

Assisted-by: Codex:gpt-6

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-11 15:27:31 +02:00
localai-org-maint-botandlocalai-org-maint-bot 6fb52d3cb0 feat(gallery): add Gemma 4 E4B HauhauCS variants (#11903)
Add Q4_K_M and Q5_K_M builds with the vision projector and an install
example. Pin downloads to the verified Hugging Face revision.

Assisted-by: Codex:gpt-6

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-11 15:27:17 +02:00
1e5b8aeca5 feat(gallery): add Ornith 1.5 35B variants (#11625)
* feat(gallery): add Ornith 1.5 35B variants

Add the supported APEX, MTP, and official Q8_0 builds for the multimodal Ornith 1.5 35B model.

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

* fix(gallery): restore Ornith 1.5 entries

The 35B insertion split the existing 9B entry and nested its remaining
fields under the final 35B file. Restore the 9B fields before the new
35B entries so the gallery index parses correctly.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-11 15:26:50 +02:00
localai-org-maint-botandEttore Di Giacinto b845468a11 fix(ds4): resolve Hugging Face repository imports (#11340)
fix(ds4): resolve repository imports to GGUF

Select a concrete DeepSeek V4 Flash GGUF from Hugging Face metadata while honoring quantization preference order. Reject unresolved repository imports instead of emitting an invalid bare URI.

Assisted-by: Codex:gpt-5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 14:45:03 +02:00
a1c79b517b docs(faq): note OPENAI_BASE_URL env var for pointing clients at LocalAI (#11310)
* docs(faq): note OPENAI_BASE_URL env var for pointing clients at LocalAI

The "Can I use it with a Discord bot, or XXX?" answer says a client can be
pointed at the LocalAI endpoint, but not how. Most OpenAI clients pick the
endpoint up from OPENAI_BASE_URL, and clients that insist on a non-empty API
key accept any placeholder value while api_keys is unset.

Assisted-by: Claude:claude-opus-5
Signed-off-by: seven7763 <seven7763@users.noreply.github.com>

* docs(faq): use Hugo notice shortcode per coding-style

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: seven7763 <seven7763@users.noreply.github.com>

---------

Signed-off-by: seven7763 <seven7763@users.noreply.github.com>
Co-authored-by: seven7763 <seven7763@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-11 14:44:18 +02:00
dependabot[bot] bcf196da3e chore(deps): bump vllm from 0.28.0 to 0.29.0 in /backend/python/vllm (#11961)
Bumps [vllm](https://github.com/vllm-project/vllm) from 0.28.0 to 0.29.0.
- [Release notes](https://github.com/vllm-project/vllm/releases)
- [Changelog](https://github.com/vllm-project/vllm/blob/main/RELEASE.md)
- [Commits](https://github.com/vllm-project/vllm/compare/v0.28.0...v0.29.0)

---
updated-dependencies:
- dependency-name: vllm
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 13:16:12 +02:00
dependabot[bot] 50fc09848b chore(deps): bump securego/gosec from 2.27.1 to 2.29.0 (#11854)
Bumps [securego/gosec](https://github.com/securego/gosec) from 2.27.1 to 2.29.0.
- [Release notes](https://github.com/securego/gosec/releases)
- [Commits](https://github.com/securego/gosec/compare/v2.27.1...v2.29.0)

---
updated-dependencies:
- dependency-name: securego/gosec
  dependency-version: 2.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 13:13:54 +02:00
Matt Van HornandMatt Van Horn 8a0548714d fix: prefer the Intel diffusers venv SYCL runtime at startup (#11971)
Fixes #11895

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-09-11 12:13:03 +02:00
localai-org-maint-botandmudler a8ff0c3a6e chore: ⬆️ Update leejet/stable-diffusion.cpp to b68d58624d227682eb4b95ef8bcf569cd1311eb5 (#11965)
⬆️ 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-09-11 08:33:29 +02:00
localai-org-maint-botandmudler 78015014a5 chore: ⬆️ Update ggml-org/llama.cpp to df03399b885831b2a1603b3abb0d8c156808e363 (#11966)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-11 08:33:12 +02:00
localai-org-maint-botandmudler 463a5e019a chore: ⬆️ Update ikawrakow/ik_llama.cpp to 3bb386eb68ffee0a5dc7db21da0735d594929eeb (#11967)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-11 08:32:59 +02:00
localai-org-maint-botandmudler fe6ca3738d chore(model-gallery): ⬆️ update checksum (#11968)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-11 08:32:47 +02:00
localai-org-maint-botandmudler 878ca9966e chore: ⬆️ Update 0xShug0/audio.cpp to efb04233dab73aeee4b2912042a90e7b36329061 (#11964)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-11 08:32:35 +02:00
localai-org-maint-botandmudler dec5cdf19b chore: ⬆️ Update vllm-project/vllm cu130 wheel to 0.29.0 (#11954)
⬆️ Update vllm-project/vllm cu130 wheel

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-10 17:05:36 +02:00
localai-org-maint-botandmudler bf405c003d chore: ⬆️ Update ggml-org/llama.cpp to 434ddbbc0e30522e897670681e503b797c12b7c1 (#11953)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-10 17:05:20 +02:00
localai-org-maint-botandmudler d60aaa171d chore: ⬆️ Update ikawrakow/ik_llama.cpp to 3e416d7f5a9d4cc3195e8171dbf891541ca59c6a (#11952)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-10 15:21:47 +02:00
localai-org-maint-botandmudler 1816013ebd chore: ⬆️ Update 0xShug0/audio.cpp to fa5aaac9266a98c68f8a5c9fcd1ba6ff65875416 (#11955)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-10 08:58:59 +02:00
dependabot[bot] 428898373a chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#11957)
Bumps the npm_and_yarn group with 2 updates in the /core/http/react-ui directory: [hono](https://github.com/honojs/hono) and [js-yaml](https://github.com/nodeca/js-yaml).


Updates `hono` from 4.12.34 to 4.13.5
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.34...v4.13.5)

Updates `js-yaml` from 3.14.2 to 3.15.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.2...3.15.2)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.5
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: js-yaml
  dependency-version: 3.15.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 08:58:38 +02:00
localai-org-maint-botandlocalai-org-maint-bot de563f17b5 fix(ui): omit GPU recommendations that do not fit (#11945)
When no sampled candidate fits GPU memory, ranking falls back to the
oversized pool and labels its first model Best fit. Keep GPU picks within
the existing 95% budget and hide the section when no candidate qualifies.
Remove static GPU starter picks so Home cannot reintroduce the same error.

Add browser regressions for both sections and document the empty result.
CPU fallback behavior stays unchanged.

Assisted-by: Codex:gpt-6 [Codex]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-10 00:53:47 +02:00
localai-org-maint-botandEttore Di Giacinto e78271563b fix(distributed): pass raw JPEG base64 through staging (#11956)
JPEG base64 starts with /9j and was classified as an absolute path in distributed mode. Detect valid JPEG base64 by its decoded magic bytes so vision requests reach the backend unchanged while malformed path-like inputs retain existing staging behavior.

Assisted-by: Codex:gpt-5

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-10 00:42:55 +02:00
Stefan Walcz 109244a76a [chat] feat: template.system_messages_after_first — merge or forward late system turns (#11906)
* feat(chat): template.system_messages_after_first — merge or forward late system turns

Tokenizer chat templates such as Qwen3.8 / Qwen3.8-Flash-Next raise
'System message must be at the beginning' for system-role messages that
appear after the leading system block, while agent frameworks (cogito tool
selection and adjustment prompts) legitimately append system instructions
mid-conversation. Every such request failed with a 500 (48 errors in one
10-task agent run).

New per-model option template.system_messages_after_first:
  merge  fold late system turns into the leading system message
  user   forward them as user-role turns at their original position
Default (unset) keeps the current pass-through behaviour.

Fixes #11876

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

* docs(model-config): document template.system_messages_after_first

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

* fix(config/meta): register template.system_messages_after_first in the field registry

TestAllFieldsHaveRegistryEntries requires every model-config field to have
a registry entry. Adds the entry (templates section, select component) and
the option list for the new field so the coverage gate passes.

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

---------

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-09-09 22:21:32 +02:00
localai-org-maint-botandmudler 752ee66506 chore: ⬆️ Update ggml-org/llama.cpp to f3f1a8f2760f28325a5ec20c05b171e5b7c83a29 (#11934)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-09 14:57:08 +02:00
localai-org-maint-botandEttore Di Giacinto f12bcfac9a fix(realtime): support voice profile switching (#11948)
* fix(realtime): support session voice profile switching

Keep the active resolved voice binding on the realtime session so updates can atomically replace model, voice, and profile parameters while releasing leases at the correct lifecycle boundaries.

Assisted-by: Codex:gpt-5

* docs(realtime): explain voice profile switching

Document the session.update payload for selecting a Voice Library URI and clarify precedence when changing the model in the same event.\n\nAssisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-09 10:38:23 +02:00
localai-org-maint-botandmudler dc353aecb6 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 1a2a8604a6c6c6413c06bf9adfc2f64329af4366 (#11932)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-09 09:25:33 +02:00
localai-org-maint-botandmudler 14b1796cdd chore: ⬆️ Update 0xShug0/audio.cpp to 05e508a70e3600b01454c647cdb122133ba8e64c (#11933)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-09 08:52:33 +02:00
localai-org-maint-botandmudler 89dcdea0a0 chore: ⬆️ Update NVIDIA/NeMo-Speech.cpp to a5b6953c4a579a2bbd1c0913ad8a85c2a4d99953 (#11935)
⬆️ Update NVIDIA/NeMo-Speech.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-09 08:52:12 +02:00
localai-org-maint-botandmudler afb9bfd183 chore: ⬆️ Update antirez/ds4 to 6289c516273979173abbc062209a81dd3706b804 (#11936)
⬆️ 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-09-09 08:51:36 +02:00
localai-org-maint-botandmudler 36cbe294b2 chore: ⬆️ Update ggml-org/whisper.cpp to c44b60b8053bbf2a5c1e014f11323fb3f2485177 (#11937)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-09 08:51:23 +02:00
localai-org-maint-botandlocalai-org-maint-bot be5342ef05 fix(worker): resolve temporary paths in tests (#11944)
Capacity guards reject symlink components. On macOS, temporary paths
start with /var, which links to /private/var, so the new staging tests
fail before exercising cleanup or capacity accounting.

Resolve the fixture directories before building guarded paths. Keep
explicit symlinks within the fixtures for containment tests.

Assisted-by: Codex:gpt-6

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-09 08:51:09 +02:00
localai-org-maint-botandEttore Di Giacinto bf93008ef3 fix(backends): bound temporary scratch files (#11941)
Backend processes shared the host temporary directory, so crashes could leave request images and audio behind until the filesystem filled. Give each process a locked LocalAI-owned runtime, remove scratch on exit, and sweep only marked abandoned runtimes at the next start.

Also close known request error-path leaks in the Python media backends, CrispASR, LongCat Video, and stable-diffusion.cpp.

Assisted-by: Codex:gpt-5

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-08 23:44:22 +02:00
localai-org-maint-botandEttore Di Giacinto 8c718441f6 fix(realtime): resolve pipeline voice profiles (#11942)
* fix(realtime): resolve saved voice profiles

Realtime pipelines now validate saved voices against the selected TTS model and retain leased audio until session teardown. Each synthesis request receives its own transcript parameter map.

Assisted-by: Codex:GPT-5

* docs(tts): document realtime voice defaults

Show how a realtime pipeline selects a saved Voice Library profile at session start. Clarify which session voice updates remain supported.

Assisted-by: Codex:GPT-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-08 23:42:31 +02:00
localai-org-maint-botandmudler 80872e5e8d chore(model-gallery): ⬆️ update checksum (#11939)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-08 23:13:20 +02:00
localai-org-maint-botandEttore Di Giacinto 780e458f11 fix(realtime): skip responses for empty transcripts (#11940)
Realtime turns could invoke the LLM and TTS even when speech transcription returned only whitespace. This let ambient noise produce unsolicited assistant output and polluted conversation history with an empty user turn.

Require non-blank transcript text before automatic response generation while preserving the completed transcription event.

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

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-08 23:12:57 +02:00
localai-org-maint-botandEttore Di Giacinto 2ba6600440 fix(detection): avoid temporary image files (#11938)
RF-DETR and Locate Anything wrote each decoded request image to the OS
temporary directory. A full temporary filesystem then disabled detection,
even though both native libraries already accept encoded image buffers.

Pass decoded images directly to the native buffer APIs. This removes the
request-time disk dependency and prevents crash-orphaned image files.

Assisted-by: Codex:gpt-5

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-08 22:59:57 +02:00
localai-org-maint-botandEttore Di Giacinto 8b5f62cc02 fix(distributed): bound ephemeral staging (#11924)
* docs: design ephemeral staging retention

High-frequency camera and audio inputs can fill a worker before the current six-hour cleanup window expires.

Define a one-hour retention policy that preserves recently modified request payloads.

Assisted-by: Codex:gpt-6

* docs: make ephemeral staging request-owned

Time-based retention can still fill a worker under bursty or high-rate input. Define request-lifecycle cleanup with capacity reservation and crash recovery.

Assisted-by: Codex:gpt-6

* feat(distributed): release exact staged keys

Request inputs need transport-neutral cleanup after backend calls. Add authenticated exact-key deletion for HTTP and coordinated cache eviction before shared-object deletion for S3/NATS.

Preserve URL metacharacters as filename data, reject unsafe keys, and remove upload sidecars while pruning empty request directories.

Assisted-by: Codex:gpt-6

* fix(distributed): release staged request inputs

Ephemeral inputs remained on workers after inference completed. Release each exact key after synchronous and streaming calls, including partial staging failures.

Use a bounded cleanup context so caller cancellation cannot suppress release. Preserve caller requests and backend results when cleanup fails.

Assisted-by: Codex:gpt-6

* feat(worker): bound ephemeral staging capacity

Concurrent staging can otherwise exceed its byte limit or consume reserved filesystem headroom. Explicit states keep bytes charged through each reservation, write, and commit transition.

Use a synchronized waiter count to prove Commit blocks until bounded writers close, and retain committed baselines across re-reservation.

Assisted-by: Codex:gpt-6

* feat(worker): enforce ephemeral staging bounds

Share capacity accounting across HTTP and S3 request inputs so workers
reject uploads before exhausting their filesystem. Reconcile exact release
and crash recovery with the same guard.

Assisted-by: Codex:gpt-6

* fix(distributed): make staged release race-safe

Pin each release path component before removing request-owned inputs and sidecars. Stop pruning when a directory identity changes.

Assisted-by: Codex:gpt-6

* fix(worker): retain staged input ownership

Keep committed request inputs protected from age recovery until exact release ends their ownership. Startup-scanned files remain reclaimable and can acquire ownership through reservation.

Assisted-by: Codex:gpt-6

* fix(worker): claim cached ephemeral inputs

Keep startup-scanned cache hits owned while inference uses them and reconcile their actual size against capacity.

Assisted-by: Codex:gpt-6

* fix(distributed): enforce staging admission

Propagate multimodal staging failures before inference and claim matching ephemeral HTTP cache entries. Fall back to PUT when an older worker does not support claims.

Assisted-by: Codex:gpt-6

* fix(distributed): close staging accounting gaps

Keep unknown-length reservations charged until bytes reach disk and bound NATS release waits by the lifecycle cleanup deadline.

Assisted-by: Codex:gpt-6

* fix(distributed): restage swept cache hits

Treat files removed between cache probing and ownership claims as misses so HTTP and S3 workers can stage them again.

Assisted-by: Codex:gpt-6

* fix(distributed): release staged inputs by request

Release every input from one inference with one fixed-size worker coordination request. Fence request ingress against cleanup, bound staging capacity and cleanup state, and retain exact-key release for rolling upgrades.

Assisted-by: Codex:gpt-6

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-08 18:13:40 +02:00
Plamen K. Kosseffandlocalai-org-maint-bot e8546965c7 fix(gallery): default audio-cpp models to backend:best (#11892)
* fix(gallery): default audio-cpp models to backend:best

The audio-cpp engine creates its session on the CPU backend when no
backend option is given, so every gallery model ran CPU-only even on
machines where a CUDA/Vulkan/Metal device was registered. backend:best
selects the best available backend and falls back to CPU.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>

* docs(audio-cpp): explain gallery device selection

Document automatic compute backend selection and the CPU override.

Assisted-by: Codex:gpt-6
Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>

---------

Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-08 08:45:17 +02:00
Plamen K. Kosseff ba88fb13ce fix(diffusers): auto-detect CUDA instead of defaulting to CPU (#11891)
The device fell back to CPU unless the model config set cuda: true,
while MPS right below was auto-detected — GPU hosts silently rendered
on CPU for any gallery entry missing the flag. Use CUDA whenever torch
reports it available (ROCm builds included), keep cuda: true as an
explicit force, and allow pinning with the device: model option (e.g.
options: ["device:cpu"]). Gallery entries stay untouched.

Assisted-by: Claude:claude-fable-5

Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
2026-09-08 08:44:35 +02:00
localai-org-maint-botandmudler e0179a1d3b chore(website): refresh the counters (#11902)
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-08 08:43:10 +02:00
localai-org-maint-botandmudler caa92a7af4 feat(swagger): update swagger (#11914)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-08 08:17:13 +02:00
localai-org-maint-botandmudler c718937900 chore: ⬆️ Update 0xShug0/audio.cpp to 9c6a282337cc83f227cc10428867a478947706ad (#11915)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-08 08:16:51 +02:00
localai-org-maint-botandmudler 20825e92c5 chore: ⬆️ Update CrispStrobe/CrispASR to 301acd87b036764973b8bfba71e0a21818036d33 (#11916)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-08 08:16:37 +02:00
localai-org-maint-botandmudler 3f9c0612ed chore: ⬆️ Update antirez/ds4 to f62ca29a308724cde5bc99134ede19104b2a3260 (#11917)
⬆️ 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-09-08 08:16:18 +02:00
localai-org-maint-botandmudler 630948683a chore: ⬆️ Update mudler/depth-anything.cpp to 14f7461d1f704761a038ac9f50dbde8fdb7275e2 (#11918)
⬆️ Update mudler/depth-anything.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-08 08:16:03 +02:00
localai-org-maint-botandmudler d3eaef2192 chore: ⬆️ Update leejet/stable-diffusion.cpp to d04e8950c1ec8d30248cbe996682b3182fb1adf6 (#11919)
⬆️ 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-09-08 08:15:48 +02:00
localai-org-maint-botandmudler 7e04ab9d87 chore: ⬆️ Update ggml-org/llama.cpp to 67672dc5b76f8bc17785a19d3dc6d1463fc2902c (#11920)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-08 08:15:31 +02:00
localai-org-maint-botandEttore Di Giacinto 561d623976 feat(gallery): add EXL3 vllm.cpp models (#11912)
* docs: design EXL3 gallery entries

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

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

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

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

Assisted-by: Codex:gpt-5

---------

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

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

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

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 19:32:17 +02:00
localai-org-maint-botandEttore Di Giacinto e494033607 fix(distributed): finalize stalled model uploads (#11910)
A worker can retain all model bytes with an unfinished-upload marker.
Retries then start at zero and repeatedly fail with HTTP 416.

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

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

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-07 18:52:29 +02:00
localai-org-maint-botandEttore Di Giacinto 88d19567b8 feat(faces): replay saved face enrollments (#11908)
Accept original embeddings and timestamps so clients can restore faces
when the in-memory store restarts. Derive stable IDs from exact vectors
to make registration retries preserve identity without duplicate entries.

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

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

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

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

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

* test(distributed): verify routed sound staging

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

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

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

---------

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

Assisted-by: Claude:claude-fable-5

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

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

* fix(stablediffusion): adapt streaming options

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

Assisted-by: Codex:gpt-5

---------

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

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

* fix(ds4): link upstream image helpers

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

Assisted-by: Codex:gpt-5

---------

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

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-07 08:22:41 +02:00
localai-org-maint-botandmudler 763bf6fbab chore: ⬆️ Update 0xShug0/audio.cpp to f6277c1695a83cf388a8282c1c1a8757cf626f18 (#11899)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-07 08:22:16 +02:00
Alex Mazzariolandlocalai-org-maint-bot 4d854f96a8 Update containers.md to fix podman image qualification (#11749)
* Update containers.md to fix podman image qualification

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

* docs(containers): clarify Podman image names

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

Assisted-by: Codex:gpt-5.6

---------

Signed-off-by: Alex Mazzariol <alex@alex-maz.info>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-06 12:45:56 +02:00
localai-org-maint-botandmudler c3f9e1a3a2 chore: ⬆️ Update ggml-org/llama.cpp to 74a7c897f049c17e7080423aa2111776eff6ebbf (#11889)
⬆️ Update ggml-org/llama.cpp

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-06 07:04:34 +00:00
localai-org-maint-botandmudler e170b2d615 chore: ⬆️ Update 0xShug0/audio.cpp to c41ffefb44c16345c0ac57a0030f4cd3b5fbc4b8 (#11888)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-06 08:55:28 +02:00
localai-org-maint-botandmudler b1dbc0686a chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to 040c8b344d8c670ce1475194751d119b5ef82c78 (#11890)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-06 08:55:10 +02:00
localai-org-maint-botandmudler cdcb724523 chore: ⬆️ Update NVIDIA/NeMo-Speech.cpp to ffa38cb2408f1e832a36d46fef5e3e1e80d07e6c (#11887)
⬆️ Update NVIDIA/NeMo-Speech.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-06 08:54:52 +02:00
Ettore Di Giacinto 18d20239df fix(ui): keep trace expansion stable during refresh
Squashed merge of #11278.

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

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

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

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

sglang's ReasoningParser keys off the opening tag:

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

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

sglang's own OpenAI server handles this via

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #11804

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

Assisted-by: Codex:gpt-5

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

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

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

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

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

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

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

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

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

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

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

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-05 22:09:16 +00:00
陈志谦 dd1776a91f docs: correct documented env var and CLI flag names (#11886)
- api-errors.md documented LOCALAI_SUBTLEKEY_COMPARISON (missing the
  KEY underscore); the code defines LOCALAI_SUBTLE_KEY_COMPARISON, so
  the documented variable silently did nothing
- cli-reference.md documented a --csrf flag / $LOCALAI_CSRF env that
  do not exist, with inverted semantics; the actual flag is
  --disable-csrf (LOCALAI_DISABLE_CSRF), 'Disable CSRF middleware
  (enabled by default)'
2026-09-05 23:49:10 +02:00
陈志谦 d9d68c595e docs: fix dead links in the PR template and backends docs (#11884)
- the PR template's 'Signed commits' anchor pointed at a CONTRIBUTING
  section that does not exist; repointed at the Commit messages
  section
- the longcat-video backend README linked a docs page that was never
  committed; replaced the dead link with plain text
- formal-verification/README.md used ../../../ for five in-repo
  packages (escaping the repo root); fixed to ../
2026-09-05 23:48:01 +02:00
陈志谦 24f897cd09 docs: fix dead anchors and a dead section link (#11885)
- middleware.md: the 'default detector' link used #instance-wide-defaults;
  the heading is 'Instance-wide default detector'
- the advanced/reference landing pages linked an ../installation/
  directory that does not exist in docs/content; dropped the dead
  bullets (deployment content lives under getting-started)
2026-09-05 23:47:13 +02:00
Abdullah Mansour | عبد الله منصور a98501d6ce docs(llama-cpp): clarify multimodal speculative decoding (#11700)
* docs(llama-cpp): clarify multimodal speculative decoding

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

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

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

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

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

---------

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

Bumps torch from 2.13.0+xpu to 2.14.0+xpu.

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 11:27:01 +02:00
localai-org-maint-botandmudler 32875a7089 chore: ⬆️ Update CrispStrobe/CrispASR to c6d4265ae2ee2b8931b09d7d25d5c65c75c36a41 (#11862)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-05 11:26:30 +02:00
localai-org-maint-botandmudler 7ad4384cbe chore: ⬆️ Update ggml-org/llama.cpp to 427291b5b34cd914a31b3fd3b61a68f6184f4b9f (#11866)
⬆️ Update ggml-org/llama.cpp

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


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

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

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

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

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

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-05 11:25:24 +02:00
localai-org-maint-botandmudler f09d6092ed chore: ⬆️ Update 0xShug0/audio.cpp to cd98afb3264044aeff33618df486c81de1bee7e7 (#11878)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-05 11:24:25 +02:00
dependabot[bot] 3e98ec935a chore(deps): bump grpcio from 1.82.1 to 1.83.1 in /backend/python/common/template (#11858)
chore(deps): bump grpcio in /backend/python/common/template

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

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

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

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-05 01:02:43 +02:00
localai-org-maint-botandmudler 87d15e7108 chore: ⬆️ Update NVIDIA/NeMo-Speech.cpp to 69d7fd46b253043ed6649bf35d68407448912a8e (#11863)
⬆️ Update NVIDIA/NeMo-Speech.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-04 16:57:26 +02:00
localai-org-maint-botandmudler a742c87f76 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to 78ce63c90d36b94a765073e8cbe0d241fc114590 (#11865)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-04 16:53:28 +02:00
localai-org-maint-botandmudler 1f2f931f53 chore: ⬆️ Update 0xShug0/audio.cpp to 8fe1ab805555c72aaf06d83828b523dfc6ed796d (#11861)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-04 09:16:30 +02:00
localai-org-maint-botandmudler 5e6a83694d chore(model-gallery): ⬆️ update checksum (#11868)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-04 09:00:41 +02:00
dependabot[bot] 5485a2e62b chore(deps): bump grpcio from 1.83.0 to 1.83.1 in /backend/python/vllm (#11853)
Bumps [grpcio](https://github.com/grpc/grpc) from 1.83.0 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.83.0...v1.83.1)

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 08:51:51 +02:00
localai-org-maint-botandmudler b35fdf0142 chore: ⬆️ Update ikawrakow/ik_llama.cpp to fe215a8ccdce6b844d2a3a3bbde08ae76a6284bf (#11864)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-04 08:49:55 +02:00
dependabot[bot] 99a2e855a8 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#11835)
Bumps the npm_and_yarn group with 2 updates in the /core/http/react-ui directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) and [browserslist](https://github.com/browserslist/browserslist).


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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 23:38:23 +02:00
localai-org-maint-botandEttore Di Giacinto 44de82e7c7 docs(dco): let maintainer-operated automation sign off (#11850)
The AI-assistant policy says an AI agent must never add a Signed-off-by
trailer, because only a human can certify the DCO. That is right for the
case it was written for: an assistant helping a contributor who then
signs off themselves.

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

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

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

Assisted-by: Claude:claude-opus-5

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

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-03 23:06:10 +02:00
localai-org-maint-botandEttore Di Giacinto 7a234473e8 fix(ci): unbreak the e2e build and the darwin vllm-metal pin (#11849)
Two independent breakages on master make every open pull request red,
for reasons unrelated to the changes under review.

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

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

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

Assisted-by: Claude:claude-opus-5

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

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

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

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

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

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

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

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

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

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

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

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

Assisted-by: Claude:claude-opus-5

Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>
2026-09-03 18:30:26 +02:00
localai-org-maint-botandmudler 49945fdd75 chore: ⬆️ Update 0xShug0/audio.cpp to c18b7f737aac0a2855e9f963a427498739ad40fe (#11843)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-03 18:03:20 +02:00
Claudio Maradonna 9e831d7709 fix(ds4): build CUDA kernels for the target architecture (#11840)
* fix(ds4): build CUDA kernels for the target architecture

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

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

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

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

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

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

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

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

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

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

---------

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

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

Assisted-by: Codex:gpt-5.6-sol

Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>
2026-09-03 13:02:33 +02:00
localai-org-maint-botandmudler e9ba60ba57 chore: ⬆️ Update CrispStrobe/CrispASR to ff3945c94cab9191199a5d531a32c4e9535c094b (#11829)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-03 13:02:00 +02:00
localai-org-maint-botandmudler 09f42db913 chore: ⬆️ Update NVIDIA/NeMo-Speech.cpp to 56b60d432f1731d6d5b28a4c5a31cbaf871daba1 (#11846)
⬆️ Update NVIDIA/NeMo-Speech.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-03 13:01:45 +02:00
localai-org-maint-botandmudler 1f4de9c809 chore: ⬆️ Update ikawrakow/ik_llama.cpp to caf7eae5282d840d77e9f91a56df7d2ef28fa612 (#11842)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-03 13:01:32 +02:00
lei_lei 7c921aa020 fix(ui): omit empty system prompt so model default applies (#11838)
Web Chat Settings left the System Prompt field empty but still treated a
blank/whitespace value as an explicit system turn. That satisfied
tokenizer chat templates' messages[0].role == system check and suppressed
the model YAML system_prompt on fresh chats.

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

Fixes #11834

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-02 12:37:12 +02:00
Claudio Maradonna 30e53f8d9f fix(ds4): enforce generation boundaries (#11821)
Clamp requested generation to the usable context after prompt sync while preserving the legacy 256-token fallback for omitted limits.

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

Assisted-by: Codex:gpt-5.6-sol

Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>
2026-09-02 12:36:10 +02:00
localai-org-maint-botandmudler 38d12f1ba4 chore: ⬆️ Update mudler/vllm.cpp to 6bf3abb580982f4fd2e4525ef37802ee0ce28981 (#11828)
⬆️ Update mudler/vllm.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-02 10:54:51 +02:00
localai-org-maint-botandmudler 35cfb4f433 chore: ⬆️ Update 0xShug0/audio.cpp to f334cff70a68ea3d2e40d6638733e8c1ec434164 (#11830)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-02 10:54:38 +02:00
localai-org-maint-botandmudler 2bbcfb3ec6 chore(model-gallery): ⬆️ update checksum (#11831)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 23:13:48 +02:00
e760eb236f chore: ⬆️ Update ggml-org/llama.cpp to 3466812d1f06728effe7c0f3c0671117f461672d (#11798)
* ⬆️ Update ggml-org/llama.cpp

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

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

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

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

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

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 23:13:11 +02:00
localai-org-maint-botandmudler aa1f0f8599 chore: ⬆️ Update mudler/vllm.cpp to 839ea1ceddb787778b6bd86a38a917a1aab74d8f (#11817)
⬆️ Update mudler/vllm.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 13:00:02 +02:00
localai-org-maint-botandmudler 1da0dd7de2 chore: ⬆️ Update 0xShug0/audio.cpp to 3497b7cc44753e2c141d8fe60ac42cec433e3281 (#11818)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 12:43:04 +02:00
localai-org-maint-botandmudler 34e9ad0cec chore: ⬆️ Update CrispStrobe/CrispASR to 78c545eb80409b91291642ddb23b3a6dc044fd34 (#11811)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 12:38:24 +02:00
localai-org-maint-botandmudler 06ebc28ae6 chore: ⬆️ Update mudler/depth-anything.cpp to 02ba082274e001a63e50de5a1eb0ccc50c6af4b1 (#11810)
⬆️ Update mudler/depth-anything.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 12:37:10 +02:00
localai-org-maint-botandEttore Di Giacinto b4e16b963f fix(ci): stop the e2e teardown from failing a green suite (#11816)
* fix(ci): remove the e2e container before removing its image

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

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

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

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

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

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

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

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 08:55:35 +02:00
localai-org-maint-botandmudler 834473a46d chore: ⬆️ Update 0xShug0/audio.cpp to bf3315fe4aaa16dc1125f580c29aff90a8900b36 (#11794)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 08:34:36 +02:00
localai-org-maint-botandmudler 2dcd853a2b chore: ⬆️ Update mudler/vllm.cpp to 6a544bdb89eb5a3512ac922241439e45f24d74d4 (#11797)
⬆️ Update mudler/vllm.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 00:06:49 +02:00
Claudio Maradonna 2ad4238416 fix(ds4): separate prefilled reasoning from content (#11802)
DS4 appends the opening thinking marker to tokenizer-templated prompts, so generated text begins directly with reasoning bytes. Starting DsmlParser in TEXT therefore puts the reasoning and closing marker in visible content.

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

Assisted-by: Codex:gpt-5

Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>
2026-09-01 00:06:23 +02:00
localai-org-maint-botandmudler 475dc254be chore: ⬆️ Update ikawrakow/ik_llama.cpp to 3c58ae373a0081c884099f435fb16ca720852bf7 (#11809)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-09-01 00:06:05 +02:00
Ettore Di GiacintoandClaude Opus 5 5555a3c569 fix(nodes): skip checksum sidecars when staging option dirs
stageDirectory and countStageableFiles already skip them, but
stageOptionDir did not - and it is the path sherpa-onnx voices take for
espeak-ng-data. The receiver writes "<file>.sha256" for every file it
accepts, so staging the sidecars made it write sidecars for those in
turn, one level deeper on every load.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142UfUh8HWxdim5JZqf8Tr6
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-01 00:03:04 +02:00
localai-org-maint-botandlocalai-org-maint-bot 357eabf382 feat(gallery): add Ornith 1.5 9B OBLITERATED (#11803)
Add Q4 and Q8 GGUF builds with their shared vision projector. The
model is a recent refusal-removed Ornith derivative for alignment and
red-team research.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-31 18:06:07 +02:00
ginavalent 04bca750d0 refactor(worker): iterate node label pairs with SplitSeq (#11806)
Signed-off-by: ginavalent <ginavalent@outlook.com>
2026-08-31 18:05:44 +02:00
localai-org-maint-botandmudler 69a5b54c0a chore: ⬆️ Update CrispStrobe/CrispASR to 18b3e3f8456748a6380dc4c13817df244b695d39 (#11799)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-31 13:11:15 +02:00
localai-org-maint-botandmudler bdf600ea2a chore: ⬆️ Update ggml-org/whisper.cpp to eacbd8234c6654cdbf2c377f72b2106875479bdc (#11796)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-31 13:11:05 +02:00
lei_lei b8316f2a2a fix(gallery): use published F16 mmproj for qwythos-9b (#11792)
Install 404s because the gallery still points at mmproj-...-f16.gguf.
HF only ships ...-F16.gguf now, with a different sha256.

Signed-off-by: lei_lei <96427312+leilei3167@users.noreply.github.com>
2026-08-31 10:17:11 +02:00
localai-org-maint-botandmudler 1ab3db4bb7 chore: ⬆️ Update leejet/stable-diffusion.cpp to 6b3edaaf32cc19e5bb2d819c788bd557eddc8eba (#11793)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-31 09:39:08 +02:00
Ettore Di Giacinto f829059e3d Rename branches from 'update/' to 'bump/'
Signed-off-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-08-31 09:38:28 +02:00
398 changed files with 23767 additions and 1412 deletions

No files matched your search

+34
View File
@@ -49,6 +49,40 @@ AI agents MUST NOT add `Co-Authored-By` trailers for themselves either.
A human reviewer owns the contribution; the AI's involvement is recorded
via `Assisted-by` (see below).
### Exception: automation operated by a maintainer
The rule above addresses the common case, an AI assistant helping a human
contributor who then signs off. It does not fit automation that a
maintainer runs themselves, which opens pull requests with no human
submitter to sign. Applied literally there, nothing ever signs and the
DCO check blocks the pull request permanently.
A maintainer-operated bot MUST therefore add a `Signed-off-by` trailer
naming **the maintainer who operates it**, not the bot and not the model:
```
Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
```
This is not the AI certifying the DCO. The maintainer is, exactly as they
do for a commit they typed by hand: they configured the automation, they
own its output, and they take responsibility for it when they merge it.
The `Assisted-by` trailer still records that a model produced the code, so
the provenance trail is unchanged.
The exception is narrow and does not widen the rule for anyone else:
- It applies only to automation a LocalAI maintainer operates and whose
output that maintainer reviews before merge.
- The sign-off names a real person who accepts DCO responsibility.
- An AI assistant helping an outside contributor still MUST NOT sign off.
That contributor adds their own trailer.
- A bot MUST NOT sign off on behalf of anyone other than its operator, and
MUST NOT add a trailer for a contributor whose branch it pushes to. If
automation contributes to someone else's branch, it leaves the sign-off
to that contributor.
## Attribution
When AI tools contribute to LocalAI development, proper attribution helps
+52
View File
@@ -236,6 +236,58 @@ Use these HTTP status codes:
If your endpoint should be tracked for usage (token counts, request counts), add the `usageMiddleware` to its middleware chain. See `core/http/middleware/usage.go` and how it's applied in `routes/openai.go`.
## Control-plane database health metrics
In distributed mode the frontend registers three OpenTelemetry gauges over the
PostgreSQL control-plane database (`core/services/monitoring/control_plane_db.go`,
wired in `core/application/distributed.go`). They reach `/metrics` through the
same Prometheus exporter as the rest of the API metrics.
| Metric | Meaning | Page when |
|--------|---------|-----------|
| `localai_control_plane_oldest_xmin_age` | Transactions elapsed since the oldest snapshot any backend still holds | above a few million, and rising |
| `localai_control_plane_longest_transaction_seconds` | Age of the longest open transaction | above 3600 |
| `localai_control_plane_dead_tuple_ratio` | Dead tuples per live tuple, labelled by `table`, on `backend_nodes`, `node_models` and `gallery_operations` | sustained above ~10 on a small table |
A sustained high `localai_control_plane_oldest_xmin_age` is the one to page on.
While it grows, autovacuum can reclaim nothing anywhere in the database no
matter how often it runs, so the dead tuple ratio keeps climbing and a six-row
registry table can reach hundreds of megabytes. Tuning autovacuum does not help.
The fix is to find the transaction holding the horizon open and clear it:
```sql
SELECT pid, state, age(backend_xmin) AS xmin_age, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;
```
Then `pg_terminate_backend(pid)` on the offenders, and `VACUUM (VERBOSE)` the
bloated tables once the horizon has moved.
**A healthy-looking xmin age does not on its own prove the horizon is free.**
The gauge reads `pg_stat_activity`, which only sees live backends. Two other
things pin the very same horizon and are invisible there, so either one can hold
vacuum back while the gauge reads 0:
```sql
SELECT gid, prepared, database, transaction FROM pg_prepared_xacts;
SELECT slot_name, active, xmin, catalog_xmin FROM pg_replication_slots;
```
An orphaned prepared transaction is cleared with `ROLLBACK PREPARED '<gid>'`,
and a stale slot with `pg_drop_replication_slot('<slot_name>')`. Check both
before concluding that a bloated table has some other cause.
Sampling is scrape-driven behind a 30 second cache, so scrape frequency does not
translate into database load. Failed and timed-out samples cost the same interval
as successful ones, so a database that is already struggling is not retried on
every scrape. A failed sample reports the last good values rather than failing the
scrape, because these gauges matter most when the database is struggling. Before
the first successful sample the gauges are absent rather than zero, since a zero
xmin age would read as a healthy horizon: alert on `absent()` too if you need to
distinguish "healthy" from "never sampled".
## Advertising surfaces — where to register a new capability
Beyond routing and auth, LocalAI publishes its capability surface in **four independent places**. When you add an endpoint — especially one introducing a net-new capability like a new media type or a new auth-gated feature — you must update every relevant surface. These aren't optional: missing them means the endpoint works but is invisible to clients, admins, and the UI.
+50
View File
@@ -77,6 +77,56 @@ spectrum. **Metal (Darwin) only** - it is a no-op on CUDA/CPU. Enable with
budget). Gallery entries built on this: `deepseek-v4-flash-q4-ssd` (153 GB Flash
on a 128 GB Mac) and `deepseek-v4-pro-q2-ssd` (433 GB Pro, experimental).
## CUDA architecture (do not build without one)
`backend/cpp/ds4/Makefile` drives upstream's **object targets** directly
(`$(MAKE) -C ds4 ds4.o ds4_cuda.o ...`), which bypasses upstream's own guard:
its `cuda` target refuses to build unless `CUDA_ARCH` is set, and offers
`cuda-spark` (sm_121, DGX Spark / GB10) and `cuda-generic` (native) instead.
Built with no `-arch`, nvcc targets its default architecture and the kernels run
as JIT'd PTX. On GB10 that silently corrupted every prefill batch of >=128
tokens - the model emitted text unrelated to the prompt and never closed its
thinking block, so `content` came back empty - and cost close to two orders of
magnitude of prefill throughput (4.21 t/s vs 325.70 t/s, same box, same model).
Short prompts stayed correct, which is why it went unnoticed.
The Makefile therefore picks a gencode list from `CUDA_MAJOR_VERSION` (a build
arg the backend matrix already declares, forwarded by `Dockerfile.ds4`) and
`uname -m`, and passes it as `NVCC_ARCH_FLAGS` to the sub-make. Upstream's
`CUDA_ARCH` accepts a single value, so it cannot express the fat binary the
shipped images need; a command-line assignment beats its `:=`. An empty
`CUDA_MAJOR_VERSION` falls back to upstream's `native` for local developer
builds, and an unrecognised one is a hard error - no CI runner has a GPU, so a
silent `native` there is exactly the failure mode this guards against.
`DS4_CUDA_HAVE_MXF4` is deliberately unset: upstream defines it only for
single-arch sm_120/sm_121 builds and guards it with a plain `#ifdef` rather than
`__CUDA_ARCH__`, so it cannot be combined with older archs. It gates an optional
MXFP4 indexer fast path whose `#ifndef` branch returns 0, so omitting it costs
speed, not correctness.
### Verifying a build
Check which flags a configuration resolves to, without compiling anything:
```
make -C backend/cpp/ds4 BUILD_TYPE=cublas CUDA_MAJOR_VERSION=13 NATIVE=false \
--eval='show: ; @echo [$(DS4_ARCH_MAKEVARS)]' show
```
Do not use `make -n` for this: the recipe is `+$(MAKE) ...`, and the `+` prefix
makes it run even under `-n`.
Then exercise the failure mode itself against a built backend. It only appears
above one prefill batch, so the ordinary `predict` spec cannot catch it:
```
BACKEND_BINARY=$(pwd)/backend/cpp/ds4/package/run.sh \
BACKEND_TEST_MODEL_FILE=/path/to/ds4flash.gguf \
BACKEND_TEST_CAPS=health,load,predict,long_prefill \
go test -count=1 -timeout=30m -v ./tests/e2e-backends/...
```
## Build matrix
| Build | Where | Notes |
+1 -1
View File
@@ -5,7 +5,7 @@ This PR fixes #
**Notes for Reviewers**
**[Signed commits](../CONTRIBUTING.md#signing-off-on-commits-developer-certificate-of-origin)**
**[Signed commits](../CONTRIBUTING.md#commit-messages)**
- [ ] Yes, I signed my commits.
- [ ] Documentation updated (docs/content/) for user-facing changes, or not applicable
+155 -3
View File
@@ -166,6 +166,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-whisper-medusa'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'true'
backend: "whisper-medusa"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -354,6 +367,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-funasr'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "funasr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -626,6 +652,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "1"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-whisper-medusa'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "whisper-medusa"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1069,6 +1108,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-funasr'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "funasr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1404,6 +1456,19 @@ include:
backend: "qwen-asr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
- build-type: 'l4t'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-funasr'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
ubuntu-version: '2404'
backend: "funasr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
- build-type: 'l4t'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -2388,6 +2453,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-funasr'
runs-on: 'ubuntu-latest'
base-image: "rocm/dev-ubuntu-24.04:7.2.1"
skip-drivers: 'false'
backend: "funasr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2668,6 +2746,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2204'
- build-type: 'l4t'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-funasr'
runs-on: 'ubuntu-24.04-arm'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
skip-drivers: 'true'
backend: "funasr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2204'
- build-type: 'l4t'
cuda-major-version: "12"
cuda-minor-version: "0"
@@ -2877,6 +2968,19 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'intel'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-funasr'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "funasr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'intel'
cuda-major-version: ""
cuda-minor-version: ""
@@ -3208,9 +3312,10 @@ include:
# consumed. Same reason CUDA needs its toolkit in base-image rather than in a
# builder image: this is the ds4 shape, not the llama-cpp one.
#
# No ROCm entry: upstream has no HIP configuration. No CUDA arm64 or L4T
# entry: upstream documents and validates CUDA on x86 only. Darwin/Metal is in
# the includeDarwin matrix below, built by scripts/build/audio-cpp-darwin.sh.
# ROCm uses upstream's HIP backend and the project-wide ROCm 7.2.1 base. No
# CUDA arm64 or L4T entry: upstream documents and validates CUDA on x86 only.
# Darwin/Metal is in the includeDarwin matrix below, built by
# scripts/build/audio-cpp-darwin.sh.
#
# No vulkan entry either, though Dockerfile.audio-cpp and the backend Makefile
# both handle BUILD_TYPE=vulkan for local builds. Every other vulkan backend
@@ -3278,6 +3383,19 @@ include:
dockerfile: "./backend/Dockerfile.audio-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-audio-cpp'
runs-on: 'ubuntu-latest'
base-image: "rocm/dev-ubuntu-24.04:7.2.1"
skip-drivers: 'false'
backend: "audio-cpp"
dockerfile: "./backend/Dockerfile.audio-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -6071,6 +6189,20 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-funasr'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "funasr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -6085,6 +6217,20 @@ include:
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-funasr'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "funasr"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -6324,6 +6470,9 @@ includeDarwin:
- backend: "mlx"
tag-suffix: "-metal-darwin-arm64-mlx"
build-type: "mps"
- backend: "mlx-video"
tag-suffix: "-metal-darwin-arm64-mlx-video"
build-type: "mps"
- backend: "chatterbox"
tag-suffix: "-metal-darwin-arm64-chatterbox"
build-type: "mps"
@@ -6460,6 +6609,9 @@ includeDarwin:
- backend: "qwen-asr"
tag-suffix: "-metal-darwin-arm64-qwen-asr"
build-type: "mps"
- backend: "funasr"
tag-suffix: "-metal-darwin-arm64-funasr"
build-type: "mps"
- backend: "nemo"
tag-suffix: "-metal-darwin-arm64-nemo"
build-type: "mps"
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Bump the CTranslate2 ROCm Python wheel release pin used by faster-whisper.
set -xe
REPO=$1 # OpenNMT/CTranslate2
FILE=$2 # backend/python/faster-whisper/install.sh
VAR=$3 # CTRANSLATE2_VERSION (used for output file names so the workflow can read them)
if [ -z "$FILE" ] || [ -z "$REPO" ] || [ -z "$VAR" ]; then
echo "usage: $0 <repo> <install-script> <var-name>" >&2
exit 1
fi
LATEST_RELEASE=$(curl -sS -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/releases/latest")
LATEST_TAG=$(python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])" <<< "$LATEST_RELEASE")
set +e
CURRENT_VERSION=$(grep -m1 "^${VAR}=" "$FILE" | cut -d= -f2 | sed -E 's/^\$\{[^:]+:-([^}]+)\}$/\1/')
CTRANSLATE2_ROCM_WHEEL_OS=$(grep -m1 '^CTRANSLATE2_ROCM_WHEEL_OS=' "$FILE" | cut -d= -f2 | sed -E 's/^\$\{[^:]+:-([^}]+)\}$/\1/')
set -e
if [ -z "$CURRENT_VERSION" ]; then
echo "Could not find $VAR in $FILE."
exit 0
fi
if [ -z "$CTRANSLATE2_ROCM_WHEEL_OS" ]; then
echo "Could not find CTRANSLATE2_ROCM_WHEEL_OS in $FILE."
exit 0
fi
ASSET_NAME="rocm-python-wheels-${CTRANSLATE2_ROCM_WHEEL_OS}.zip"
LATEST_RELEASE="$LATEST_RELEASE" python3 - "$ASSET_NAME" <<'PY'
import json
import os
import sys
asset_name = sys.argv[1]
release = json.loads(os.environ["LATEST_RELEASE"])
assets = {asset.get("name") for asset in release.get("assets", [])}
if asset_name not in assets:
raise SystemExit(f"Could not find release asset {asset_name!r}")
PY
sed -i "$FILE" -e "s/^${VAR}=.*/${VAR}=\${${VAR}:-${LATEST_TAG}}/"
echo "Changes: https://github.com/$REPO/compare/${CURRENT_VERSION}...${LATEST_TAG}" >> "${VAR}_message.txt"
echo "${LATEST_TAG}" >> "${VAR}_commit.txt"
+13 -8
View File
@@ -3,9 +3,9 @@
# darwin (Apple Silicon) install path. The macOS/Metal build
# (backend/python/vllm/install.sh, Darwin branch) installs vllm-metal, which is
# version-locked to a specific vLLM source release. install.sh derives that vLLM
# version at build time from vllm-metal's own installer at the pinned
# tag, so there is only ONE value to bump here -- mirroring bump_vllm_wheel.sh,
# which bumps the Linux cu130 wheel pin.
# version, and the wheel asset name, at build time from the pinned tag, so there
# is only ONE value to bump here -- mirroring bump_vllm_wheel.sh, which bumps the
# Linux cu130 wheel pin.
#
# This deliberately tracks vllm-project/vllm-metal, NOT vllm-project/vllm: the
# darwin build can only use the exact vLLM version vllm-metal supports, so it may
@@ -23,15 +23,20 @@ if [ -z "$FILE" ] || [ -z "$REPO" ] || [ -z "$VAR" ]; then
exit 1
fi
# vllm-metal ships frequent dev releases, all flagged as non-prerelease, so
# /releases/latest returns the newest one (with its cp312 wheel asset).
# vllm-metal ships frequent .dev releases, flagged as prereleases, alongside the
# stable ones. /releases/latest skips the prereleases and returns the newest
# stable tag, which is what darwin should pin: upstream deletes and re-cuts .dev
# tags, and a pin to a deleted tag 404s the whole build.
LATEST_TAG=$(gh_curl -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/releases/latest" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])")
# The coupled vLLM source version lives in vllm-metal's installer at that tag.
NEW_VLLM_VERSION=$(gh_curl \
"https://raw.githubusercontent.com/$REPO/$LATEST_TAG/install.sh" \
# The coupled vLLM release lives in .github/vllm-release-tag.commit at that tag
# (since vllm-metal 0.28); releases predating that file pinned it inline in their
# own install.sh. The extractor reads both forms.
NEW_VLLM_VERSION=$( { gh_curl \
"https://raw.githubusercontent.com/$REPO/$LATEST_TAG/.github/vllm-release-tag.commit" \
|| gh_curl "https://raw.githubusercontent.com/$REPO/$LATEST_TAG/install.sh"; } \
| "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib/extract-vllm-metal-version.sh")
if [ -z "$LATEST_TAG" ] || [ -z "$NEW_VLLM_VERSION" ]; then
+36 -3
View File
@@ -166,7 +166,40 @@ jobs:
push-to-fork: ci-forks/LocalAI
commit-message: ':arrow_up: Update ${{ matrix.repository }}'
title: 'chore: :arrow_up: Update ${{ matrix.repository }} to `${{ steps.bump.outputs.commit }}`'
branch: "update/${{ matrix.variable }}"
branch: "bump/${{ matrix.variable }}"
body: ${{ steps.bump.outputs.message }}
signoff: true
bump-ctranslate2-rocm-wheel:
# CTranslate2's ROCm wheels are published as release assets, so the
# faster-whisper hipblas install path pins the release tag used in the URL.
if: github.repository == 'mudler/LocalAI'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Bump CTranslate2 ROCm wheel pin 🔧
id: bump
run: |
bash .github/bump_ctranslate2_rocm_wheel.sh OpenNMT/CTranslate2 backend/python/faster-whisper/install.sh CTRANSLATE2_VERSION
{
echo 'message<<EOF'
cat "CTRANSLATE2_VERSION_message.txt"
echo EOF
} >> "$GITHUB_OUTPUT"
{
echo 'commit<<EOF'
cat "CTRANSLATE2_VERSION_commit.txt"
echo EOF
} >> "$GITHUB_OUTPUT"
rm -rfv CTRANSLATE2_VERSION_message.txt CTRANSLATE2_VERSION_commit.txt
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.UPDATE_BOT_TOKEN }}
push-to-fork: ci-forks/LocalAI
commit-message: ':arrow_up: Update OpenNMT/CTranslate2 ROCm wheel'
title: 'chore: :arrow_up: Update OpenNMT/CTranslate2 ROCm wheel to `${{ steps.bump.outputs.commit }}`'
branch: "update/CTRANSLATE2_VERSION"
body: ${{ steps.bump.outputs.message }}
signoff: true
@@ -203,7 +236,7 @@ jobs:
push-to-fork: ci-forks/LocalAI
commit-message: ':arrow_up: Update vllm-project/vllm cu130 wheel'
title: 'chore: :arrow_up: Update vllm-project/vllm cu130 wheel to `${{ steps.bump.outputs.commit }}`'
branch: "update/VLLM_VERSION"
branch: "bump/VLLM_VERSION"
body: ${{ steps.bump.outputs.message }}
signoff: true
@@ -241,6 +274,6 @@ jobs:
push-to-fork: ci-forks/LocalAI
commit-message: ':arrow_up: Update vllm-project/vllm-metal (darwin)'
title: 'chore: :arrow_up: Update vllm-metal (darwin) to `${{ steps.bump.outputs.commit }}`'
branch: "update/VLLM_METAL_VERSION"
branch: "bump/VLLM_METAL_VERSION"
body: ${{ steps.bump.outputs.message }}
signoff: true
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
if: ${{ !github.repository.fork && github.actor != 'dependabot[bot]' }}
- name: Run Gosec Security Scanner
if: ${{ !github.repository.fork && github.actor != 'dependabot[bot]' }}
uses: securego/gosec@v2.27.1
uses: securego/gosec@v2.29.0
with:
# we let the report trigger content trigger a failure using the GitHub Security features.
# backend/go/supertonic is excluded: it vendors upstream supertone-inc/supertonic
+12 -2
View File
@@ -80,8 +80,13 @@ jobs:
coverage/coverage.out
coverage/coverage.html
if-no-files-found: ignore
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
@@ -125,8 +130,13 @@ jobs:
export PATH="/opt/homebrew/opt/make/libexec/gnubin:$PATH"
PATH="$PATH:$HOME/go/bin" make protogen-go
PATH="$PATH:$HOME/go/bin" BUILD_TYPE="GITHUB_CI_HAS_BROKEN_METAL" CMAKE_ARGS="-DGGML_F16C=OFF -DGGML_AVX512=OFF -DGGML_AVX2=OFF -DGGML_FMA=OFF" make --jobs 4 --output-sync=target test
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+6 -1
View File
@@ -77,8 +77,13 @@ jobs:
- name: Test
run: |
PATH="$PATH:$HOME/go/bin" make backends/local-store backends/silero-vad backends/llama-cpp backends/whisper backends/piper backends/stablediffusion-ggml docker-build-e2e e2e-aio
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+6 -1
View File
@@ -63,8 +63,13 @@ jobs:
- name: Test Backend E2E
run: |
PATH="$PATH:$HOME/go/bin" make build-mock-backend test-e2e
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+6 -1
View File
@@ -88,8 +88,13 @@ jobs:
# CPU and runs the token_classify capability spec (byte-offset contract).
- name: Run live PII NER backend E2E
run: PATH="$PATH:$HOME/go/bin" make test-extra-backend-privacy-filter
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+6 -1
View File
@@ -75,8 +75,13 @@ jobs:
path: core/http/react-ui/coverage/
if-no-files-found: ignore
retention-days: 7
# tmate keeps the runner busy until the 6 hour job limit, so a single
# failure costs a whole runner slot. Only open a session when someone
# asked for one by labelling the pull request `ci-debug`, and cap the
# session so a forgotten label cannot idle a runner either.
- name: Setup tmate session if tests fail
if: ${{ failure() }}
if: ${{ failure() && contains(github.event.pull_request.labels.*.name, 'ci-debug') }}
timeout-minutes: 30
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
+1 -1
View File
@@ -8,7 +8,7 @@ Human contributors: see [CONTRIBUTING.md](CONTRIBUTING.md) for the development w
LocalAI follows the Linux kernel project's [guidelines for AI coding assistants](https://docs.kernel.org/process/coding-assistants.html). Before submitting AI-assisted code, read [.agents/ai-coding-assistants.md](.agents/ai-coding-assistants.md). Key rules:
- **No `Signed-off-by` from AI.** Only the human submitter may sign off on the Developer Certificate of Origin.
- **No `Signed-off-by` from AI.** Only the human submitter may sign off on the Developer Certificate of Origin. One exception: automation a maintainer operates signs off with *that maintainer's* identity, since no other human submitter exists to certify it. See [.agents/ai-coding-assistants.md](.agents/ai-coding-assistants.md).
- **No `Co-Authored-By: <AI>` trailers.** The human contributor owns the change.
- **Use an `Assisted-by:` trailer** to attribute AI involvement. Format: `Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]`.
- **The human submitter is responsible** for reviewing, testing, and understanding every line of generated code.
+1 -1
View File
@@ -218,7 +218,7 @@ LocalAI follows the **same guidelines as the Linux kernel project** for AI-assis
The full policy for this repository lives in [`.agents/ai-coding-assistants.md`](.agents/ai-coding-assistants.md). Summary:
- **AI agents MUST NOT add `Signed-off-by` tags.** Only humans can certify the Developer Certificate of Origin.
- **AI agents MUST NOT add `Signed-off-by` tags.** Only humans can certify the Developer Certificate of Origin. Automation operated by a maintainer is the one exception: it signs off with that maintainer's identity, because there is no other human submitter to certify it.
- **AI agents MUST NOT add `Co-Authored-By` trailers** attributing themselves as co-authors.
- **Attribute AI involvement with an `Assisted-by` trailer** in the commit message:
+29 -3
View File
@@ -1,5 +1,7 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/mlx-video backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
.NOTPARALLEL: backends/whisper-medusa
.NOTPARALLEL: backends/funasr
GOCMD=go
GOTEST=$(GOCMD) test
@@ -240,7 +242,7 @@ test-ci-scripts:
## pure stdlib on purpose so they run without any backend venv; the list is
## explicit because their siblings (model_identity_test) import grpc and the
## generated protobufs, which only exist inside a built backend.
PYTHON_HELPER_TESTS?=python_utils_test vllm_utils_test model_utils_test mlx_utils_test parent_watch_test
PYTHON_HELPER_TESTS?=python_utils_test vllm_utils_test model_utils_test mlx_utils_test parent_watch_test temp_utils_test
test-python-helpers:
cd backend/python/common && python3 -m unittest $(PYTHON_HELPER_TESTS)
@@ -393,9 +395,17 @@ test-e2e: build-mock-backend build-cloud-proxy-backend prepare-e2e run-e2e-image
$(MAKE) teardown-e2e
docker rmi localai-tests
# `docker stop` returns as soon as the container exits, but Docker reaps a
# `--rm` container asynchronously after that. The `docker rmi localai-tests` in
# test-e2e then loses the race against the reaper and fails on a still
# referenced image, turning a green suite red. Removing the container ourselves
# is synchronous, so the image reference is gone before we return. It also
# covers the case where nothing is running, which `docker stop` could not
# because it rejects an empty argument list.
teardown-e2e:
rm -rf $(TEST_DIR) || true
docker stop $$(docker ps -q --filter ancestor=localai-tests)
@CONTAINERS=$$(docker ps -aq --filter ancestor=localai-tests 2>/dev/null); \
if [ -n "$$CONTAINERS" ]; then docker rm -f $$CONTAINERS || true; fi
########################################################
## Integration and unit tests
@@ -604,6 +614,7 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/python/vllm
$(MAKE) -C backend/python/vllm-omni
$(MAKE) -C backend/python/longcat-video
$(MAKE) -C backend/python/mlx-video
$(MAKE) -C backend/python/sglang
$(MAKE) -C backend/python/vibevoice
$(MAKE) -C backend/python/liquid-audio
@@ -613,9 +624,11 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/python/fish-speech
$(MAKE) -C backend/python/faster-qwen3-tts
$(MAKE) -C backend/python/qwen-asr
$(MAKE) -C backend/python/funasr
$(MAKE) -C backend/python/nemo
$(MAKE) -C backend/python/voxcpm
$(MAKE) -C backend/python/faster-whisper
$(MAKE) -C backend/python/whisper-medusa
$(MAKE) -C backend/python/whisperx
$(MAKE) -C backend/python/ace-step
$(MAKE) -C backend/python/trl
@@ -636,6 +649,7 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/python/vllm test
$(MAKE) -C backend/python/vllm-omni test
$(MAKE) -C backend/python/longcat-video test
$(MAKE) -C backend/python/mlx-video test
$(MAKE) -C backend/python/vibevoice test
$(MAKE) -C backend/python/liquid-audio test
$(MAKE) -C backend/python/moonshine test
@@ -644,9 +658,11 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/python/fish-speech test
$(MAKE) -C backend/python/faster-qwen3-tts test
$(MAKE) -C backend/python/qwen-asr test
$(MAKE) -C backend/python/funasr test
$(MAKE) -C backend/python/nemo test
$(MAKE) -C backend/python/voxcpm test
$(MAKE) -C backend/python/faster-whisper test
$(MAKE) -C backend/python/whisper-medusa test
$(MAKE) -C backend/python/whisperx test
$(MAKE) -C backend/python/ace-step test
$(MAKE) -C backend/python/trl test
@@ -1250,6 +1266,10 @@ backends/mlx:
BACKEND=mlx $(MAKE) build-darwin-python-backend
./local-ai backends install "ocifile://$(abspath ./backend-images/mlx.tar)"
backends/mlx-video:
BACKEND=mlx-video $(MAKE) build-darwin-python-backend
./local-ai backends install "ocifile://$(abspath ./backend-images/mlx-video.tar)"
backends/diffuser-darwin:
BACKEND=diffusers $(MAKE) build-darwin-python-backend
./local-ai backends install "ocifile://$(abspath ./backend-images/diffusers.tar)"
@@ -1336,6 +1356,7 @@ BACKEND_RERANKERS = rerankers|python|.|false|true
BACKEND_TRANSFORMERS = transformers|python|.|false|true
BACKEND_OUTETTS = outetts|python|.|false|true
BACKEND_FASTER_WHISPER = faster-whisper|python|.|false|true
BACKEND_WHISPER_MEDUSA = whisper-medusa|python|.|false|true
BACKEND_COQUI = coqui|python|.|false|true
BACKEND_RFDETR = rfdetr|python|.|false|true
BACKEND_INSIGHTFACE = insightface|python|.|false|true
@@ -1357,6 +1378,7 @@ BACKEND_QWEN_TTS = qwen-tts|python|.|false|true
BACKEND_FISH_SPEECH = fish-speech|python|.|false|true
BACKEND_FASTER_QWEN3_TTS = faster-qwen3-tts|python|.|false|true
BACKEND_QWEN_ASR = qwen-asr|python|.|false|true
BACKEND_FUNASR = funasr|python|.|false|true
BACKEND_NEMO = nemo|python|.|false|true
BACKEND_VOXCPM = voxcpm|python|.|false|true
BACKEND_WHISPERX = whisperx|python|.|false|true
@@ -1427,6 +1449,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_RERANKERS)))
$(eval $(call generate-docker-build-target,$(BACKEND_TRANSFORMERS)))
$(eval $(call generate-docker-build-target,$(BACKEND_OUTETTS)))
$(eval $(call generate-docker-build-target,$(BACKEND_FASTER_WHISPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_WHISPER_MEDUSA)))
$(eval $(call generate-docker-build-target,$(BACKEND_COQUI)))
$(eval $(call generate-docker-build-target,$(BACKEND_RFDETR)))
$(eval $(call generate-docker-build-target,$(BACKEND_INSIGHTFACE)))
@@ -1448,6 +1471,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_QWEN_TTS)))
$(eval $(call generate-docker-build-target,$(BACKEND_FISH_SPEECH)))
$(eval $(call generate-docker-build-target,$(BACKEND_FASTER_QWEN3_TTS)))
$(eval $(call generate-docker-build-target,$(BACKEND_QWEN_ASR)))
$(eval $(call generate-docker-build-target,$(BACKEND_FUNASR)))
$(eval $(call generate-docker-build-target,$(BACKEND_NEMO)))
$(eval $(call generate-docker-build-target,$(BACKEND_VOXCPM)))
$(eval $(call generate-docker-build-target,$(BACKEND_WHISPERX)))
@@ -1477,6 +1501,8 @@ docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-nemo-speech-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
docker-build-backends: docker-build-whisper-medusa
docker-build-backends: docker-build-funasr
########################################################
### Mock Backend for E2E Tests
+13 -9
View File
@@ -6,13 +6,14 @@ ARG APT_PORTS_MIRROR=""
# ASR, VAD, diarization, source separation and music generation, wrapped as a
# LocalAI gRPC backend.
#
# BASE_IMAGE is ubuntu:24.04 for cpu and vulkan builds, or
# nvidia/cuda:<ver>-devel-ubuntu24.04 for cublas builds; both ship apt and
# Ubuntu Noble packages, and the CUDA base additionally provides
# /usr/local/cuda. BUILD_TYPE selects the engine backend in the Makefile:
# "" = portable CPU with all ggml CPU variants, "cublas" ->
# -DENGINE_ENABLE_CUDA=ON, "vulkan" -> -DENGINE_ENABLE_VULKAN=ON. Darwin
# (Metal) builds bypass this Dockerfile entirely.
# BASE_IMAGE is ubuntu:24.04 for cpu and vulkan builds,
# nvidia/cuda:<ver>-devel-ubuntu24.04 for cublas builds, or
# rocm/dev-ubuntu-24.04:<ver> for hipblas builds. All ship apt and Ubuntu Noble
# packages; the GPU bases also provide their toolkits. BUILD_TYPE selects the
# engine backend in the Makefile: "" = portable CPU with all ggml CPU variants,
# "cublas" -> -DENGINE_ENABLE_CUDA=ON, "hipblas" -> -DENGINE_ENABLE_HIP=ON,
# and "vulkan" -> -DENGINE_ENABLE_VULKAN=ON. Darwin (Metal) builds bypass this
# Dockerfile entirely.
#
# Upstream needs GCC 13 or newer, which ubuntu:24.04 and the CUDA 12/13
# devel-ubuntu24.04 images all provide.
@@ -62,7 +63,7 @@ ENV BUILD_TYPE=${BUILD_TYPE} \
APT_MIRROR=${APT_MIRROR} \
APT_PORTS_MIRROR=${APT_PORTS_MIRROR} \
DEBIAN_FRONTEND=noninteractive \
PATH=/usr/local/cuda/bin:${PATH}
PATH=/opt/rocm/bin:/usr/local/cuda/bin:${PATH}
WORKDIR /build
@@ -73,7 +74,7 @@ WORKDIR /build
# fallback of its own.
#
# BUILD_TYPE=vulkan additionally needs the loader headers and glslc; both are in
# Noble. The CUDA toolkit for BUILD_TYPE=cublas comes from BASE_IMAGE.
# Noble. The CUDA and ROCm toolkits come from their matching BASE_IMAGE.
RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
sh /usr/local/sbin/apt-mirror && \
apt-get update && \
@@ -83,6 +84,9 @@ RUN --mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mi
if [ "${BUILD_TYPE}" = "vulkan" ]; then \
apt-get install -y --no-install-recommends libvulkan-dev glslc; \
fi && \
if [ "${BUILD_TYPE}" = "hipblas" ]; then \
apt-get install -y --no-install-recommends hipblas-dev rocblas-dev; \
fi && \
if [ "${TARGETARCH}" = "arm64" ]; then \
apt-get install -y --no-install-recommends gcc-14 g++-14; \
fi && \
+3 -1
View File
@@ -10,6 +10,7 @@ FROM ${BASE_IMAGE} AS builder
ARG BUILD_TYPE
ARG TARGETARCH
ARG TARGETVARIANT
ARG CUDA_MAJOR_VERSION
ENV BUILD_TYPE=${BUILD_TYPE} \
DEBIAN_FRONTEND=noninteractive \
@@ -35,7 +36,8 @@ RUN apt-get update && \
COPY . /LocalAI
RUN --mount=type=cache,target=/root/.ccache,id=ds4-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
make -C /LocalAI/backend/cpp/ds4 BUILD_TYPE=${BUILD_TYPE} NATIVE=false grpc-server package
make -C /LocalAI/backend/cpp/ds4 BUILD_TYPE=${BUILD_TYPE} \
CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION} NATIVE=false grpc-server package
FROM scratch
COPY --from=builder /LocalAI/backend/cpp/ds4/package/. ./
+1 -1
View File
@@ -47,7 +47,7 @@ The backend system provides language-specific Dockerfiles that handle the build
- **mlx**: Apple Silicon optimization
- **diffusers**: Stable Diffusion models
- **longcat-video**: CUDA text/image-to-video and speech-driven avatar generation
- **Audio**: coqui, faster-whisper, kitten-tts
- **Audio**: coqui, faster-whisper, funasr, kitten-tts
- **Vision**: mlx-vlm, rfdetr
- **Specialized**: rerankers, chatterbox, kokoro
+4
View File
@@ -499,6 +499,10 @@ message ModelOptions {
// applied verbatim to the backend's engine constructor (e.g. vLLM AsyncEngineArgs).
// Unknown keys produce an error at LoadModel time.
string EngineArgs = 73;
string OriginalConfigFile = 77;
// EnvVars carries environment variables to be passed to the backend process.
map<string, string> EnvVars = 76;
// Proxy carries the cloud-proxy backend's per-model configuration.
// Empty for non-proxy backends.
+11 -1
View File
@@ -9,7 +9,7 @@
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
# rebuild and so the bump bot can see the pin.
AUDIO_CPP_VERSION?=89a0e9803380880305e9e1b83c93614f9df2c893
AUDIO_CPP_VERSION?=efb04233dab73aeee4b2912042a90e7b36329061
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
@@ -77,6 +77,16 @@ endif
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS += -DENGINE_ENABLE_CUDA=ON "-DCMAKE_CUDA_ARCHITECTURES=$(CUDA_ARCHITECTURES)"
else ifeq ($(BUILD_TYPE),hipblas)
ROCM_HOME ?= /opt/rocm
ROCM_PATH ?= /opt/rocm
export CXX=$(ROCM_HOME)/llvm/bin/clang++
export CC=$(ROCM_HOME)/llvm/bin/clang
AMDGPU_TARGETS ?= gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201
# audio.cpp forwards GPU_TARGETS to CMake's semicolon-delimited HIP list.
comma := ,
HIP_TARGETS := $(subst $(comma),;,$(AMDGPU_TARGETS))
CMAKE_ARGS += -DENGINE_ENABLE_HIP=ON "-DGPU_TARGETS=$(HIP_TARGETS)"
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS += -DENGINE_ENABLE_VULKAN=ON
else ifeq ($(UNAME_S),Darwin)
+2 -1
View File
@@ -22,7 +22,8 @@ bool voice_is_reference_file(const std::string &voice) {
RequestShape build_tts_shape(const backend::TTSRequest &request) {
RequestShape shape;
shape.has_voice_reference = voice_is_reference_file(request.voice());
shape.has_voice_reference = voice_is_reference_file(request.voice()) ||
request.params().find("multi_reference_cond") != request.params().end();
// !empty() as well as has_instructions(), and it must match the guard in
// build_tts_request: a request whose instructions are an empty string
// carries no style condition, so telling routing to prefer VoiceDesign for
@@ -128,6 +128,14 @@ static void test_tts_shape() {
check(!shape.has_voice_reference,
"shape: a preset name is not a voice reference");
}
{
backend::TTSRequest request;
(*request.mutable_params())["multi_reference_cond"] =
R"([{"audio":"one.wav","text":"one"}])";
const auto shape = build_tts_shape(request);
check(shape.has_voice_reference,
"shape: multi-reference conditioning is a voice reference");
}
{
backend::TTSRequest request;
request.set_voice(dir.string());
+4 -1
View File
@@ -103,6 +103,9 @@ engine::core::BackendType parse_backend_type(const std::string &value) {
if (value == "cuda") {
return engine::core::BackendType::Cuda;
}
if (value == "hip" || value == "rocm") {
return engine::core::BackendType::Hip;
}
if (value == "vulkan") {
return engine::core::BackendType::Vulkan;
}
@@ -116,7 +119,7 @@ engine::core::BackendType parse_backend_type(const std::string &value) {
return engine::core::BackendType::Cpu;
}
throw ConfigError("audio-cpp: unknown backend option '" + value +
"'. Known backends: cpu, cuda, vulkan, metal, best");
"'. Known backends: cpu, cuda, hip, rocm, vulkan, metal, best");
}
std::filesystem::path executable_directory() {
+12
View File
@@ -59,6 +59,12 @@ bool starts_with(const std::string &value, const std::string &prefix) {
value.compare(0, prefix.size(), prefix) == 0;
}
bool is_known_backend(const std::string &value) {
return value == "cpu" || value == "cuda" || value == "hip" ||
value == "rocm" || value == "vulkan" || value == "metal" ||
value == "best";
}
} // namespace
ParsedOptions parse_model_options(const std::vector<std::string> &entries) {
@@ -107,6 +113,12 @@ ParsedOptions parse_model_options(const std::vector<std::string> &entries) {
} else if (key == "task") {
parsed.options.task = value;
} else if (key == "backend") {
if (!is_known_backend(value)) {
parsed.error = "audio-cpp: unknown backend option '" + value +
"'. Known backends: cpu, cuda, hip, rocm, "
"vulkan, metal, best";
return parsed;
}
parsed.options.backend = value;
} else if (key == "model_spec_override") {
parsed.options.model_spec_override = value;
+1 -1
View File
@@ -17,7 +17,7 @@ struct ModelOptions {
std::string family;
// Pins the audio.cpp task, overriding RPC-based routing. Empty means route.
std::string task;
// ggml backend: cpu, cuda, vulkan, metal, best.
// ggml backend: cpu, cuda, hip (or rocm), vulkan, metal, best.
std::string backend = "cpu";
int device = 0;
// True once a `device:` entry has been seen. 0 is both the default and a
@@ -75,6 +75,11 @@ static void test_scalar_options() {
check(parse_model_options({"live_idle_timeout_ms:0"}).options.live_idle_timeout_ms == 0,
"an explicit 0 turns the live idle limit off rather than reverting to "
"the default");
check(parse_model_options({"backend:hip"}).error.empty(),
"HIP backend option is accepted");
check(parse_model_options({"backend:rocm"}).error.empty(),
"ROCm backend alias is accepted");
}
// Values containing colons must survive: split on the FIRST colon only.
@@ -124,6 +129,8 @@ static void test_errors() {
"negative device is rejected");
check(!parse_model_options({"threads:x"}).error.empty(),
"non-numeric threads is rejected");
check(!parse_model_options({"backend:unknown"}).error.empty(),
"unknown compute backend is rejected before model loading");
// Values too large for int must be rejected, not silently wrapped into a
// negative device index that then reaches the ggml backend selector.
+4 -3
View File
@@ -84,9 +84,10 @@ elseif(DS4_GPU STREQUAL "cpu")
set(DS4_OBJS "${DS4_DIR}/ds4_cpu.o")
endif()
# Upstream splits distributed inference, tensor-parallel transport, the SSD
# expert cache, and layer placement into GPU-agnostic translation units. Link
# them regardless of DS4_GPU.
# Upstream splits image preprocessing, distributed inference, tensor-parallel
# transport, the SSD expert cache, and layer placement into GPU-agnostic
# translation units. Link them regardless of DS4_GPU.
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_image.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_distributed.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_tp.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_ssd.o")
+73 -11
View File
@@ -1,10 +1,10 @@
# ds4 backend Makefile.
#
# Upstream pin lives below as DS4_VERSION?=8db89fe083ae4d17c9a2428ccd29803d3ae8f577
# Upstream pin lives below as DS4_VERSION?=6289c516273979173abbc062209a81dd3706b804
# (.github/bump_deps.sh) can find and update it - matches the
# llama-cpp / ik-llama-cpp / turboquant convention.
DS4_VERSION?=8db89fe083ae4d17c9a2428ccd29803d3ae8f577
DS4_VERSION?=6289c516273979173abbc062209a81dd3706b804
DS4_REPO?=https://github.com/antirez/ds4
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
@@ -18,21 +18,83 @@ UNAME_S := $(shell uname -s)
CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
# Upstream splits distributed inference, tensor-parallel transport, the SSD
# expert cache, and layer placement into GPU-agnostic translation units. They
# are shared by every GPU mode, so append them unconditionally below.
# nvcc must be told the target architecture explicitly for a cublas build, and
# this is not a tuning knob. Upstream's Makefile leaves CUDA_ARCH empty and its
# `cuda` target REFUSES to build without one, offering `cuda-spark`
# (CUDA_ARCH=sm_121) and `cuda-generic` (CUDA_ARCH=native) instead. We drive its
# object targets directly, which bypasses that guard: nvcc then compiles with no
# -arch at all, and the kernels run as JIT'd PTX for its default architecture.
# On GB10 (sm_121) that silently produced corrupt inference output above a
# ~128-token prefill batch and ~77x slower prefill (4.21 t/s vs 325.70 t/s,
# measured on the same box with the same model). No CI runner has a GPU, so
# `native` has nothing to enumerate there.
#
# Upstream's CUDA_ARCH takes a SINGLE value (see its sm_120/sm_121 special cases
# and the `-arch=$(CUDA_ARCH)` fallback), so it cannot express the fat binary
# these images need. NVCC_ARCH_FLAGS is overridden instead: a command-line
# assignment wins over the `:=` in upstream's Makefile, and its NVCCFLAGS
# expands whatever we pass.
#
# The architecture lists are copied from backend/go/vllm-cpp/Makefile rather
# than invented, so the two CUDA images cover the same GPUs: amd64 datacenter +
# consumer, and l4t/arm64 covering Orin (87), Thor (110) and GB10 (121a).
#
# -DDS4_CUDA_HAVE_MXF4=1 is deliberately NOT set. Upstream only defines it for
# single-arch sm_120/sm_121 builds and guards the code with a plain #ifdef
# rather than __CUDA_ARCH__, so it cannot be combined with older archs in one
# fat binary. It gates an optional MXFP4 indexer fast path whose #ifndef branch
# returns 0 and falls back to the generic path, so omitting it costs some speed
# on GB10, not correctness. Revisit if upstream adds __CUDA_ARCH__ guards.
#
# An EMPTY CUDA_MAJOR_VERSION means a local developer build, not CI: fall back
# to upstream's own `native` handling, which needs a GPU present but is what a
# developer building on their own machine wants. Both variables are `?=` so an
# explicit value on the command line always wins.
UNAME_M := $(shell uname -m)
CUDA_MAJOR_VERSION ?=
ifeq ($(BUILD_TYPE),cublas)
ifeq ($(CUDA_MAJOR_VERSION),13)
ifeq ($(UNAME_M),aarch64)
DS4_NVCC_ARCH_FLAGS ?= -gencode arch=compute_87,code=sm_87 \
-gencode arch=compute_90a,code=sm_90a \
-gencode arch=compute_100a,code=sm_100a \
-gencode arch=compute_110,code=sm_110 \
-gencode arch=compute_121a,code=sm_121a
else
DS4_NVCC_ARCH_FLAGS ?= -gencode arch=compute_80,code=sm_80 \
-gencode arch=compute_86,code=sm_86 \
-gencode arch=compute_89,code=sm_89 \
-gencode arch=compute_90a,code=sm_90a \
-gencode arch=compute_100a,code=sm_100a \
-gencode arch=compute_103a,code=sm_103a \
-gencode arch=compute_120a,code=sm_120a \
-gencode arch=compute_121a,code=sm_121a
endif
DS4_ARCH_MAKEVARS := NVCC_ARCH_FLAGS="$(DS4_NVCC_ARCH_FLAGS)"
else ifeq ($(CUDA_MAJOR_VERSION),)
# Local build: let upstream resolve the host GPU.
DS4_ARCH_MAKEVARS := CUDA_ARCH=native
else
$(error CUDA_MAJOR_VERSION=$(CUDA_MAJOR_VERSION) has no architecture list here (13 does). Leave it empty for a native build, or pass DS4_NVCC_ARCH_FLAGS explicitly.)
endif
endif
# Upstream splits image preprocessing, distributed inference, tensor-parallel
# transport, the SSD expert cache, and layer placement into GPU-agnostic
# translation units. They are shared by every GPU mode, so append them
# unconditionally below.
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS += -DDS4_GPU=cuda
DS4_OBJ_TARGET := ds4.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o \
DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o \
cuda/mmq/ds4_ggml_stubs.o cuda/mmq/ds4_mmq.o cuda/mmq/ds4_mmq_d2r.o \
cuda/mmq/quantize.o cuda/mmq/mmid.o cuda/mmq/mmvq.o cuda/mmq/ds4_repack.o
else ifeq ($(UNAME_S),Darwin)
CMAKE_ARGS += -DDS4_GPU=metal
DS4_OBJ_TARGET := ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
else
# CPU reference path (Linux only - macOS CPU path is broken by VM bug per ds4 README).
CMAKE_ARGS += -DDS4_GPU=cpu
DS4_OBJ_TARGET := ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
DS4_OBJ_TARGET := ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
endif
ifneq ($(NATIVE),true)
@@ -57,11 +119,11 @@ ds4:
# the right per-platform compile flags (Objective-C/Metal on Darwin, nvcc on Linux+CUDA).
ds4/ds4.o: ds4
ifeq ($(BUILD_TYPE),cublas)
+$(MAKE) -C ds4 $(DS4_OBJ_TARGET)
+$(MAKE) -C ds4 $(DS4_ARCH_MAKEVARS) $(DS4_OBJ_TARGET)
else ifeq ($(UNAME_S),Darwin)
+$(MAKE) -C ds4 ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+$(MAKE) -C ds4 ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
else
+$(MAKE) -C ds4 ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+$(MAKE) -C ds4 ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
endif
grpc-server: ds4/ds4.o
+2 -1
View File
@@ -92,7 +92,8 @@ std::string json_escape(const std::string &in) {
} // namespace
DsmlParser::DsmlParser() = default;
DsmlParser::DsmlParser(bool starts_in_thinking)
: state_(starts_in_thinking ? State::THINK : State::TEXT) {}
bool DsmlParser::IsInDsmlStructural() const {
switch (state_) {
+4 -2
View File
@@ -17,7 +17,9 @@ struct ParserEvent {
// Streaming parser. Stateless across instances; one per Predict call.
class DsmlParser {
public:
DsmlParser();
// The chat prompt may already contain the opening thinking marker, so the
// generated text can begin directly with reasoning bytes.
explicit DsmlParser(bool starts_in_thinking = false);
// Feed a chunk of raw model-emitted text. Appends classified events to
// `out`. May buffer the tail of `chunk` internally if it looks like a
@@ -43,7 +45,7 @@ public:
private:
enum class State { TEXT, THINK, TOOL_CALLS, INVOKE, PARAM_VALUE };
State state_ = State::TEXT;
State state_;
std::string buf_;
std::string current_tool_name_;
int tool_index_ = -1;
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: MIT
// Standalone regression tests for the DSML streaming parser.
//
// The repository's backend/cpp/run-unit-tests.sh harness compiles each
// *_test.cpp as a single translation unit, so include the implementation here.
#include "dsml_parser.cpp"
#include <cstdio>
#include <string>
#include <type_traits>
#include <vector>
namespace {
struct ParsedText {
std::string content;
std::string reasoning;
};
int failures = 0;
void check_equal(const std::string &got, const std::string &want,
const char *name) {
if (got == want) return;
std::fprintf(stderr, "FAIL %s: got \"%s\", want \"%s\"\n",
name, got.c_str(), want.c_str());
failures++;
}
void collect_text(const std::vector<ds4cpp::ParserEvent> &events,
ParsedText *parsed) {
for (const auto &event : events) {
if (event.type == ds4cpp::ParserEvent::CONTENT) {
parsed->content += event.text;
} else if (event.type == ds4cpp::ParserEvent::REASONING) {
parsed->reasoning += event.text;
}
}
}
ParsedText parse_chunks(ds4cpp::DsmlParser *parser,
const std::vector<std::string> &chunks) {
ParsedText parsed;
for (const auto &chunk : chunks) {
std::vector<ds4cpp::ParserEvent> events;
parser->Feed(chunk, events);
collect_text(events, &parsed);
}
std::vector<ds4cpp::ParserEvent> events;
parser->Flush(events);
collect_text(events, &parsed);
return parsed;
}
template <typename Parser>
void test_reasoning_opened_by_prompt() {
if constexpr (!std::is_constructible_v<Parser, bool>) {
std::fprintf(stderr,
"FAIL reasoning_opened_by_prompt: parser cannot start in thinking state\n");
failures++;
} else {
Parser parser(true);
ParsedText parsed = parse_chunks(
&parser,
{"We need to calculate factorial recursively.</think>Here is the answer."});
check_equal(parsed.reasoning,
"We need to calculate factorial recursively.",
"reasoning_opened_by_prompt:reasoning");
check_equal(parsed.content, "Here is the answer.",
"reasoning_opened_by_prompt:content");
}
}
template <typename Parser>
Parser text_parser() {
if constexpr (std::is_constructible_v<Parser, bool>) {
return Parser(false);
} else {
return Parser();
}
}
void test_reasoning_disabled() {
auto parser = text_parser<ds4cpp::DsmlParser>();
ParsedText parsed = parse_chunks(&parser, {"Here is the answer."});
check_equal(parsed.reasoning, "", "reasoning_disabled:reasoning");
check_equal(parsed.content, "Here is the answer.",
"reasoning_disabled:content");
}
void test_explicit_think_tag() {
auto parser = text_parser<ds4cpp::DsmlParser>();
ParsedText parsed = parse_chunks(
&parser, {"<think>reasoning</think>answer"});
check_equal(parsed.reasoning, "reasoning", "explicit_think_tag:reasoning");
check_equal(parsed.content, "answer", "explicit_think_tag:content");
}
template <typename Parser>
void test_split_think_close_marker() {
if constexpr (!std::is_constructible_v<Parser, bool>) {
std::fprintf(stderr,
"FAIL split_think_close_marker: parser cannot start in thinking state\n");
failures++;
} else {
Parser parser(true);
ParsedText parsed = parse_chunks(
&parser,
{"We need ", "to calculate ", "factorial", "</thi", "nk>",
"Here is ", "the answer."});
check_equal(parsed.reasoning, "We need to calculate factorial",
"split_think_close_marker:reasoning");
check_equal(parsed.content, "Here is the answer.",
"split_think_close_marker:content");
}
}
} // namespace
int main() {
test_reasoning_opened_by_prompt<ds4cpp::DsmlParser>();
test_reasoning_disabled();
test_explicit_think_tag();
test_split_think_close_marker<ds4cpp::DsmlParser>();
if (failures == 0) {
std::fprintf(stderr, "all dsml_parser checks passed\n");
return 0;
}
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <algorithm>
namespace ds4cpp {
inline int EffectiveGenerationLimit(int requested, int context_size,
int session_position) {
const int limit = requested > 0 ? requested : 256;
const int room = context_size - session_position;
if (room <= 1) return 0;
return std::min(limit, room - 1);
}
inline int RemainingGenerationBudget(int effective_limit, int produced) {
if (effective_limit <= produced) return 0;
return effective_limit - produced;
}
inline int SpeculativeAcceptedCapacity(int remaining, int draft_allowance,
int buffer_capacity) {
if (remaining <= 0 || draft_allowance < 0 || buffer_capacity <= 0) return 0;
return std::min({remaining, draft_allowance + 1, buffer_capacity});
}
} // namespace ds4cpp
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: MIT
#include "generation_limits.h"
#include <cstdio>
namespace {
int failures = 0;
void check_equal(int got, int want, const char *name) {
if (got == want) return;
std::fprintf(stderr, "FAIL %s: got %d, want %d\n", name, got, want);
failures++;
}
// Mutation caught: treating omitted or negative max_tokens as unlimited instead
// of preserving DS4's legacy 256-token default.
void test_nonpositive_uses_legacy_default_when_space_permits() {
check_equal(ds4cpp::EffectiveGenerationLimit(0, 4096, 100), 256,
"zero max_tokens uses legacy default");
check_equal(ds4cpp::EffectiveGenerationLimit(-1, 4096, 100), 256,
"negative max_tokens uses legacy default");
}
// Mutation caught: applying the legacy default without clamping it to the
// post-prefill context room and reserved slot.
void test_legacy_default_is_clamped_by_context() {
check_equal(ds4cpp::EffectiveGenerationLimit(0, 300, 100), 199,
"legacy default is context-clamped");
}
// Mutation caught: allowing an explicitly large request to overrun the
// post-prefill context boundary.
void test_large_positive_limit_is_clamped_to_context() {
check_equal(ds4cpp::EffectiveGenerationLimit(32768, 32768, 100), 32667,
"large positive is context-clamped");
}
// Mutation caught: replacing every positive request with the legacy default
// rather than preserving a smaller configured limit.
void test_smaller_positive_limit_is_preserved() {
check_equal(ds4cpp::EffectiveGenerationLimit(64, 4096, 100), 64,
"smaller positive is preserved");
}
// Mutation caught: consuming the final context slot instead of reserving it as
// required by DS4's generation loop.
void test_no_usable_room_returns_zero() {
check_equal(ds4cpp::EffectiveGenerationLimit(32, 100, 99), 0,
"one remaining context slot is not usable");
}
// Mutation caught: sending the original generation limit to a later
// speculative cycle instead of subtracting tokens already produced.
void test_remaining_budget_accounts_for_produced_tokens() {
check_equal(ds4cpp::RemainingGenerationBudget(10, 4), 6,
"remaining budget subtracts produced tokens");
check_equal(ds4cpp::RemainingGenerationBudget(10, 12), 0,
"remaining budget never becomes negative");
}
// Mutation caught: giving speculative evaluation capacity beyond either the
// output budget, the draft allowance plus its first target token, or the fixed
// accepted-token buffer.
void test_speculative_capacity_obeys_all_bounds() {
check_equal(ds4cpp::SpeculativeAcceptedCapacity(3, 8, 8), 3,
"capacity respects remaining output budget");
check_equal(ds4cpp::SpeculativeAcceptedCapacity(20, 4, 8), 5,
"capacity includes one target token beyond draft allowance");
check_equal(ds4cpp::SpeculativeAcceptedCapacity(20, 8, 6), 6,
"capacity respects fixed buffer");
}
} // namespace
int main() {
test_nonpositive_uses_legacy_default_when_space_permits();
test_legacy_default_is_clamped_by_context();
test_large_positive_limit_is_clamped_to_context();
test_smaller_positive_limit_is_preserved();
test_no_usable_room_returns_zero();
test_remaining_budget_accounts_for_produced_tokens();
test_speculative_capacity_obeys_all_bounds();
if (failures == 0) {
std::fprintf(stderr, "all generation limit checks passed\n");
return 0;
}
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
+184 -55
View File
@@ -10,7 +10,9 @@
#include "dsml_parser.h" // populated in Task 12
#include "dsml_renderer.h" // populated in Task 16
#include "generation_limits.h"
#include "kv_cache.h" // populated in Task 17
#include "request_lifecycle.h"
extern "C" {
#include "ds4.h"
@@ -35,6 +37,7 @@ extern "C" {
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
using grpc::Server;
@@ -69,6 +72,21 @@ int g_route_timeout_sec = 60;
std::atomic<Server *> g_server{nullptr};
static bool server_context_cancelled(void *ud) {
return static_cast<ServerContext *>(ud)->IsCancelled();
}
static void set_session_cancel(void *target, ds4cpp::CancelCallback callback,
void *userdata) noexcept {
ds4_session_set_cancel(static_cast<ds4_session *>(target), callback, userdata);
}
static bool request_should_continue(ds4cpp::RequestLifecycle *request,
ServerContext *context) {
request->ObserveContextCancellation(context->IsCancelled());
return request->ShouldContinue();
}
// Parse a "key:value" option string. Returns empty when no colon.
static std::pair<std::string, std::string> split_option(const std::string &opt) {
auto colon = opt.find(':');
@@ -238,37 +256,58 @@ static bool apply_engine_option(ds4_engine_options *opt, const std::string &key,
// When acting as a distributed coordinator, block until the worker route
// covers all layers (ds4_session_distributed_route_ready == 1) or the timeout
// elapses. Returns an empty string on success, or an error message to return
// to the client. No-op when not distributed.
// elapses. No-op when not distributed.
//
// Takes the g_engine_mu lock by reference and RELEASES it during each poll
// sleep. The wait can span up to g_route_timeout_sec seconds while workers
// connect; holding g_engine_mu the whole time would block the Status/Health
// readiness probes (they also lock g_engine_mu), making LocalAI's loader treat
// a still-starting worker as hung.
static std::string wait_route_ready(std::unique_lock<std::mutex> &lock) {
if (!g_distributed) return "";
struct RouteWaitResult {
ds4cpp::RouteWaitDecision decision;
std::string error;
};
static RouteWaitResult wait_route_ready(std::unique_lock<std::mutex> &lock,
ServerContext *context) {
if (!g_distributed) return {ds4cpp::RouteWaitDecision::Ready, ""};
char err[256] = {0};
const int deadline_polls = g_route_timeout_sec * 10; // 100ms per poll
for (int i = 0; i <= deadline_polls; ++i) {
int ready = ds4_session_distributed_route_ready(g_session, err, sizeof(err));
if (ready == 1) return "";
if (ready < 0) {
return std::string("ds4 distributed route error: ") +
(err[0] ? err : "unknown");
switch (ds4cpp::DecideRouteWait(ready, context->IsCancelled())) {
case ds4cpp::RouteWaitDecision::Ready:
return {ds4cpp::RouteWaitDecision::Ready, ""};
case ds4cpp::RouteWaitDecision::Error:
return {ds4cpp::RouteWaitDecision::Error,
std::string("ds4 distributed route error: ") +
(err[0] ? err : "unknown")};
case ds4cpp::RouteWaitDecision::Cancelled:
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
case ds4cpp::RouteWaitDecision::Pending:
break;
}
if (i == deadline_polls) break;
// Release the lock while sleeping so Status/Health and other RPCs can
// interleave during worker startup.
lock.unlock();
struct timespec ts = {0, 100L * 1000L * 1000L}; // 100ms
nanosleep(&ts, nullptr);
lock.lock();
if (context->IsCancelled()) {
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
}
// A concurrent Free() may have torn down the engine while we slept.
if (!g_engine || !g_session) {
return "ds4: model unloaded while waiting for distributed route";
return {ds4cpp::RouteWaitDecision::Error,
"ds4: model unloaded while waiting for distributed route"};
}
}
return "ds4 distributed route incomplete: workers not connected (layers uncovered)";
if (context->IsCancelled()) {
return {ds4cpp::RouteWaitDecision::Cancelled, ""};
}
return {ds4cpp::RouteWaitDecision::Error,
"ds4 distributed route incomplete: workers not connected (layers uncovered)"};
}
static void append_token_text(ds4_engine *engine, int token, std::string &out) {
@@ -341,9 +380,9 @@ static void collect_done(void *) {}
struct StreamCtx {
ds4_engine *engine;
ServerWriter<backend::Reply> *writer;
ds4cpp::RequestLifecycle *request;
ds4cpp::DsmlParser parser;
int tokens;
bool aborted;
// Track which tool indices we've seen TOOL_START for, so subsequent
// ARGS deltas can elide the redundant id/name fields.
std::vector<bool> tool_started;
@@ -351,7 +390,7 @@ struct StreamCtx {
static void stream_emit(void *ud, int token) {
auto *s = static_cast<StreamCtx *>(ud);
if (s->aborted) return;
if (!s->request->ShouldContinue()) return;
if (token == ds4_token_eos(s->engine)) return;
size_t len = 0;
const char *text = ds4_token_text(s->engine, token, &len);
@@ -401,7 +440,7 @@ static void stream_emit(void *ud, int token) {
reply.set_message(chunk);
reply.set_tokens(1);
if (any_field) {
if (!s->writer->Write(reply)) s->aborted = true;
s->request->ObserveStreamWrite(s->writer->Write(reply));
}
s->tokens++;
}
@@ -757,21 +796,30 @@ public:
return GStatus::OK;
}
GStatus Predict(ServerContext *, const backend::PredictOptions *request,
GStatus Predict(ServerContext *context, const backend::PredictOptions *request,
backend::Reply *reply) override {
std::unique_lock<std::mutex> lock(g_engine_mu);
if (!g_engine || !g_session) {
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
}
if (GStatus id = check_model_identity(request); !id.ok()) return id;
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
return GStatus(StatusCode::UNAVAILABLE, route_err);
RouteWaitResult route = wait_route_ready(lock, context);
if (route.decision == ds4cpp::RouteWaitDecision::Cancelled) {
return GStatus(StatusCode::CANCELLED, "ds4 request cancelled");
}
if (route.decision == ds4cpp::RouteWaitDecision::Error) {
return GStatus(StatusCode::UNAVAILABLE, route.error);
}
ds4_tokens prompt = {};
build_prompt(g_engine, request, &prompt);
int n_predict = request->tokens() > 0 ? request->tokens() : 256;
CollectCtx collect = {g_engine, "", {}, reply, 0, {}, "", ""};
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
const bool starts_in_thinking = think_enabled &&
request->usetokenizertemplate() && request->messages_size() > 0;
CollectCtx collect = {
g_engine, "", ds4cpp::DsmlParser(starts_in_thinking),
reply, 0, {}, "", ""};
ds4cpp::RequestLifecycle lifecycle;
std::string cache_key = render_prompt_text(request);
size_t cache_hit = maybe_load_cache(cache_key);
(void)cache_hit; // future: skip prompt prefix if hit covers full prompt
@@ -783,15 +831,27 @@ public:
// Either way g_session advances so the disk KV cache picks up a
// real checkpoint after the call (see maybe_save_cache below).
char err[256] = {0};
int rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
int rc;
{
ds4cpp::CancelCallbackScope cancel_scope(
g_session, set_session_cancel, server_context_cancelled, context);
rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
}
int prompt_len = prompt.len;
ds4_tokens_free(&prompt);
if (rc == 0) {
if (rc == DS4_SESSION_SYNC_INTERRUPTED) {
lifecycle.ObserveContextCancellation(true);
}
const bool generation_started = rc == 0;
if (generation_started) {
const int n_predict = ds4cpp::EffectiveGenerationLimit(
request->tokens(), ds4_session_ctx(g_session),
ds4_session_pos(g_session));
const int eos = ds4_token_eos(g_engine);
const int draft_max = ds4_engine_mtp_draft_tokens(g_engine);
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
int produced = 0;
while (produced < n_predict) {
if (!request_should_continue(&lifecycle, context)) break;
SampleParams sp = compute_sample_params(request, collect.parser, think_enabled);
int first;
if (sp.temperature <= 0.0f) {
@@ -806,13 +866,20 @@ public:
if (draft_max > 0 && sp.temperature <= 0.0f) {
constexpr int kAcceptedMax = 8;
int accepted[kAcceptedMax];
int cap = std::min(kAcceptedMax, draft_max + 1);
const int remaining = ds4cpp::RemainingGenerationBudget(
n_predict, produced);
const int cap = ds4cpp::SpeculativeAcceptedCapacity(
remaining, draft_max, kAcceptedMax);
int n = ds4_session_eval_speculative_argmax(
g_session, first, draft_max, eos,
g_session, first, remaining, eos,
accepted, cap, err, sizeof(err));
if (n < 0) { rc = -1; break; }
bool stop = false;
for (int j = 0; j < n; ++j) {
if (!request_should_continue(&lifecycle, context)) {
stop = true;
break;
}
if (accepted[j] == eos) { stop = true; break; }
collect_emit(&collect, accepted[j]);
if (++produced >= n_predict) { stop = true; break; }
@@ -821,12 +888,26 @@ public:
} else {
collect_emit(&collect, first);
if (++produced >= n_predict) break;
if (!request_should_continue(&lifecycle, context)) break;
rc = ds4_session_eval(g_session, first, err, sizeof(err));
if (rc != 0) break;
}
}
collect_done(&collect);
}
request_should_continue(&lifecycle, context);
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
rc == DS4_SESSION_SYNC_INTERRUPTED, rc != 0,
!lifecycle.ShouldFinalize());
if (!terminal.should_finalize) {
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
return GStatus(StatusCode::INTERNAL,
std::string("ds4 generation failed: ") + err);
}
return GStatus(StatusCode::CANCELLED,
"ds4 request cancelled");
}
if (generation_started) collect_done(&collect);
maybe_save_cache(cache_key);
// Flush any buffered parser state.
@@ -834,7 +915,7 @@ public:
collect.parser.Flush(events);
apply_events(&collect, events);
if (rc != 0) {
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
return GStatus(StatusCode::INTERNAL,
std::string("ds4 generation failed: ") + err);
}
@@ -857,21 +938,30 @@ public:
return GStatus::OK;
}
GStatus PredictStream(ServerContext *, const backend::PredictOptions *request,
GStatus PredictStream(ServerContext *context, const backend::PredictOptions *request,
ServerWriter<backend::Reply> *writer) override {
std::unique_lock<std::mutex> lock(g_engine_mu);
if (!g_engine || !g_session) {
return GStatus(StatusCode::FAILED_PRECONDITION, "ds4: model not loaded");
}
if (GStatus id = check_model_identity(request); !id.ok()) return id;
if (std::string route_err = wait_route_ready(lock); !route_err.empty()) {
return GStatus(StatusCode::UNAVAILABLE, route_err);
RouteWaitResult route = wait_route_ready(lock, context);
if (route.decision == ds4cpp::RouteWaitDecision::Cancelled) {
return GStatus(StatusCode::CANCELLED, "ds4 request cancelled");
}
if (route.decision == ds4cpp::RouteWaitDecision::Error) {
return GStatus(StatusCode::UNAVAILABLE, route.error);
}
ds4_tokens prompt = {};
build_prompt(g_engine, request, &prompt);
int n_predict = request->tokens() > 0 ? request->tokens() : 256;
StreamCtx s = {g_engine, writer, {}, 0, false, {}};
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
const bool starts_in_thinking = think_enabled &&
request->usetokenizertemplate() && request->messages_size() > 0;
ds4cpp::RequestLifecycle lifecycle;
StreamCtx s = {
g_engine, writer, &lifecycle,
ds4cpp::DsmlParser(starts_in_thinking), 0, {}};
std::string cache_key = render_prompt_text(request);
size_t cache_hit = maybe_load_cache(cache_key);
(void)cache_hit;
@@ -879,14 +969,26 @@ public:
// Manual loop on g_session - see Predict() above for the rationale.
// MTP speculative path used when ds4_engine_mtp_draft_tokens > 0.
char err[256] = {0};
int rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
int rc;
{
ds4cpp::CancelCallbackScope cancel_scope(
g_session, set_session_cancel, server_context_cancelled, context);
rc = ds4_session_sync(g_session, &prompt, err, sizeof(err));
}
ds4_tokens_free(&prompt);
if (rc == 0) {
if (rc == DS4_SESSION_SYNC_INTERRUPTED) {
lifecycle.ObserveContextCancellation(true);
}
const bool generation_started = rc == 0;
if (generation_started) {
const int n_predict = ds4cpp::EffectiveGenerationLimit(
request->tokens(), ds4_session_ctx(g_session),
ds4_session_pos(g_session));
const int eos = ds4_token_eos(g_engine);
const int draft_max = ds4_engine_mtp_draft_tokens(g_engine);
const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request));
int produced = 0;
while (produced < n_predict && !s.aborted) {
while (produced < n_predict) {
if (!request_should_continue(&lifecycle, context)) break;
SampleParams sp = compute_sample_params(request, s.parser, think_enabled);
int first;
if (sp.temperature <= 0.0f) {
@@ -900,50 +1002,77 @@ public:
if (draft_max > 0 && sp.temperature <= 0.0f) {
constexpr int kAcceptedMax = 8;
int accepted[kAcceptedMax];
int cap = std::min(kAcceptedMax, draft_max + 1);
const int remaining = ds4cpp::RemainingGenerationBudget(
n_predict, produced);
const int cap = ds4cpp::SpeculativeAcceptedCapacity(
remaining, draft_max, kAcceptedMax);
int n = ds4_session_eval_speculative_argmax(
g_session, first, draft_max, eos,
g_session, first, remaining, eos,
accepted, cap, err, sizeof(err));
if (n < 0) { rc = -1; break; }
bool stop = false;
for (int j = 0; j < n; ++j) {
if (!request_should_continue(&lifecycle, context)) {
stop = true;
break;
}
if (accepted[j] == eos) { stop = true; break; }
stream_emit(&s, accepted[j]);
if (s.aborted) { stop = true; break; }
if (!lifecycle.ShouldContinue()) { stop = true; break; }
if (++produced >= n_predict) { stop = true; break; }
}
if (stop) break;
} else {
stream_emit(&s, first);
if (s.aborted || ++produced >= n_predict) break;
if (!lifecycle.ShouldContinue() || ++produced >= n_predict) break;
if (!request_should_continue(&lifecycle, context)) break;
rc = ds4_session_eval(g_session, first, err, sizeof(err));
if (rc != 0) break;
}
}
stream_done(&s);
}
maybe_save_cache(cache_key);
// Flush parser state.
std::vector<ds4cpp::ParserEvent> events;
s.parser.Flush(events);
if (!events.empty() && !s.aborted) {
backend::Reply reply;
auto *delta = reply.add_chat_deltas();
for (const auto &e : events) {
if (e.type == ds4cpp::ParserEvent::CONTENT) {
delta->set_content(delta->content() + e.text);
} else if (e.type == ds4cpp::ParserEvent::REASONING) {
delta->set_reasoning_content(delta->reasoning_content() + e.text);
request_should_continue(&lifecycle, context);
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
rc == DS4_SESSION_SYNC_INTERRUPTED, rc != 0,
!lifecycle.ShouldFinalize());
terminal = ds4cpp::RunPostlude(
terminal,
[&]() {
ds4cpp::DsmlParser staged_parser = s.parser;
std::vector<ds4cpp::ParserEvent> events;
staged_parser.Flush(events);
bool write_succeeded = true;
if (!events.empty()) {
backend::Reply reply;
auto *delta = reply.add_chat_deltas();
for (const auto &e : events) {
if (e.type == ds4cpp::ParserEvent::CONTENT) {
delta->set_content(delta->content() + e.text);
} else if (e.type == ds4cpp::ParserEvent::REASONING) {
delta->set_reasoning_content(
delta->reasoning_content() + e.text);
}
}
write_succeeded = s.writer->Write(reply);
}
}
s.writer->Write(reply);
}
lifecycle.ObserveStreamWrite(write_succeeded);
request_should_continue(&lifecycle, context);
if (!lifecycle.ShouldFinalize()) return false;
s.parser = std::move(staged_parser);
if (generation_started) stream_done(&s);
return true;
},
[&]() { maybe_save_cache(cache_key); });
if (rc != 0 && !s.aborted) {
if (terminal.cause == ds4cpp::TerminalCause::EngineError) {
return GStatus(StatusCode::INTERNAL,
std::string("ds4 generation failed: ") + err);
}
if (terminal.cause == ds4cpp::TerminalCause::Cancelled) {
return GStatus(StatusCode::CANCELLED,
"ds4 request cancelled");
}
return GStatus::OK;
}
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: MIT
#pragma once
namespace ds4cpp {
using CancelCallback = bool (*)(void *);
using CancelSetter = void (*)(void *, CancelCallback, void *) noexcept;
class CancelCallbackScope {
public:
CancelCallbackScope(void *target, CancelSetter setter,
CancelCallback callback, void *userdata) noexcept
: target_(target), setter_(setter) {
setter_(target_, callback, userdata);
}
~CancelCallbackScope() noexcept {
setter_(target_, nullptr, nullptr);
}
CancelCallbackScope(const CancelCallbackScope &) = delete;
CancelCallbackScope &operator=(const CancelCallbackScope &) = delete;
private:
void *target_;
CancelSetter setter_;
};
enum class RouteWaitDecision {
Pending,
Ready,
Error,
Cancelled,
};
inline RouteWaitDecision DecideRouteWait(int route_status, bool cancelled) {
if (cancelled) return RouteWaitDecision::Cancelled;
if (route_status > 0) return RouteWaitDecision::Ready;
if (route_status < 0) return RouteWaitDecision::Error;
return RouteWaitDecision::Pending;
}
enum class TerminalCause {
Success,
Cancelled,
EngineError,
};
inline TerminalCause DecideTerminalCause(bool sync_interrupted,
bool engine_error,
bool abandoned) {
if (sync_interrupted) return TerminalCause::Cancelled;
if (engine_error) return TerminalCause::EngineError;
if (abandoned) return TerminalCause::Cancelled;
return TerminalCause::Success;
}
struct TerminalDecision {
TerminalCause cause;
bool should_finalize;
};
inline TerminalDecision ResolveTerminalDecision(bool sync_interrupted,
bool engine_error,
bool abandoned) {
return {
DecideTerminalCause(sync_interrupted, engine_error, abandoned),
!sync_interrupted && !abandoned,
};
}
template <typename Finalize, typename Persist>
TerminalDecision RunPostlude(TerminalDecision terminal,
Finalize transactional_finalize,
Persist persist) {
if (!terminal.should_finalize) return terminal;
if (!transactional_finalize()) {
terminal.should_finalize = false;
if (terminal.cause != TerminalCause::EngineError) {
terminal.cause = TerminalCause::Cancelled;
}
return terminal;
}
persist();
return terminal;
}
class RequestLifecycle {
public:
void ObserveContextCancellation(bool cancelled) {
context_cancelled_ = context_cancelled_ || cancelled;
}
void ObserveStreamWrite(bool succeeded) {
stream_write_aborted_ = stream_write_aborted_ || !succeeded;
}
bool ShouldContinue() const {
return !context_cancelled_ && !stream_write_aborted_;
}
bool ShouldFinalize() const {
return ShouldContinue();
}
private:
bool context_cancelled_ = false;
bool stream_write_aborted_ = false;
};
} // namespace ds4cpp
+414
View File
@@ -0,0 +1,414 @@
// SPDX-License-Identifier: MIT
// Standalone regression tests for DS4 request cancellation policy.
#include "request_lifecycle.h"
#include <cstdio>
namespace {
int failures = 0;
struct FakeCancelTarget {
ds4cpp::CancelCallback callback = nullptr;
void *userdata = nullptr;
int installs = 0;
int clears = 0;
};
struct PostludeCounts {
int finalize_attempts = 0;
int finalize_commits = 0;
int cache_persists = 0;
bool cache_followed_commit = true;
};
ds4cpp::TerminalDecision run_fake_postlude(
ds4cpp::TerminalDecision terminal, bool finalize_succeeds,
PostludeCounts *counts) {
return ds4cpp::RunPostlude(
terminal,
[=]() {
counts->finalize_attempts++;
if (!finalize_succeeds) return false;
counts->finalize_commits++;
return true;
},
[=]() {
counts->cache_followed_commit = counts->finalize_commits == 1;
counts->cache_persists++;
});
}
bool fake_cancel(void *) {
return false;
}
void fake_set_cancel(void *target, ds4cpp::CancelCallback callback,
void *userdata) noexcept {
auto *fake = static_cast<FakeCancelTarget *>(target);
fake->callback = callback;
fake->userdata = userdata;
if (callback) {
fake->installs++;
} else {
fake->clears++;
}
}
void check(bool condition, const char *name) {
if (condition) return;
std::fprintf(stderr, "FAIL %s\n", name);
failures++;
}
// Production mutation caught: treating an active request as abandoned would
// skip its parser finalization and cache save.
void test_active_request_continues_and_finalizes() {
ds4cpp::RequestLifecycle request;
check(request.ShouldContinue(), "active:continue");
check(request.ShouldFinalize(), "active:finalize");
}
// Production mutation caught: omitting the ServerContext cancellation branch
// would continue decoding and finalize a partial response.
void test_context_cancellation_stops_without_finalizing() {
ds4cpp::RequestLifecycle request;
request.ObserveContextCancellation(true);
check(!request.ShouldContinue(), "context_cancelled:stop");
check(!request.ShouldFinalize(), "context_cancelled:no_finalize");
}
// Production mutation caught: ignoring ServerWriter::Write failure would keep
// streaming and finalize a response whose client has gone away.
void test_stream_write_abort_stops_without_finalizing() {
ds4cpp::RequestLifecycle request;
request.ObserveStreamWrite(false);
check(!request.ShouldContinue(), "write_abort:stop");
check(!request.ShouldFinalize(), "write_abort:no_finalize");
}
// Production mutation caught: combining cancellation and write failure with
// AND would fail to stop when either signal occurs on its own.
void test_cancellation_and_write_abort_are_independent_or_conditions() {
ds4cpp::RequestLifecycle cancelled;
cancelled.ObserveContextCancellation(true);
cancelled.ObserveStreamWrite(true);
ds4cpp::RequestLifecycle write_aborted;
write_aborted.ObserveContextCancellation(false);
write_aborted.ObserveStreamWrite(false);
check(!cancelled.ShouldContinue(), "or:context_only");
check(!write_aborted.ShouldContinue(), "or:write_only");
}
// Production mutation caught: treating an incomplete distributed route as an
// error would return before workers have time to connect.
void test_route_wait_pending() {
check(ds4cpp::DecideRouteWait(0, false) ==
ds4cpp::RouteWaitDecision::Pending,
"route_wait:pending");
}
// Production mutation caught: failing to recognize a complete route would
// keep a ready inference request in the polling loop.
void test_route_wait_ready() {
check(ds4cpp::DecideRouteWait(1, false) ==
ds4cpp::RouteWaitDecision::Ready,
"route_wait:ready");
}
// Production mutation caught: ignoring a route probe error would poll until a
// misleading timeout instead of returning UNAVAILABLE promptly.
void test_route_wait_error() {
check(ds4cpp::DecideRouteWait(-1, false) ==
ds4cpp::RouteWaitDecision::Error,
"route_wait:error");
}
// Production mutation caught: omitting cancellation from route waiting would
// leave an abandoned request blocked until the distributed timeout.
void test_route_wait_cancellation() {
check(ds4cpp::DecideRouteWait(0, true) ==
ds4cpp::RouteWaitDecision::Cancelled,
"route_wait:cancelled");
}
// Production mutation caught: checking route errors before cancellation would
// report UNAVAILABLE for a request the client already abandoned.
void test_route_wait_cancellation_precedes_error() {
check(ds4cpp::DecideRouteWait(-1, true) ==
ds4cpp::RouteWaitDecision::Cancelled,
"route_wait:cancellation_precedence");
}
// Production mutation caught: classifying a successful active request as a
// terminal failure would suppress its normal response finalization.
void test_terminal_success() {
check(ds4cpp::DecideTerminalCause(false, false, false) ==
ds4cpp::TerminalCause::Success,
"terminal:success");
}
// Production mutation caught: treating DS4's cooperative sync interruption
// as an ordinary engine error would return INTERNAL instead of CANCELLED.
void test_terminal_sync_interruption_is_cancelled() {
check(ds4cpp::DecideTerminalCause(true, true, true) ==
ds4cpp::TerminalCause::Cancelled,
"terminal:sync_interrupted");
}
// Production mutation caught: treating every nonzero engine result as client
// abandonment would hide genuine DS4 failures behind CANCELLED.
void test_terminal_engine_error() {
check(ds4cpp::DecideTerminalCause(false, true, false) ==
ds4cpp::TerminalCause::EngineError,
"terminal:engine_error");
}
// Production mutation caught: ignoring an rc==0 context cancellation would
// finalize and cache an abandoned request.
void test_terminal_context_abandonment() {
ds4cpp::RequestLifecycle request;
request.ObserveContextCancellation(true);
check(ds4cpp::DecideTerminalCause(
false, false, !request.ShouldFinalize()) ==
ds4cpp::TerminalCause::Cancelled,
"terminal:context_abandonment");
}
// Production mutation caught: ignoring an rc==0 stream write failure would
// finalize and cache an abandoned streaming request.
void test_terminal_write_abandonment() {
ds4cpp::RequestLifecycle request;
request.ObserveStreamWrite(false);
check(ds4cpp::DecideTerminalCause(
false, false, !request.ShouldFinalize()) ==
ds4cpp::TerminalCause::Cancelled,
"terminal:write_abandonment");
}
// Production mutation caught: checking late cancellation or write failure
// before a determined ordinary DS4 error would replace INTERNAL with CANCELLED.
void test_terminal_engine_error_precedes_late_abandonment() {
ds4cpp::RequestLifecycle cancelled;
cancelled.ObserveContextCancellation(true);
ds4cpp::RequestLifecycle write_aborted;
write_aborted.ObserveStreamWrite(false);
check(ds4cpp::DecideTerminalCause(
false, true, !cancelled.ShouldFinalize()) ==
ds4cpp::TerminalCause::EngineError,
"terminal:engine_error_precedes_cancellation");
check(ds4cpp::DecideTerminalCause(
false, true, !write_aborted.ShouldFinalize()) ==
ds4cpp::TerminalCause::EngineError,
"terminal:engine_error_precedes_write_abort");
}
// Production mutation caught: using status precedence alone to gate side
// effects would finalize and persist an engine-error request abandoned later.
void test_abandoned_engine_error_keeps_internal_without_finalizing() {
ds4cpp::RequestLifecycle request;
request.ObserveContextCancellation(true);
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
false, true, !request.ShouldFinalize());
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"terminal_decision:abandoned_engine_error_status");
check(!terminal.should_finalize,
"terminal_decision:abandoned_engine_error_no_finalize");
}
// Production mutation caught: suppressing side effects for every engine error
// would change the existing finalization and cache behavior of active failures.
void test_active_engine_error_still_finalizes() {
ds4cpp::RequestLifecycle request;
ds4cpp::TerminalDecision terminal = ds4cpp::ResolveTerminalDecision(
false, true, !request.ShouldFinalize());
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"terminal_decision:active_engine_error_status");
check(terminal.should_finalize,
"terminal_decision:active_engine_error_finalize");
}
// Production mutation caught: persisting before committed finalization would
// cache a state whose final buffered stream reply was never completed.
void test_postlude_active_success_commits_then_persists() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::Success, true}, true, &counts);
check(terminal.cause == ds4cpp::TerminalCause::Success,
"postlude:success_outcome");
check(terminal.should_finalize, "postlude:success_committed");
check(counts.finalize_attempts == 1, "postlude:success_attempts");
check(counts.finalize_commits == 1, "postlude:success_commits");
check(counts.cache_persists == 1, "postlude:success_cache");
check(counts.cache_followed_commit, "postlude:success_cache_order");
}
// Production mutation caught: starting the postlude for an already-cancelled
// request would flush buffered parser state or persist an abandoned session.
void test_postlude_cancellation_skips_all_side_effects() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::Cancelled, false}, true, &counts);
check(terminal.cause == ds4cpp::TerminalCause::Cancelled,
"postlude:cancelled_outcome");
check(counts.finalize_attempts == 0, "postlude:cancelled_attempts");
check(counts.finalize_commits == 0, "postlude:cancelled_commits");
check(counts.cache_persists == 0, "postlude:cancelled_cache");
}
// Production mutation caught: committing the live parser or cache after a
// failed final Write would publish an abandoned streaming postlude.
void test_postlude_finalize_failure_cancels_without_commit_or_cache() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::Success, true}, false, &counts);
check(terminal.cause == ds4cpp::TerminalCause::Cancelled,
"postlude:write_failure_outcome");
check(!terminal.should_finalize, "postlude:write_failure_not_committed");
check(counts.finalize_attempts == 1, "postlude:write_failure_attempts");
check(counts.finalize_commits == 0, "postlude:write_failure_commits");
check(counts.cache_persists == 0, "postlude:write_failure_cache");
}
// Production mutation caught: skipping the postlude for every engine error
// would change active internal-error finalization and cache behavior.
void test_postlude_active_engine_error_finalizes_and_persists() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::EngineError, true}, true, &counts);
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"postlude:engine_error_outcome");
check(counts.finalize_attempts == 1, "postlude:engine_error_attempts");
check(counts.finalize_commits == 1, "postlude:engine_error_commits");
check(counts.cache_persists == 1, "postlude:engine_error_cache");
check(counts.cache_followed_commit, "postlude:engine_error_cache_order");
}
// Production mutation caught: replacing every failed transactional finalize
// with cancellation would hide an already-determined engine error.
void test_postlude_engine_error_finalize_failure_preserves_internal() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::EngineError, true}, false, &counts);
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"postlude:engine_error_write_failure_outcome");
check(!terminal.should_finalize,
"postlude:engine_error_write_failure_not_committed");
check(counts.finalize_attempts == 1,
"postlude:engine_error_write_failure_attempts");
check(counts.finalize_commits == 0,
"postlude:engine_error_write_failure_commits");
check(counts.cache_persists == 0,
"postlude:engine_error_write_failure_cache");
}
// Production mutation caught: status precedence must not grant side-effect
// permission to an engine-error request that was also abandoned.
void test_postlude_abandoned_engine_error_skips_all_side_effects() {
PostludeCounts counts;
ds4cpp::TerminalDecision terminal = run_fake_postlude(
{ds4cpp::TerminalCause::EngineError, false}, true, &counts);
check(terminal.cause == ds4cpp::TerminalCause::EngineError,
"postlude:abandoned_engine_error_outcome");
check(counts.finalize_attempts == 0,
"postlude:abandoned_engine_error_attempts");
check(counts.finalize_commits == 0,
"postlude:abandoned_engine_error_commits");
check(counts.cache_persists == 0,
"postlude:abandoned_engine_error_cache");
}
// Production mutation caught: failing to install the request callback would
// make DS4 prompt synchronization unable to observe client cancellation.
void test_cancel_callback_scope_installs_callback() {
FakeCancelTarget target;
int request_context = 42;
{
ds4cpp::CancelCallbackScope scope(
&target, fake_set_cancel, fake_cancel, &request_context);
check(target.callback == fake_cancel, "cancel_scope:callback_installed");
check(target.userdata == &request_context, "cancel_scope:userdata_installed");
check(target.installs == 1, "cancel_scope:installed_once");
}
}
// Production mutation caught: failing to clear the callback at every scope
// exit would leave DS4 pointing at a destroyed stack-owned ServerContext.
void test_cancel_callback_scope_clears_callback() {
FakeCancelTarget target;
int request_context = 42;
{
ds4cpp::CancelCallbackScope scope(
&target, fake_set_cancel, fake_cancel, &request_context);
}
check(target.callback == nullptr, "cancel_scope:callback_cleared");
check(target.userdata == nullptr, "cancel_scope:userdata_cleared");
check(target.clears == 1, "cancel_scope:cleared_once");
}
} // namespace
int main() {
test_active_request_continues_and_finalizes();
test_context_cancellation_stops_without_finalizing();
test_stream_write_abort_stops_without_finalizing();
test_cancellation_and_write_abort_are_independent_or_conditions();
test_route_wait_pending();
test_route_wait_ready();
test_route_wait_error();
test_route_wait_cancellation();
test_route_wait_cancellation_precedes_error();
test_terminal_success();
test_terminal_sync_interruption_is_cancelled();
test_terminal_engine_error();
test_terminal_context_abandonment();
test_terminal_write_abandonment();
test_terminal_engine_error_precedes_late_abandonment();
test_abandoned_engine_error_keeps_internal_without_finalizing();
test_active_engine_error_still_finalizes();
test_postlude_active_success_commits_then_persists();
test_postlude_cancellation_skips_all_side_effects();
test_postlude_finalize_failure_cancels_without_commit_or_cache();
test_postlude_active_engine_error_finalizes_and_persists();
test_postlude_engine_error_finalize_failure_preserves_internal();
test_postlude_abandoned_engine_error_skips_all_side_effects();
test_cancel_callback_scope_installs_callback();
test_cancel_callback_scope_clears_callback();
if (failures == 0) {
std::fprintf(stderr, "all request_lifecycle checks passed\n");
return 0;
}
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
+1 -1
View File
@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=15dddc60b3fc937a9e2a210359ecce392ccdf446
IK_LLAMA_VERSION?=3bb386eb68ffee0a5dc7db21da0735d594929eeb
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=
+11
View File
@@ -125,4 +125,15 @@ if(LLAMA_GRPC_BUILD_TESTS)
target_include_directories(thread_params_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(thread_params_test PRIVATE cxx_std_17)
add_test(NAME thread_params_test COMMAND thread_params_test)
add_executable(model_load_error_test model_load_error_test.cpp model_load_error.h)
target_include_directories(model_load_error_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(model_load_error_test PRIVATE cxx_std_17)
add_test(NAME model_load_error_test COMMAND model_load_error_test)
# Dead-stream tracker (standard library only).
add_executable(stream_peer_test stream_peer_test.cpp stream_peer.h)
target_include_directories(stream_peer_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(stream_peer_test PRIVATE cxx_std_17)
add_test(NAME stream_peer_test COMMAND stream_peer_test)
endif()
+1 -1
View File
@@ -1,5 +1,5 @@
LLAMA_VERSION?=d7bd3bfcad3e29c7e49fd26f38c79ee3e9a3fd6b
LLAMA_VERSION?=df03399b885831b2a1603b3abb0d8c156808e363
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=
+25 -8
View File
@@ -53,9 +53,11 @@
#include "arg.h"
#include "chat-auto-parser.h"
#include "llama_compat.h" // fork-skew switches, generated by prepare.sh
#include "model_load_error.h"
#include "thread_params.h"
#include "message_content.h"
#include "passthrough_options.h"
#include "stream_peer.h"
#include "tts_request_options.h"
#include <getopt.h>
#include <grpcpp/ext/proto_server_reflection_plugin.h>
@@ -1628,7 +1630,8 @@ public:
{
std::lock_guard<std::mutex> lock(error_capture_data.error_mutex);
if (!error_capture_data.captured_error.empty()) {
error_msg += ". Error: " + error_capture_data.captured_error;
error_msg += ". Error: " +
localai::model_load_error_with_hint(error_capture_data.captured_error);
} else {
error_msg += ". Model file may not exist or be invalid.";
}
@@ -2266,6 +2269,11 @@ public:
// such concept, so there is nothing to emit — the real tokens arrive in
// the loop below. Feeding this null into build_reply_from_json would
// throw (uncaught) and surface as a generic RPC error.
// A write that returns false means the peer is gone for good. Track it
// so the loop below stops decoding instead of feeding a dead stream —
// see stream_peer.h for why that matters to everyone else's requests.
llama_grpc::StreamPeer peer;
if (first_res_json.is_null()) {
// skip the begin-of-stream marker
} else if (first_res_json.is_array()) {
@@ -2278,17 +2286,21 @@ public:
if (!is_role_init) {
attach_chat_deltas(reply, first_result.get());
}
writer->Write(reply);
peer.observe_write(writer->Write(reply));
if (peer.gone()) {
break;
}
}
} else {
auto reply = build_reply_from_json(first_res_json, first_result.get());
attach_chat_deltas(reply, first_result.get());
writer->Write(reply);
peer.observe_write(writer->Write(reply));
}
// Process subsequent results
while (rd.has_next()) {
if (context->IsCancelled()) {
peer.observe_cancelled(context->IsCancelled());
if (peer.gone()) {
break;
}
@@ -2309,17 +2321,22 @@ public:
if (!is_role_init) {
attach_chat_deltas(reply, result.get());
}
writer->Write(reply);
peer.observe_write(writer->Write(reply));
if (peer.gone()) {
break;
}
}
} else {
auto reply = build_reply_from_json(res_json, result.get());
attach_chat_deltas(reply, result.get());
writer->Write(reply);
peer.observe_write(writer->Write(reply));
}
}
// Check if context was cancelled during processing
if (context->IsCancelled()) {
// Returning here is what releases the slot: ~server_response_reader()
// posts SERVER_TASK_TYPE_CANCEL for whatever is still decoding.
peer.observe_cancelled(context->IsCancelled());
if (peer.gone()) {
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <string>
namespace localai {
inline std::string model_load_error_with_hint(const std::string& error) {
const std::string mismatch = "wrong number of tensors; expected ";
const std::string got = ", got ";
const std::string::size_type mismatch_pos = error.find(mismatch);
if (mismatch_pos == std::string::npos ||
error.find(got, mismatch_pos + mismatch.size()) == std::string::npos) {
return error;
}
return error +
" Hint: the model may be incompatible with this llama.cpp backend "
"or the GGUF file may be corrupt. Try a newer compatible backend "
"and verify or re-download the model file.";
}
} // namespace localai
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
#include "model_load_error.h"
#include <cassert>
#include <string>
int main() {
const std::string issue_error =
"llama_model_load: error loading model: done_getting_tensors: wrong number of tensors; expected 2131, got 720; "
"llama_model_load_from_file_impl: failed to load model";
const std::string issue_result = localai::model_load_error_with_hint(issue_error);
assert(issue_result.compare(0, issue_error.size(), issue_error) == 0);
assert(issue_result.find("incompatible") != std::string::npos);
assert(issue_result.find("corrupt") != std::string::npos);
const std::string generic_error =
"wrong number of tensors; expected 42, got 17";
const std::string generic_result = localai::model_load_error_with_hint(generic_error);
assert(generic_result.compare(0, generic_error.size(), generic_error) == 0);
assert(generic_result.size() > generic_error.size());
const std::string unrelated_error = "failed to open GGUF file";
assert(localai::model_load_error_with_hint(unrelated_error) == unrelated_error);
}
+32
View File
@@ -15,12 +15,40 @@ if [ -d "patches" ]; then
done
fi
## Apple RDMA link fixup.
## ggml-rpc hands Apple's librdma to the linker with
## target_link_options(ggml-rpc PRIVATE "LINKER:-weak_library,..."). Link options are not
## a usage requirement of a static library, so in our BUILD_SHARED_LIBS=OFF build the flag
## dies with libggml-rpc.a and every ibv_* symbol transport-apple.cpp reaches for comes out
## undefined when grpc-server and ggml-rpc-server link. Re-declare the same weak link as
## INTERFACE so it travels to whoever links the static library.
##
## Guarded on the marker so a second prepare.sh over the same checkout is a no-op, and on
## GGML_RPC_RDMA_APPLE so forks that branched before the Apple RDMA transport (turboquant,
## bonsai) are left alone.
RPC_CMAKE=llama.cpp/ggml/src/ggml-rpc/CMakeLists.txt
if [ -f "$RPC_CMAKE" ] && grep -q "GGML_RPC_RDMA_APPLE" "$RPC_CMAKE" && ! grep -q "LOCALAI_RDMA_IFACE" "$RPC_CMAKE"; then
echo "==> ggml-rpc carries the Apple RDMA transport, re-declaring its weak librdma link as INTERFACE"
cat >> "$RPC_CMAKE" <<'EOF'
# LOCALAI_RDMA_IFACE: added by backend/cpp/llama-cpp/prepare.sh
if (GGML_RPC_RDMA AND APPLE AND NOT BUILD_SHARED_LIBS)
target_link_options(ggml-rpc INTERFACE "LINKER:-weak_library,${RDMA_LIB}")
endif()
EOF
fi
for file in $(ls llama.cpp/tools/server/); do
cp -rfv llama.cpp/tools/server/$file llama.cpp/tools/grpc-server/
done
cp -r CMakeLists.txt llama.cpp/tools/grpc-server/
cp -r grpc-server.cpp llama.cpp/tools/grpc-server/
# Model-load diagnostics (included by grpc-server.cpp) and their standalone
# regression test.
cp -r model_load_error.h llama.cpp/tools/grpc-server/
cp -r model_load_error_test.cpp llama.cpp/tools/grpc-server/
# Shared message-reconstruction helpers (included by grpc-server.cpp) and their
# unit test (compiled only when -DLLAMA_GRPC_BUILD_TESTS=ON).
cp -r message_content.h llama.cpp/tools/grpc-server/
@@ -39,6 +67,10 @@ cp -r thread_params_test.cpp llama.cpp/tools/grpc-server/
# test (run via backend/cpp/run-unit-tests.sh; also buildable under ctest).
cp -r parent_watch.h llama.cpp/tools/grpc-server/
cp -r parent_watch_test.cpp llama.cpp/tools/grpc-server/
# Dead-stream tracker (included by grpc-server.cpp) and its standalone unit
# test (run via backend/cpp/run-unit-tests.sh; also buildable under ctest).
cp -r stream_peer.h llama.cpp/tools/grpc-server/
cp -r stream_peer_test.cpp llama.cpp/tools/grpc-server/
cp -rfv llama.cpp/vendor/nlohmann/json.hpp llama.cpp/tools/grpc-server/
cp -rfv llama.cpp/vendor/cpp-httplib/httplib.h llama.cpp/tools/grpc-server/
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT
#pragma once
namespace llama_grpc {
// Tracks whether a server-streaming RPC still has somewhere to send tokens.
//
// grpc::ServerWriter::Write() returns false once the peer is gone, and a
// stream never recovers afterwards. Ignoring that result is not harmless: the
// handler goes on draining decoded tokens into a dead stream, so the llama.cpp
// slot stays busy for the rest of the request's token budget. A model config
// with no max_tokens and a large context turns that into tens of minutes per
// abandoned request, and the slots are exactly what every other request queues
// behind.
//
// Returning as soon as the peer is gone is what frees the slot: the handler's
// server_response_reader then goes out of scope and its destructor posts
// SERVER_TASK_TYPE_CANCEL for whatever is still decoding.
class StreamPeer {
public:
// Records the outcome of a Write(). Once a write has failed the peer stays
// gone -- a later write cannot succeed on a broken stream.
void observe_write(bool ok) noexcept {
if (!ok) {
gone_ = true;
}
}
// Folds in the RPC's own cancellation flag, so callers have a single
// predicate to test rather than two that can disagree.
void observe_cancelled(bool cancelled) noexcept {
if (cancelled) {
gone_ = true;
}
}
bool gone() const noexcept { return gone_; }
bool alive() const noexcept { return !gone_; }
private:
bool gone_ = false;
};
} // namespace llama_grpc
@@ -0,0 +1,67 @@
#include "stream_peer.h"
#include <cstdio>
namespace {
int failures = 0;
void check(bool condition, const char *what) {
if (!condition) {
std::fprintf(stderr, "FAIL: %s\n", what);
++failures;
}
}
} // namespace
int main() {
{
llama_grpc::StreamPeer peer;
check(peer.alive(), "a fresh peer is alive");
check(!peer.gone(), "a fresh peer is not gone");
}
{
llama_grpc::StreamPeer peer;
peer.observe_write(true);
peer.observe_write(true);
check(peer.alive(), "successful writes keep the peer alive");
}
{
llama_grpc::StreamPeer peer;
peer.observe_write(false);
check(peer.gone(), "a failed write marks the peer gone");
}
{
// The whole point of the guard: a stream never comes back, so a later
// success must not resurrect a peer an earlier failure retired.
llama_grpc::StreamPeer peer;
peer.observe_write(false);
peer.observe_write(true);
check(peer.gone(), "a failed write is sticky across later writes");
}
{
llama_grpc::StreamPeer peer;
peer.observe_cancelled(false);
check(peer.alive(), "an uncancelled RPC keeps the peer alive");
peer.observe_cancelled(true);
check(peer.gone(), "cancellation marks the peer gone");
}
{
llama_grpc::StreamPeer peer;
peer.observe_cancelled(true);
peer.observe_cancelled(false);
check(peer.gone(), "cancellation is sticky across later checks");
}
if (failures != 0) {
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
return 0;
}
+1 -1
View File
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
CRISPASR_VERSION?=ae4474dd8306384a0e697183d863dfc52e69a2fb
CRISPASR_VERSION?=301acd87b036764973b8bfba71e0a21818036d33
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
+1 -1
View File
@@ -615,10 +615,10 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
return fmt.Errorf("crispasr: tempfile: %w", err)
}
dst := tmp.Name()
defer func() { _ = os.Remove(dst) }()
if err := tmp.Close(); err != nil {
return fmt.Errorf("crispasr: close tempfile: %w", err)
}
defer func() { _ = os.Remove(dst) }()
if err := writeWAV(dst, pcm, w.sampleRate); err != nil {
return err
+1 -1
View File
@@ -14,7 +14,7 @@ JOBS?=$(shell nproc --ignore=1)
# It is kept alive by the upstream tag da2-support (survives a squash-merge);
# repoint to the master merge commit once mudler/depth-anything.cpp PR #1 lands.
DEPTHANYTHING_REPO?=https://github.com/mudler/depth-anything.cpp.git
DEPTHANYTHING_VERSION?=739992d10bf9472c46dcd4622b14d2b20766c58d
DEPTHANYTHING_VERSION?=14f7461d1f704761a038ac9f50dbde8fdb7275e2
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
@@ -11,6 +11,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
@@ -109,30 +110,25 @@ func (r *LocateAnythingCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, e
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: a text prompt is required (open-vocabulary detection)")
}
// Decode base64 image and write to temp file.
imgData, err := base64.StdEncoding.DecodeString(opts.Src)
if err != nil {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to decode base64 image: %w", err)
}
tmpFile, err := os.CreateTemp("", "locate-anything-*.img")
if err != nil {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to create temp file: %w", err)
}
defer func() { _ = os.Remove(tmpFile.Name()) }()
if _, err := tmpFile.Write(imgData); err != nil {
_ = tmpFile.Close()
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to write temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to close temp file: %w", err)
if len(imgData) == 0 {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: decoded image is empty")
}
// mode 0 = hybrid (Parallel Box Decoding). The JSON return value is unused:
// structured detections are read via the accessor functions. Still must
// free the returned string.
jsonPtr := CapiLocatePath(r.handle, tmpFile.Name(), prompt, 0)
jsonPtr := CapiLocateBuffer(
r.handle,
uintptr(unsafe.Pointer(unsafe.SliceData(imgData))),
uintptr(len(imgData)),
prompt,
0,
)
runtime.KeepAlive(imgData)
if jsonPtr != 0 {
CapiFreeString(jsonPtr)
}
@@ -0,0 +1,54 @@
package main
import (
"encoding/base64"
"path/filepath"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LocateAnythingCpp detection input", func() {
It("detects from memory when the temporary directory is unavailable", func() {
originalLocateBuffer := CapiLocateBuffer
originalLocatePath := CapiLocatePath
originalGetNDetections := CapiGetNDetections
defer func() {
CapiLocateBuffer = originalLocateBuffer
CapiLocatePath = originalLocatePath
CapiGetNDetections = originalGetNDetections
}()
image := []byte("encoded-image")
var receivedData uintptr
var receivedLength uintptr
CapiLocateBuffer = func(_ uintptr, data uintptr, length uintptr, _ string, _ int32) uintptr {
receivedData = data
receivedLength = length
return 0
}
CapiLocatePath = func(_ uintptr, _ string, _ string, _ int32) uintptr {
Fail("path-based detection must not be called")
return 0
}
CapiGetNDetections = func(uintptr) int32 { return 0 }
GinkgoT().Setenv("TMPDIR", filepath.Join(GinkgoT().TempDir(), "missing"))
result, err := (&LocateAnythingCpp{handle: 1}).Detect(&pb.DetectOptions{
Src: base64.StdEncoding.EncodeToString(image),
Prompt: "the object",
})
Expect(err).NotTo(HaveOccurred())
Expect(result.Detections).To(BeEmpty())
Expect(receivedData).NotTo(BeZero())
Expect(receivedLength).To(Equal(uintptr(len(image))))
})
It("rejects an empty decoded image", func() {
_, err := (&LocateAnythingCpp{handle: 1}).Detect(&pb.DetectOptions{Prompt: "the object"})
Expect(err).To(MatchError("locate-anything-cpp: decoded image is empty"))
})
})
+1 -1
View File
@@ -12,7 +12,7 @@
# runs 'make -C backend/go/$(BACKEND) build' and then copies package/), so it
# has to produce the binary and the package, not just the shared libraries.
NEMO_SPEECH_VERSION?=4f9676226f667d14608487df744f375db87127f8
NEMO_SPEECH_VERSION?=a5b6953c4a579a2bbd1c0913ad8a85c2a4d99953
NEMO_SPEECH_REPO?=https://github.com/NVIDIA/NeMo-Speech.cpp
GOCMD?=go
+1 -1
View File
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# omnivoice.cpp version
OMNIVOICE_REPO?=https://github.com/ServeurpersoCom/omnivoice.cpp
OMNIVOICE_VERSION?=4f33af825d66e6ef1cb185e87b4589cacf747291
OMNIVOICE_VERSION?=040c8b344d8c670ce1475194751d119b5ef82c78
SO_TARGET?=libgomnivoicecpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
+13 -16
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"unsafe"
@@ -102,24 +103,12 @@ func (r *RFDetrCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, error) {
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: model not loaded")
}
// Decode base64 image and write to temp file.
imgData, err := base64.StdEncoding.DecodeString(opts.Src)
if err != nil {
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to decode base64 image: %w", err)
}
tmpFile, err := os.CreateTemp("", "rfdetr-*.img")
if err != nil {
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to create temp file: %w", err)
}
defer func() { _ = os.Remove(tmpFile.Name()) }()
if _, err := tmpFile.Write(imgData); err != nil {
_ = tmpFile.Close()
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to write temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to close temp file: %w", err)
if len(imgData) == 0 {
return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: decoded image is empty")
}
threshold := opts.Threshold
@@ -127,10 +116,18 @@ func (r *RFDetrCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, error) {
threshold = 0.5
}
// JSON output from detect_path is unused: we read structured detections via
// JSON output from the detection ABI is unused: we read structured detections via
// the accessor functions. Still must free the returned string.
var jsonPtr uintptr
rc := CapiDetectPath(r.handle, tmpFile.Name(), threshold, uint32(defaultTopK), &jsonPtr)
rc := CapiDetectBuffer(
r.handle,
uintptr(unsafe.Pointer(unsafe.SliceData(imgData))),
uintptr(len(imgData)),
threshold,
uint32(defaultTopK),
&jsonPtr,
)
runtime.KeepAlive(imgData)
if jsonPtr != 0 {
CapiFreeString(jsonPtr)
}
@@ -0,0 +1,56 @@
package main
import (
"encoding/base64"
"path/filepath"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("RFDetrCpp detection input", func() {
It("detects from memory when the temporary directory is unavailable", func() {
originalDetectBuffer := CapiDetectBuffer
originalDetectPath := CapiDetectPath
originalFreeString := CapiFreeString
originalGetNDetections := CapiGetNDetections
defer func() {
CapiDetectBuffer = originalDetectBuffer
CapiDetectPath = originalDetectPath
CapiFreeString = originalFreeString
CapiGetNDetections = originalGetNDetections
}()
image := []byte("encoded-image")
var receivedData uintptr
var receivedLength uintptr
CapiDetectBuffer = func(_ uintptr, data uintptr, length uintptr, _ float32, _ uint32, _ *uintptr) int32 {
receivedData = data
receivedLength = length
return 0
}
CapiDetectPath = func(_ uintptr, _ string, _ float32, _ uint32, _ *uintptr) int32 {
Fail("path-based detection must not be called")
return -1
}
CapiFreeString = func(uintptr) {}
CapiGetNDetections = func(uintptr) int32 { return 0 }
GinkgoT().Setenv("TMPDIR", filepath.Join(GinkgoT().TempDir(), "missing"))
result, err := (&RFDetrCpp{handle: 1}).Detect(&pb.DetectOptions{
Src: base64.StdEncoding.EncodeToString(image),
})
Expect(err).NotTo(HaveOccurred())
Expect(result.Detections).To(BeEmpty())
Expect(receivedData).NotTo(BeZero())
Expect(receivedLength).To(Equal(uintptr(len(image))))
})
It("rejects an empty decoded image", func() {
_, err := (&RFDetrCpp{handle: 1}).Detect(&pb.DetectOptions{})
Expect(err).To(MatchError("rfdetr-cpp: decoded image is empty"))
})
})
+1 -1
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?=be0e34480dada95f8ce9a021bbb95c5de85d67c7
STABLEDIFFUSION_GGML_VERSION?=b68d58624d227682eb4b95ef8bcf569cd1311eb5
CMAKE_ARGS+=-DGGML_MAX_NAME=128
+18 -11
View File
@@ -401,7 +401,6 @@ int load_model(const char *model, char *model_path, char* options[], int threads
const char *params_backend_arg = "";
const char *rpc_servers_arg = "";
const char *max_vram_arg = "";
bool stream_layers = false;
int n_threads = threads;
enum sd_type_t wtype = SD_TYPE_COUNT;
@@ -510,7 +509,10 @@ int load_model(const char *model, char *model_path, char* options[], int threads
if (!strcmp(optname, "params_backend")) params_backend_arg = strdup(optval);
if (!strcmp(optname, "rpc_servers")) rpc_servers_arg = strdup(optval);
if (!strcmp(optname, "max_vram")) max_vram_arg = strdup(optval);
if (!strcmp(optname, "stream_layers")) stream_layers = (strcmp(optval, "true") == 0 || strcmp(optval, "1") == 0);
if (!strcmp(optname, "stream_layers")) {
// Retained as a no-op for existing configurations. Upstream now
// selects segmented weight streaming automatically.
}
// vae_decode_only is still accepted for backwards compatibility with
// existing gallery configs, but upstream dropped the option (the model
@@ -650,11 +652,9 @@ int load_model(const char *model, char *model_path, char* options[], int threads
ctx_params.rpc_servers = env_rpc_servers;
}
}
// max_vram: GiB budget or per-backend spec for graph-cut segmented param
// offload ("0" = disabled, "-1" = auto). stream_layers only has effect when
// max_vram is set.
// max_vram is an optional GiB budget or per-backend spec for automatic
// graph-cut execution. A zero value uses the live free-VRAM budget.
if (strlen(max_vram_arg) > 0) ctx_params.max_vram = max_vram_arg;
ctx_params.stream_layers = stream_layers;
ctx_params.diffusion_flash_attn = diffusion_flash_attn;
ctx_params.tae_preview_only = tae_preview_only;
ctx_params.diffusion_conv_direct = diffusion_conv_direct;
@@ -1144,17 +1144,25 @@ static uint8_t* load_and_resize_image(const char* path, int target_width, int ta
// Write sd.cpp's audio buffer to a temp WAV file (IEEE float, interleaved).
// sd_audio_t.data is planar (all channel 0 samples, then channel 1, etc.) — we
// interleave on the fly so ffmpeg's standard wav demuxer can read it directly.
// Returns 0 on success and fills wav_path (must be at least 64 bytes).
// Returns 0 on success and fills wav_path.
static int write_planar_float_wav(const sd_audio_t* a, char* wav_path, size_t wav_path_sz) {
if (!a || !a->data || a->sample_count == 0 || a->channels == 0 || a->sample_rate == 0) {
return -1;
}
snprintf(wav_path, wav_path_sz, "/tmp/gosd-audio-XXXXXX.wav");
const char* temp_dir = getenv("TMPDIR");
if (!temp_dir || temp_dir[0] == '\0') {
temp_dir = "/tmp";
}
int path_len = snprintf(wav_path, wav_path_sz, "%s/gosd-audio-XXXXXX.wav", temp_dir);
if (path_len < 0 || (size_t)path_len >= wav_path_sz) {
fprintf(stderr, "temporary directory path is too long\n");
return -1;
}
int fd = mkstemps(wav_path, 4);
if (fd < 0) { perror("mkstemps wav"); return -1; }
FILE* f = fdopen(fd, "wb");
if (!f) { perror("fdopen wav"); close(fd); return -1; }
if (!f) { perror("fdopen wav"); close(fd); unlink(wav_path); return -1; }
uint64_t frames = a->sample_count;
uint32_t channels = a->channels;
@@ -1221,7 +1229,7 @@ static int ffmpeg_mux_raw_to_mp4(sd_image_t* frames, int num_frames, int fps,
snprintf(fps_str, sizeof(fps_str), "%d", fps);
// Optional audio: write a temp WAV file if the model produced audio.
char wav_path[64] = {0};
char wav_path[4096] = {0};
bool have_audio = false;
if (audio && audio->data && audio->sample_count > 0 && audio->channels > 0 && audio->sample_rate > 0) {
if (write_planar_float_wav(audio, wav_path, sizeof(wav_path)) == 0) {
@@ -1438,4 +1446,3 @@ int unload() {
free_sd_ctx(sd_c);
return 0;
}
+1 -1
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?=150b37852c123f7855fb219b37347572ca9427e7
VLLM_CPP_VERSION?=6bf3abb580982f4fd2e4525ef37802ee0ce28981
# 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
+31
View File
@@ -128,9 +128,40 @@ func parseOptions(opts *pb.ModelOptions) loadOptions {
lo := loadOptions{}
applyOptionsList(&lo, opts.GetOptions())
applyEngineArgs(&lo, opts.GetEngineArgs())
applyDraftModelOption(&lo, opts.GetOptions())
return lo
}
// applyDraftModelOption binds a managed companion snapshot after engine_args
// has supplied the speculative document. Companion paths do not exist until
// LocalAI materializes the artifact, so they must replace the gallery's static
// repository reference without disturbing the method or token budget.
func applyDraftModelOption(lo *loadOptions, options []string) {
if strings.TrimSpace(lo.speculativeConfig) == "" {
return
}
var draftModel string
for _, option := range options {
key, value, found := strings.Cut(option, ":")
if found && strings.TrimSpace(key) == "draft_model" {
draftModel = strings.TrimSpace(value)
}
}
if draftModel == "" {
return
}
var spec map[string]any
if err := json.Unmarshal([]byte(lo.speculativeConfig), &spec); err != nil {
return
}
spec["model"] = draftModel
encoded, err := json.Marshal(spec)
if err == nil {
lo.speculativeConfig = string(encoded)
}
}
// applyOptionsList reads the legacy free-form "key:value" list. strings.Cut
// splits on the FIRST colon only, so a JSON object value survives intact.
func applyOptionsList(lo *loadOptions, options []string) {
+38
View File
@@ -0,0 +1,38 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
var _ = Describe("managed DFlash companion options", func() {
It("replaces only the draft model in an existing speculative configuration", func() {
managedPath := ".artifacts/huggingface/0123456789abcdef/snapshot"
lo := parseOptions(&pb.ModelOptions{
Options: []string{"draft_model:" + managedPath},
EngineArgs: `{
"speculative_config": {
"method": "dflash",
"model": "Mia-AiLab/Qwen3.8-27B-DFlash2-EXL3-5.0bpw",
"num_speculative_tokens": 7
}
}`,
})
Expect(lo.speculativeConfig).To(MatchJSON(`{
"method": "dflash",
"model": ".artifacts/huggingface/0123456789abcdef/snapshot",
"num_speculative_tokens": 7
}`))
})
It("ignores a draft companion when speculative decoding is not configured", func() {
lo := parseOptions(&pb.ModelOptions{
Options: []string{"draft_model:.artifacts/huggingface/0123456789abcdef/snapshot"},
})
Expect(lo.speculativeConfig).To(BeEmpty())
})
})
+1 -1
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?=978113305b2ead22249b881deafa131dc8884911
WHISPER_CPP_VERSION?=c44b60b8053bbf2a5c1e014f11323fb3f2485177
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
+35
View File
@@ -0,0 +1,35 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Specs for resolveAddr run under the suite bootstrap in gowhisper_test.go
// (TestWhisper); they need no native library, so they never skip.
var _ = Describe("resolveAddr", func() {
It("prefers an explicitly set -addr over a positional argument", func() {
Expect(resolveAddr("127.0.0.1:12345", true, []string{"127.0.0.1:59999"})).To(Equal("127.0.0.1:12345"))
})
It("keeps an explicit -addr equal to the default over a positional argument", func() {
Expect(resolveAddr(defaultAddr, true, []string{"127.0.0.1:59999"})).To(Equal(defaultAddr))
})
It("falls back to the positional argument when -addr is unset", func() {
Expect(resolveAddr(defaultAddr, false, []string{"127.0.0.1:59999"})).To(Equal("127.0.0.1:59999"))
})
It("keeps the default when -addr is unset and no positional argument is given", func() {
Expect(resolveAddr(defaultAddr, false, nil)).To(Equal(defaultAddr))
})
It("uses the first positional argument among several", func() {
Expect(resolveAddr(defaultAddr, false, []string{"127.0.0.1:59999", "extra"})).To(Equal("127.0.0.1:59999"))
})
It("treats an explicitly empty -addr as unset", func() {
Expect(resolveAddr("", true, []string{"127.0.0.1:59999"})).To(Equal("127.0.0.1:59999"))
Expect(resolveAddr("", true, nil)).To(Equal(defaultAddr))
})
})
+33 -3
View File
@@ -10,10 +10,31 @@ import (
grpc "github.com/mudler/LocalAI/pkg/grpc"
)
var (
addr = flag.String("addr", "localhost:50051", "the address to connect to")
const (
defaultAddr = "localhost:50051"
)
var (
addr = flag.String("addr", defaultAddr, "the address to listen on")
)
// resolveAddr picks the address the gRPC server binds to. An explicitly set
// -addr always wins. Launchers may hand us the listen address as a bare
// positional argument, which Go's flag package silently drops — honour it
// next so the server binds the port its caller actually allocated instead of
// the default one (#11623). An explicitly empty -addr counts as unset:
// binding the empty address would listen on an OS-chosen port on every
// interface instead of the one the caller allocated.
func resolveAddr(flagAddr string, addrSet bool, args []string) string {
if addrSet && flagAddr != "" {
return flagAddr
}
if len(args) > 0 {
return args[0]
}
return defaultAddr
}
type LibFuncs struct {
FuncPtr any
Name string
@@ -61,7 +82,16 @@ func main() {
flag.Parse()
if err := grpc.StartServer(*addr, &Whisper{}); err != nil {
// flag.Visit reports only flags that were explicitly set, so an -addr
// equal to the default is still distinguished from an untouched one.
addrSet := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "addr" {
addrSet = true
}
})
if err := grpc.StartServer(resolveAddr(*addr, addrSet, flag.Args()), &Whisper{}); err != nil {
panic(err)
}
}
+210
View File
@@ -150,6 +150,7 @@
- audio-transcription
- CPU
- CUDA
- HIP
- Metal
# No vulkan key: the vulkan image would carry a Vulkan loader with no Mesa ICD
# (see the audio-cpp block in .github/backend-matrix.yml). Pointing a
@@ -161,6 +162,7 @@
nvidia: "cuda12-audio-cpp"
nvidia-cuda-12: "cuda12-audio-cpp"
nvidia-cuda-13: "cuda13-audio-cpp"
amd: "rocm-audio-cpp"
metal: "metal-audio-cpp"
metal-darwin-arm64: "metal-audio-cpp"
- &whispercpp
@@ -1060,6 +1062,21 @@
nvidia-l4t: "nvidia-l4t-mlx"
nvidia-l4t-cuda-12: "nvidia-l4t-mlx"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-mlx"
- &mlx-video
name: "mlx-video"
icon: https://avatars.githubusercontent.com/u/102832242?s=200&v=4
urls:
- https://github.com/Blaizzy/mlx-video
license: MIT
description: |
Generate videos with LTX-2 and converted Wan2.1/Wan2.2 checkpoints using
MLX on Apple Silicon.
tags:
- text-to-video
- image-to-video
- MLX
capabilities:
metal: "metal-mlx-video"
- &mlx-vlm
name: "mlx-vlm"
icon: https://avatars.githubusercontent.com/u/102832242?s=200&v=4
@@ -1194,6 +1211,7 @@
tags:
- image-generation
- video-generation
- sound-generation
- diffusion-models
license: apache-2.0
alias: "diffusers"
@@ -1518,6 +1536,25 @@
nvidia-cuda-12: "cuda12-faster-whisper"
nvidia-l4t: "nvidia-l4t-arm64-faster-whisper"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-faster-whisper"
- &whisper-medusa
description: |
Whisper-Medusa accelerates Whisper transcription by predicting multiple tokens per decoding step.
The upstream checkpoints use custom Transformers generation code and accept audio clips up to 30 seconds.
urls:
- https://github.com/aiola-lab/whisper-medusa
- https://huggingface.co/collections/aiola/whisper-medusa
tags:
- speech-to-text
- transcription
- Whisper
- Medusa
license: MIT
name: "whisper-medusa"
alias: "whisper-medusa"
capabilities:
default: "cpu-whisper-medusa"
nvidia: "cuda12-whisper-medusa"
nvidia-cuda-12: "cuda12-whisper-medusa"
- &moonshine
description: |
Moonshine is a fast, accurate, and efficient speech-to-text transcription model using ONNX Runtime.
@@ -1785,6 +1822,32 @@
nvidia-l4t-cuda-12: "nvidia-l4t-faster-qwen3-tts"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-faster-qwen3-tts"
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/620760a26e3b7210c2ff1943/-s1gyJfvbE1RgO5iBeNOi.png
- &funasr
urls:
- https://github.com/modelscope/FunASR
description: |
FunASR is an industrial-grade speech recognition toolkit supporting 50+ languages.
Includes SenseVoice (170x realtime, emotion detection), Paraformer (highest Chinese
accuracy), and built-in VAD, punctuation restoration, and speaker diarization.
tags:
- speech-recognition
- ASR
- multilingual
license: mit
name: "funasr"
alias: "funasr"
capabilities:
nvidia: "cuda12-funasr"
intel: "intel-funasr"
amd: "rocm-funasr"
metal: "metal-funasr"
default: "cpu-funasr"
nvidia-cuda-13: "cuda13-funasr"
nvidia-cuda-12: "cuda12-funasr"
nvidia-l4t: "nvidia-l4t-funasr"
nvidia-l4t-cuda-12: "nvidia-l4t-funasr"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-funasr"
icon: https://avatars.githubusercontent.com/u/109454077
- &qwen-asr
urls:
- https://github.com/QwenLM/Qwen3-ASR
@@ -2101,6 +2164,7 @@
nvidia: "cuda12-audio-cpp-development"
nvidia-cuda-12: "cuda12-audio-cpp-development"
nvidia-cuda-13: "cuda13-audio-cpp-development"
amd: "rocm-audio-cpp-development"
metal: "metal-audio-cpp-development"
metal-darwin-arm64: "metal-audio-cpp-development"
- !!merge <<: *stablediffusionggml
@@ -2164,6 +2228,20 @@
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-mlx"
mirrors:
- localai/localai-backends:latest-metal-darwin-arm64-mlx
- !!merge <<: *mlx-video
name: "metal-mlx-video"
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-mlx-video"
mirrors:
- localai/localai-backends:latest-metal-darwin-arm64-mlx-video
- !!merge <<: *mlx-video
name: "metal-mlx-video-development"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-mlx-video"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-mlx-video
- !!merge <<: *mlx-video
name: "mlx-video-development"
capabilities:
metal: "metal-mlx-video-development"
- !!merge <<: *mlx
name: "metal-mlx-development"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-mlx"
@@ -6368,6 +6446,34 @@
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-faster-qwen3-tts"
mirrors:
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-faster-qwen3-tts
## whisper-medusa
- !!merge <<: *whisper-medusa
name: "whisper-medusa-development"
capabilities:
default: "cpu-whisper-medusa-development"
nvidia: "cuda12-whisper-medusa-development"
nvidia-cuda-12: "cuda12-whisper-medusa-development"
- !!merge <<: *whisper-medusa
name: "cpu-whisper-medusa"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-whisper-medusa"
mirrors:
- localai/localai-backends:latest-cpu-whisper-medusa
- !!merge <<: *whisper-medusa
name: "cpu-whisper-medusa-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-whisper-medusa"
mirrors:
- localai/localai-backends:master-cpu-whisper-medusa
- !!merge <<: *whisper-medusa
name: "cuda12-whisper-medusa"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-whisper-medusa"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-12-whisper-medusa
- !!merge <<: *whisper-medusa
name: "cuda12-whisper-medusa-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-whisper-medusa"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-whisper-medusa
## qwen-asr
- !!merge <<: *qwen-asr
name: "qwen-asr-development"
@@ -7054,6 +7160,16 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-audio-cpp"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-audio-cpp
- !!merge <<: *audiocpp
name: "rocm-audio-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-audio-cpp"
mirrors:
- localai/localai-backends:latest-gpu-rocm-hipblas-audio-cpp
- !!merge <<: *audiocpp
name: "rocm-audio-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-audio-cpp"
mirrors:
- localai/localai-backends:master-gpu-rocm-hipblas-audio-cpp
- !!merge <<: *audiocpp
name: "metal-audio-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-audio-cpp"
@@ -7064,3 +7180,97 @@
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-audio-cpp"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-audio-cpp
## funasr
- !!merge <<: *funasr
name: "funasr-development"
capabilities:
nvidia: "cuda12-funasr-development"
intel: "intel-funasr-development"
amd: "rocm-funasr-development"
nvidia-l4t: "nvidia-l4t-funasr-development"
metal: "metal-funasr-development"
default: "cpu-funasr-development"
nvidia-cuda-13: "cuda13-funasr-development"
nvidia-cuda-12: "cuda12-funasr-development"
nvidia-l4t-cuda-12: "nvidia-l4t-funasr-development"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-funasr-development"
- !!merge <<: *funasr
name: "cpu-funasr"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-funasr"
mirrors:
- localai/localai-backends:latest-cpu-funasr
- !!merge <<: *funasr
name: "cpu-funasr-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-funasr"
mirrors:
- localai/localai-backends:master-cpu-funasr
- !!merge <<: *funasr
name: "cuda12-funasr"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-funasr"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-12-funasr
- !!merge <<: *funasr
name: "cuda12-funasr-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-funasr"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-funasr
- !!merge <<: *funasr
name: "cuda13-funasr"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-funasr"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-13-funasr
- !!merge <<: *funasr
name: "cuda13-funasr-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-funasr"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-funasr
- !!merge <<: *funasr
name: "intel-funasr"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-funasr"
mirrors:
- localai/localai-backends:latest-gpu-intel-funasr
- !!merge <<: *funasr
name: "intel-funasr-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-funasr"
mirrors:
- localai/localai-backends:master-gpu-intel-funasr
- !!merge <<: *funasr
name: "rocm-funasr"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-funasr"
mirrors:
- localai/localai-backends:latest-gpu-rocm-hipblas-funasr
- !!merge <<: *funasr
name: "rocm-funasr-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-funasr"
mirrors:
- localai/localai-backends:master-gpu-rocm-hipblas-funasr
- !!merge <<: *funasr
name: "nvidia-l4t-funasr"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-funasr"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-funasr
- !!merge <<: *funasr
name: "nvidia-l4t-funasr-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-funasr"
mirrors:
- localai/localai-backends:master-nvidia-l4t-funasr
- !!merge <<: *funasr
name: "cuda13-nvidia-l4t-arm64-funasr"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-cuda-13-arm64-funasr"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-cuda-13-arm64-funasr
- !!merge <<: *funasr
name: "cuda13-nvidia-l4t-arm64-funasr-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-funasr"
mirrors:
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-funasr
- !!merge <<: *funasr
name: "metal-funasr"
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-funasr"
mirrors:
- localai/localai-backends:latest-metal-darwin-arm64-funasr
- !!merge <<: *funasr
name: "metal-funasr-development"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-funasr"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-funasr
+1
View File
@@ -20,6 +20,7 @@ The Python backends use a unified build system based on `libbackend.sh` that pro
### Audio & Speech
- **coqui** - Coqui TTS models
- **faster-whisper** - Fast Whisper speech recognition
- **funasr** - Local multilingual transcription with FunASR and SenseVoice
- **kitten-tts** - Lightweight TTS
- **mlx-audio** - Apple Silicon audio processing
- **chatterbox** - TTS model
+15 -18
View File
@@ -19,6 +19,7 @@ import grpc
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common'))
from grpc_auth import get_auth_interceptors
from temp_utils import cleanup_paths
import tempfile
@@ -115,11 +116,6 @@ def merge_audio_files(audio_files, output_path, sample_rate):
# Save the merged audio
ta.save(output_path, merged_waveform, sample_rate)
# Clean up temporary files
for audio_file in audio_files:
if os.path.exists(audio_file):
os.remove(audio_file)
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
# If MAX_WORKERS are specified in the environment use it, otherwise default to 1
@@ -226,19 +222,20 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
text_chunks = split_text_at_word_boundary(request.text, max_length=250)
print(f"Splitting text into chunks of 250 characters: {len(text_chunks)}", file=sys.stderr)
# Generate audio for each chunk
temp_audio_files = []
for i, chunk in enumerate(text_chunks):
# Generate audio for this chunk
wav = self.model.generate(chunk, **kwargs)
# Create temporary file for this chunk
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')
temp_file.close()
ta.save(temp_file.name, wav, self.model.sr)
temp_audio_files.append(temp_file.name)
# Merge all audio files
merge_audio_files(temp_audio_files, request.dst, self.model.sr)
with cleanup_paths() as temp_audio_files:
for i, chunk in enumerate(text_chunks):
# Generate audio for this chunk
wav = self.model.generate(chunk, **kwargs)
# Register ownership before saving so a partial write is
# removed too when generation or encoding fails.
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')
temp_file.close()
temp_audio_files.append(temp_file.name)
ta.save(temp_file.name, wav, self.model.sr)
# Merge all audio files
merge_audio_files(temp_audio_files, request.dst, self.model.sr)
else:
# Generate audio using ChatterboxTTS for short text
wav = self.model.generate(request.text, **kwargs)
+12
View File
@@ -563,6 +563,18 @@ function startBackend() {
echo "Added ${EDIR}/lib to LD_LIBRARY_PATH for GPU libraries"
fi
# XPU wheels carry a matching SYCL/Unified Runtime set. Prefer it over
# packaged oneAPI or host libraries, which may expose an older loader ABI.
if [ "$(uname -s)" = "Linux" ]; then
local sycl_runtime
for sycl_runtime in "${EDIR}/venv/lib"/libsycl.so*; do
if [ -f "${sycl_runtime}" ]; then
export LD_LIBRARY_PATH="${EDIR}/venv/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
break
fi
done
fi
if [ ! -z "${BACKEND_FILE:-}" ]; then
exec "${EDIR}/venv/bin/python" "${BACKEND_FILE}" "$@"
elif [ -e "${MY_DIR}/server.py" ]; then
+40
View File
@@ -37,6 +37,46 @@ def parse_options(options_list):
return opts
def attach_media_parts(messages_dicts, n_images=0, n_videos=0):
"""Rebuild the last user message as content *parts* carrying media markers.
Backends that let the tokenizer do the templating hand plain string content
to ``apply_chat_template``, but a chat template only emits the model's own
media tokens (``<|vision_start|><|image_pad|><|vision_end|>`` for the
Qwen-VL family, and the equivalents elsewhere) when the content is a list
of parts. Without those markers the engine's multimodal processor finds
nothing to substitute and silently discards the pixels, even though they
were forwarded correctly out of band.
Returns a new list whose last user message has
``[{"type": "image"} * n_images, {"type": "video"} * n_videos, text]`` as
its content, or ``None`` when there is nothing to attach - no media, no
user turn, or content that is already a list of parts - so the caller can
keep using the original string-content list.
"""
if not n_images and not n_videos:
return None
idx = next(
(
i
for i in reversed(range(len(messages_dicts)))
if messages_dicts[i].get("role") == "user"
),
None,
)
if idx is None:
return None
text = messages_dicts[idx].get("content") or ""
if not isinstance(text, str):
return None
parts = [{"type": "image"}] * n_images + [{"type": "video"}] * n_videos
if text:
parts.append({"type": "text", "text": text})
patched = list(messages_dicts)
patched[idx] = dict(patched[idx], content=parts)
return patched
def messages_to_dicts(proto_messages):
"""Convert proto ``Message`` objects to dicts suitable for ``apply_chat_template``.
+59 -1
View File
@@ -14,7 +14,7 @@ import json
import types
import unittest
from python_utils import messages_to_dicts, parse_options
from python_utils import attach_media_parts, messages_to_dicts, parse_options
def _msg(**fields):
@@ -118,5 +118,63 @@ class TestMessagesToDicts(unittest.TestCase):
self.assertNotIn("tool_calls", out[0])
class TestAttachMediaParts(unittest.TestCase):
def test_image_marker_added_to_last_user_turn(self):
messages = [
{"role": "system", "content": "be brief"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "how high is the water?"},
]
out = attach_media_parts(messages, n_images=1)
self.assertEqual(
out[3]["content"],
[{"type": "image"}, {"type": "text", "text": "how high is the water?"}],
)
# Earlier turns and the input list itself are untouched.
self.assertEqual(out[:3], messages[:3])
self.assertEqual(messages[3]["content"], "how high is the water?")
def test_counts_and_order_images_then_videos(self):
out = attach_media_parts(
[{"role": "user", "content": "describe"}], n_images=2, n_videos=1
)
self.assertEqual(
out[0]["content"],
[
{"type": "image"},
{"type": "image"},
{"type": "video"},
{"type": "text", "text": "describe"},
],
)
def test_empty_text_yields_media_only_parts(self):
out = attach_media_parts([{"role": "user", "content": ""}], n_images=1)
self.assertEqual(out[0]["content"], [{"type": "image"}])
def test_other_message_keys_are_preserved(self):
out = attach_media_parts(
[{"role": "user", "content": "hi", "name": "bob"}], n_images=1
)
self.assertEqual(out[0]["name"], "bob")
def test_no_media_is_a_no_op(self):
self.assertIsNone(attach_media_parts([{"role": "user", "content": "hi"}]))
def test_no_user_turn_is_a_no_op(self):
self.assertIsNone(
attach_media_parts([{"role": "system", "content": "hi"}], n_images=1)
)
def test_content_already_parts_is_a_no_op(self):
self.assertIsNone(
attach_media_parts(
[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
n_images=1,
)
)
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -0,0 +1,36 @@
import base64
import contextlib
import os
import tempfile
@contextlib.contextmanager
def materialize_base64(data, suffix=""):
"""Materialize base64 data for a path-only library and always remove it."""
descriptor, path = tempfile.mkstemp(prefix="localai-media-", suffix=suffix)
try:
with os.fdopen(descriptor, "wb") as output:
descriptor = None
output.write(base64.b64decode(data))
yield path
finally:
if descriptor is not None:
os.close(descriptor)
try:
os.remove(path)
except OSError:
pass
@contextlib.contextmanager
def cleanup_paths():
"""Collect temporary paths and remove them on success or failure."""
paths = []
try:
yield paths
finally:
for path in paths:
try:
os.remove(path)
except OSError:
pass
+41
View File
@@ -0,0 +1,41 @@
import os
import tempfile
import unittest
from unittest import mock
from temp_utils import cleanup_paths, materialize_base64
class MaterializeBase64Test(unittest.TestCase):
def test_removes_materialized_file_after_success(self):
with tempfile.TemporaryDirectory() as directory:
with mock.patch.object(tempfile, "tempdir", directory):
with materialize_base64("aGVsbG8=", suffix=".data") as path:
with open(path, "rb") as materialized:
self.assertEqual(materialized.read(), b"hello")
self.assertFalse(os.path.exists(path))
def test_removes_materialized_file_when_consumer_fails(self):
with tempfile.TemporaryDirectory() as directory:
with mock.patch.object(tempfile, "tempdir", directory):
with self.assertRaisesRegex(RuntimeError, "decode failed"):
with materialize_base64("aGVsbG8="):
raise RuntimeError("decode failed")
self.assertEqual(os.listdir(directory), [])
class CleanupPathsTest(unittest.TestCase):
def test_removes_every_registered_path_after_failure(self):
with tempfile.TemporaryDirectory() as directory:
paths = [os.path.join(directory, name) for name in ("one.wav", "two.wav")]
with self.assertRaisesRegex(RuntimeError, "merge failed"):
with cleanup_paths() as registered:
for path in paths:
open(path, "wb").close()
registered.append(path)
raise RuntimeError("merge failed")
self.assertEqual(os.listdir(directory), [])
if __name__ == "__main__":
unittest.main()
@@ -1,4 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/xpu
torch==2.13.0+xpu
torch==2.14.0+xpu
oneccl_bind_pt==2.8.0+xpu
optimum[openvino]
@@ -1,3 +1,3 @@
grpcio==1.82.1
grpcio==1.83.1
protobuf
grpcio-tools
+1 -1
View File
@@ -1,4 +1,4 @@
grpcio==1.83.0
grpcio==1.83.1
protobuf
certifi
packaging==26.3
+24
View File
@@ -0,0 +1,24 @@
import array
import sys
import wave
def write_pcm_wav(destination, samples, sampling_rate):
"""Write normalized floating-point audio samples as mono 16-bit PCM."""
pcm = array.array(
"h",
(
max(-32768, min(32767, round(float(sample) * 32768)))
for sample in samples
),
)
if pcm.itemsize != 2:
raise RuntimeError("16-bit PCM requires two-byte signed integers")
if sys.byteorder != "little":
pcm.byteswap()
with wave.open(destination, "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(sampling_rate)
output.writeframes(pcm.tobytes())
+73 -9
View File
@@ -26,6 +26,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common'))
from grpc_auth import get_auth_interceptors
from model_utils import resolve_model_reference
from audio_utils import write_pcm_wav
# Import dynamic loader for pipeline discovery
@@ -35,6 +36,7 @@ from diffusers_dynamic_loader import (
get_available_pipelines,
load_diffusers_pipeline,
)
from load_options import single_file_load_kwargs
# Import specific items still needed for special cases and safety checker
from diffusers import DiffusionPipeline, ControlNetModel
@@ -122,6 +124,21 @@ from diffusers.schedulers import (
UniPCMultistepScheduler,
)
def select_device(request_cuda, device_option, cuda_available, xpu, mps_available):
"""Pick the pipeline device. An explicit `device:` model option wins;
otherwise CUDA is used whenever torch reports it available (ROCm
builds included) or the model config forces it with `cuda: true`,
keeping the pre-existing XPU/MPS overrides. CPU is the fallback, not
the default."""
if device_option:
return device_option
device = "cuda" if (request_cuda or cuda_available) else "cpu"
if xpu:
device = "xpu"
if mps_available:
device = "mps"
return device
def is_float(s):
"""Check if a string can be converted to float."""
try:
@@ -464,6 +481,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# Build kwargs for dynamic loading
load_kwargs = {"torch_dtype": torchType}
load_kwargs.update(
single_file_load_kwargs(request.OriginalConfigFile, from_single_file)
)
# Add variant if not loading from single file
if not from_single_file and variant:
@@ -627,12 +647,13 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# modify LoraAdapter to be relative to modelFileBase
request.LoraAdapter = os.path.join(request.ModelPath, request.LoraAdapter)
device = "cpu" if not request.CUDA else "cuda"
if XPU:
device = "xpu"
mps_available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
if mps_available:
device = "mps"
device = select_device(
request.CUDA,
self.options.pop("device", None),
torch.cuda.is_available(),
XPU,
hasattr(torch.backends, "mps") and torch.backends.mps.is_available(),
)
self.device = device
if request.LoraAdapter:
# Check if its a local file and not a directory ( we load lora differently for a safetensor file )
@@ -800,12 +821,12 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
image = image.resize((1024, 576))
generator = torch.manual_seed(request.seed)
frames = self.pipe(image, guidance_scale=self.cfg_scale, decode_chunk_size=CHUNK_SIZE, generator=generator).frames[0]
frames = self.pipe(image=image, guidance_scale=self.cfg_scale, decode_chunk_size=CHUNK_SIZE, generator=generator).frames[0]
export_to_video(frames, request.dst, fps=FPS)
return backend_pb2.Result(message="Media generated successfully", success=True)
if self.txt2vid:
video_frames = self.pipe(prompt, guidance_scale=self.cfg_scale, num_inference_steps=steps, num_frames=int(FRAMES)).frames
video_frames = self.pipe(prompt=prompt, guidance_scale=self.cfg_scale, num_inference_steps=steps, num_frames=int(FRAMES)).frames
export_to_video(video_frames, request.dst)
return backend_pb2.Result(message="Media generated successfully", success=True)
@@ -868,7 +889,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
else:
# pass the kwargs dictionary to the self.pipe method
image = self.pipe(
prompt,
prompt=prompt,
guidance_scale=self.cfg_scale,
**kwargs
).images[0]
@@ -883,6 +904,49 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
return backend_pb2.Result(message="Media generated", success=True)
def SoundGeneration(self, request, context):
if not request.dst:
return backend_pb2.Result(success=False, message="request.dst is required")
prompt = request.text or request.caption
if not prompt:
return backend_pb2.Result(success=False, message="request.text is required")
try:
generation_options = dict(self.options)
if "num_inference_steps" in generation_options:
generation_options["num_inference_steps"] = int(
generation_options["num_inference_steps"]
)
generation_options["prompt"] = prompt
if request.HasField("duration"):
generation_options["audio_length_in_s"] = request.duration
if request.HasField("temperature"):
generation_options["guidance_scale"] = request.temperature
generated = self.pipe(**generation_options)
if not hasattr(generated, "audios") or len(generated.audios) == 0:
return backend_pb2.Result(
success=False,
message="The diffusers pipeline returned no audio",
)
samples = generated.audios[0]
if hasattr(samples, "reshape"):
samples = samples.reshape(-1)
if hasattr(samples, "tolist"):
samples = samples.tolist()
sampling_rate = getattr(
getattr(getattr(self.pipe, "vae", None), "config", None),
"sampling_rate",
16000,
)
write_pcm_wav(request.dst, samples, sampling_rate)
return backend_pb2.Result(success=True, message="Sound generated successfully")
except Exception as err:
return backend_pb2.Result(success=False, message=f"SoundGeneration error: {err}")
def UpscaleImage(self, request, context):
try:
if not request.src:
+7
View File
@@ -0,0 +1,7 @@
# SPDX-License-Identifier: MIT
def single_file_load_kwargs(original_config_file: str, from_single_file: bool) -> dict:
if from_single_file and original_config_file:
return {"original_config_file": original_config_file}
return {}
+136
View File
@@ -4,9 +4,13 @@ A test script to test the gRPC service and dynamic loader
import unittest
import subprocess
import time
import os
import tempfile
import wave
from unittest.mock import patch, MagicMock
# Import dynamic loader for testing (these don't need gRPC)
import backend
import diffusers_dynamic_loader as loader
from diffusers import DiffusionPipeline, StableDiffusionPipeline
@@ -373,3 +377,135 @@ class TestGenerateImageOptionsKwargsMerge(unittest.TestCase):
finally:
os.unlink(src_file.name)
os.unlink(dst_file.name)
def test_text_to_image_prompt_is_passed_by_keyword(self):
"""Test compatibility with pipelines that take image before prompt."""
import os
import tempfile
from PIL import Image
from backend import BackendServicer
class Flux2CompatiblePipeline:
"""Model the FLUX.2 call signature: image is before prompt."""
def __call__(self, image=None, prompt=None, **kwargs):
if prompt is None:
raise ValueError("prompt was not passed by keyword")
self.prompt = prompt
self.kwargs = kwargs
return MagicMock(images=[Image.new("RGB", (4, 4))])
pipeline = Flux2CompatiblePipeline()
svc = BackendServicer.__new__(BackendServicer)
svc.pipe = pipeline
svc.cfg_scale = 7.5
svc.controlnet = None
svc.img2vid = False
svc.txt2vid = False
svc.clip_skip = 0
svc.PipelineType = "Flux2KleinPipeline"
svc.options = {}
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as dst_file:
dst_path = dst_file.name
try:
request = MagicMock()
request.positive_prompt = "a red apple on a wooden table"
request.negative_prompt = ""
request.step = 4
request.seed = 0
request.width = 0
request.height = 0
request.src = ""
request.ref_images = []
request.dst = dst_path
svc.GenerateImage(request, context=None)
self.assertEqual(pipeline.prompt, request.positive_prompt)
self.assertEqual(pipeline.kwargs["num_inference_steps"], 4)
finally:
os.unlink(dst_path)
class TestDeviceSelection(unittest.TestCase):
"""Unit tests for backend.select_device (no GPU required)."""
def test_autodetect_cuda(self):
self.assertEqual(backend.select_device(False, None, True, False, False), "cuda")
def test_cpu_fallback(self):
self.assertEqual(backend.select_device(False, None, False, False, False), "cpu")
def test_forced_cuda(self):
self.assertEqual(backend.select_device(True, None, False, False, False), "cuda")
def test_device_option_wins(self):
self.assertEqual(backend.select_device(True, "cpu", True, True, True), "cpu")
def test_mps_overrides(self):
self.assertEqual(backend.select_device(False, None, True, False, True), "mps")
class TestWritePcmWav(unittest.TestCase):
def test_writes_clipped_float_samples_as_mono_pcm(self):
from audio_utils import write_pcm_wav
destination = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
destination.close()
try:
write_pcm_wav(destination.name, [0.0, 0.5, -0.5, 2.0], 16000)
with wave.open(destination.name, "rb") as generated:
self.assertEqual(generated.getframerate(), 16000)
self.assertEqual(generated.getnchannels(), 1)
self.assertEqual(generated.getsampwidth(), 2)
self.assertEqual(generated.getnframes(), 4)
self.assertEqual(
generated.readframes(4),
b"\x00\x00\x00@\x00\xc0\xff\x7f",
)
finally:
os.unlink(destination.name)
@unittest.skipUnless(GRPC_AVAILABLE, "gRPC modules not available")
class TestSoundGeneration(unittest.TestCase):
def test_maps_request_options_and_writes_pipeline_audio(self):
from backend import BackendServicer
service = BackendServicer.__new__(BackendServicer)
service.options = {"num_inference_steps": 200.0}
service.pipe = MagicMock()
service.pipe.return_value.audios = [[0.0, 0.5, -0.5]]
service.pipe.vae.config.sampling_rate = 16000
destination = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
destination.close()
try:
request = backend_pb2.SoundGenerationRequest(
text="ocean waves",
dst=destination.name,
duration=2.5,
temperature=0,
)
result = service.SoundGeneration(request, context=None)
self.assertTrue(result.success, result.message)
service.pipe.assert_called_once_with(
num_inference_steps=200,
prompt="ocean waves",
audio_length_in_s=2.5,
guidance_scale=0,
)
with wave.open(destination.name, "rb") as generated:
self.assertEqual(generated.getframerate(), 16000)
self.assertEqual(generated.getnframes(), 3)
finally:
os.unlink(destination.name)
@@ -0,0 +1,26 @@
# SPDX-License-Identifier: MIT
import unittest
from backend.python.diffusers.load_options import single_file_load_kwargs
class SingleFileLoadKwargsTest(unittest.TestCase):
def test_includes_original_config_for_single_file(self):
self.assertEqual(
single_file_load_kwargs("configs/v1-inference.yaml", True),
{"original_config_file": "configs/v1-inference.yaml"},
)
def test_omits_original_config_for_pretrained_model(self):
self.assertEqual(
single_file_load_kwargs("configs/v1-inference.yaml", False),
{},
)
def test_omits_empty_original_config(self):
self.assertEqual(single_file_load_kwargs("", True), {})
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -26,4 +26,17 @@ if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
USE_PIP=true
fi
CTRANSLATE2_VERSION=${CTRANSLATE2_VERSION:-v4.7.1}
CTRANSLATE2_ROCM_WHEEL_OS=${CTRANSLATE2_ROCM_WHEEL_OS:-Linux}
CTRANSLATE2_ROCM_WHEEL_ARCHIVE="rocm-python-wheels-${CTRANSLATE2_ROCM_WHEEL_OS}.zip"
if [ "x${BUILD_PROFILE}" == "xhipblas" ]; then
ensureVenv
mkdir /tmp/ctranslate2-rocm
wget -O "/tmp/ctranslate2-rocm/${CTRANSLATE2_ROCM_WHEEL_ARCHIVE}" "https://github.com/OpenNMT/CTranslate2/releases/download/${CTRANSLATE2_VERSION}/${CTRANSLATE2_ROCM_WHEEL_ARCHIVE}"
unzip "/tmp/ctranslate2-rocm/${CTRANSLATE2_ROCM_WHEEL_ARCHIVE}" -d /tmp/ctranslate2-rocm/
python3 -m ensurepip
python3 -m pip install --no-dependencies --no-index --find-links=/tmp/ctranslate2-rocm/temp-linux/ ctranslate2
fi
installRequirements
+11 -3
View File
@@ -1,18 +1,26 @@
.PHONY: fish-speech test-source-preparation
fish-speech: test-source-preparation
.PHONY: fish-speech test-source-preparation test-runtime-import
fish-speech: test-source-preparation test-runtime-import
bash install.sh
test-source-preparation:
bash prepare-source_test.sh
test-runtime-import:
bash run_test.sh
.PHONY: run
run: fish-speech
@echo "Running fish-speech..."
bash run.sh
@echo "fish-speech run."
.PHONY: test-unit
test-unit:
python3 -m unittest -v prepare_upstream_test.py
bash run_test.sh
.PHONY: test
test: fish-speech
test: fish-speech test-unit
@echo "Testing fish-speech..."
bash test.sh
@echo "fish-speech tested."
+20 -2
View File
@@ -326,11 +326,29 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
max_new_tokens = self.options.get("max_new_tokens", 1024)
chunk_length = self.options.get("chunk_length", 200)
# Build references list for voice cloning
# Build references list for voice cloning. Saved LocalAI
# personalities use the same ordered JSON shape as audio.cpp.
references = []
voice_name = request.voice if request.voice else None
if voice_name and os.path.isfile(voice_name):
multi_reference_cond = request.params.get("multi_reference_cond", "") if hasattr(request, "params") else ""
if multi_reference_cond:
reference_entries = json.loads(multi_reference_cond)
if not isinstance(reference_entries, list) or not reference_entries:
raise ValueError("multi_reference_cond must be a non-empty JSON array")
for entry in reference_entries:
if not isinstance(entry, dict) or not entry.get("audio") or not entry.get("text"):
raise ValueError("multi_reference_cond entries require audio and text")
ref_audio_path = self._get_ref_audio_path(entry["audio"])
with open(ref_audio_path, "rb") as f:
audio_bytes = f.read()
references.append(ServeReferenceAudio(audio=audio_bytes, text=entry["text"]))
print(
f"[INFO] Using {len(references)} per-request reference audios",
file=sys.stderr,
)
elif voice_name and os.path.isfile(voice_name):
ref_audio_path = self._get_ref_audio_path(voice_name)
with open(ref_audio_path, "rb") as f:
audio_bytes = f.read()
+7
View File
@@ -44,6 +44,13 @@ fi
# editable install. pyaudio is only used by the upstream playback client.
bash "${backend_dir}/prepare-source.sh" "${BUILD_TYPE:-}" "${FISH_SPEECH_DIR}/pyproject.toml"
# CUDA 13 has no torch 2.8 wheels, so fish-speech's exact upstream pin would
# make pip select the CPU-only aarch64 wheel from PyPI. Prepare the cloned tree
# before resolving it, and use soundfile for reference audio because torchcodec
# does not publish Linux aarch64 wheels.
python3 "${backend_dir}/prepare_upstream.py" "${FISH_SPEECH_DIR}" \
--cuda-major "${CUDA_MAJOR_VERSION:-}"
# Install fish-speech deps from source (without the package itself since we use PYTHONPATH)
ensureVenv
if [ "x${USE_PIP}" == "xtrue" ]; then
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
import argparse
from pathlib import Path
TORCH_28 = '"torch==2.8.0"'
TORCH_29 = '"torch==2.9.1"'
TORCHAUDIO_28 = '"torchaudio==2.8.0"'
TORCHAUDIO_29 = '"torchaudio==2.9.1"'
TORCHAUDIO_LOAD = (
" waveform, original_sr = "
"torchaudio.load(reference_audio, backend=self.backend)"
)
SOUNDFILE_LOAD = "\n".join(
(
" import soundfile as _sf",
" import torch as _torch",
"",
" data, original_sr = _sf.read(",
' reference_audio, dtype="float32", always_2d=True',
" )",
" waveform = _torch.from_numpy(data.T.copy())",
)
)
def patch_cuda13_dependencies(pyproject: Path) -> None:
content = pyproject.read_text()
if (
TORCH_28 not in content
and TORCHAUDIO_28 not in content
and TORCH_29 in content
and TORCHAUDIO_29 in content
):
return
if TORCH_28 not in content or TORCHAUDIO_28 not in content:
raise RuntimeError("fish-speech's torch 2.8 dependency pins have changed")
content = content.replace(TORCH_28, TORCH_29)
content = content.replace(TORCHAUDIO_28, TORCHAUDIO_29)
pyproject.write_text(content)
def patch_reference_loader(loader: Path) -> None:
content = loader.read_text()
if TORCHAUDIO_LOAD not in content and content.count(SOUNDFILE_LOAD) == 1:
return
if content.count(TORCHAUDIO_LOAD) != 1:
raise RuntimeError("fish-speech's torchaudio.load call has changed")
loader.write_text(content.replace(TORCHAUDIO_LOAD, SOUNDFILE_LOAD))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("--cuda-major")
args = parser.parse_args()
if args.cuda_major == "13":
patch_cuda13_dependencies(args.source / "pyproject.toml")
patch_reference_loader(
args.source / "fish_speech/inference_engine/reference_loader.py"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,136 @@
# SPDX-License-Identifier: MIT
import importlib.util
import sys
import tempfile
import types
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).with_name("prepare_upstream.py")
def load_prepare_upstream():
if not MODULE_PATH.exists():
raise AssertionError("prepare_upstream.py is missing")
spec = importlib.util.spec_from_file_location("prepare_upstream", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class FakeAudioData:
@property
def T(self):
return self
def copy(self):
return "channels-first"
class PrepareUpstreamTests(unittest.TestCase):
def test_cuda13_dependencies_follow_available_pytorch_wheels(self):
prepare_upstream = load_prepare_upstream()
with tempfile.TemporaryDirectory() as tmp:
pyproject = Path(tmp) / "pyproject.toml"
pyproject.write_text(
'dependencies = [\n "torch==2.8.0",\n "torchaudio==2.8.0",\n]\n'
'stable = [\n "torch==2.8.0",\n "torchaudio",\n]\n'
)
prepare_upstream.patch_cuda13_dependencies(pyproject)
self.assertEqual(
pyproject.read_text(),
'dependencies = [\n "torch==2.9.1",\n "torchaudio==2.9.1",\n]\n'
'stable = [\n "torch==2.9.1",\n "torchaudio",\n]\n',
)
def test_reference_audio_uses_soundfile_without_torchcodec(self):
prepare_upstream = load_prepare_upstream()
with tempfile.TemporaryDirectory() as tmp:
loader = Path(tmp) / "reference_loader.py"
loader.write_text(
"class ReferenceLoader:\n"
" def load_audio(self, reference_audio):\n"
" waveform, original_sr = torchaudio.load(reference_audio, backend=self.backend)\n"
" return waveform, original_sr\n"
)
prepare_upstream.patch_reference_loader(loader)
calls = []
fake_soundfile = types.SimpleNamespace(
read=lambda source, **kwargs: (
calls.append((source, kwargs)) or FakeAudioData(),
24000,
)
)
fake_torch = types.SimpleNamespace(
from_numpy=lambda data: ("tensor", data),
)
previous_soundfile = sys.modules.get("soundfile")
previous_torch = sys.modules.get("torch")
sys.modules["soundfile"] = fake_soundfile
sys.modules["torch"] = fake_torch
try:
namespace = {"torchaudio": None}
exec(compile(loader.read_text(), str(loader), "exec"), namespace)
instance = namespace["ReferenceLoader"]()
instance.backend = "soundfile"
waveform, sample_rate = instance.load_audio("voice.wav")
finally:
if previous_soundfile is None:
del sys.modules["soundfile"]
else:
sys.modules["soundfile"] = previous_soundfile
if previous_torch is None:
del sys.modules["torch"]
else:
sys.modules["torch"] = previous_torch
self.assertEqual(waveform, ("tensor", "channels-first"))
self.assertEqual(sample_rate, 24000)
self.assertEqual(
calls,
[("voice.wav", {"dtype": "float32", "always_2d": True})],
)
def test_reference_loader_drift_fails_the_build(self):
prepare_upstream = load_prepare_upstream()
with tempfile.TemporaryDirectory() as tmp:
loader = Path(tmp) / "reference_loader.py"
loader.write_text("def load_audio():\n pass\n")
with self.assertRaisesRegex(RuntimeError, "torchaudio.load call"):
prepare_upstream.patch_reference_loader(loader)
def test_preparation_can_be_repeated(self):
prepare_upstream = load_prepare_upstream()
with tempfile.TemporaryDirectory() as tmp:
pyproject = Path(tmp) / "pyproject.toml"
pyproject.write_text(
'dependencies = ["torch==2.8.0", "torchaudio==2.8.0"]\n'
)
loader = Path(tmp) / "reference_loader.py"
loader.write_text(
"def load_audio(reference_audio):\n"
" waveform, original_sr = torchaudio.load(reference_audio, backend=self.backend)\n"
)
prepare_upstream.patch_cuda13_dependencies(pyproject)
prepare_upstream.patch_reference_loader(loader)
try:
prepare_upstream.patch_cuda13_dependencies(pyproject)
prepare_upstream.patch_reference_loader(loader)
except RuntimeError as err:
self.fail(f"preparation is not idempotent: {err}")
if __name__ == "__main__":
unittest.main()
@@ -1,3 +1,3 @@
--extra-index-url https://download.pytorch.org/whl/cu130
torch
torchaudio
torch==2.9.1+cu130
torchaudio==2.9.1
@@ -1,3 +1,3 @@
--extra-index-url https://download.pytorch.org/whl/cu130
torch
torchaudio
torch==2.9.1+cu130
torchaudio==2.9.1
+11 -2
View File
@@ -1,9 +1,18 @@
#!/bin/bash
backend_dir=$(dirname $0)
backend_dir=$(dirname "$(realpath "$0")")
if [ -d $backend_dir/common ]; then
source $backend_dir/common/libbackend.sh
else
source $backend_dir/../common/libbackend.sh
fi
startBackend $@
# Editable installs record their build-time absolute source path, which becomes
# stale when the backend is relocated under /backends at install time.
export PYTHONPATH="${backend_dir}/fish-speech-src${PYTHONPATH:+:${PYTHONPATH}}"
cuda_home=${CUDA_HOME:-/usr/local/cuda}
if [ -z "${TRITON_PTXAS_PATH:-}" ] && [ -x "$cuda_home/bin/ptxas" ]; then
export TRITON_PTXAS_PATH="$cuda_home/bin/ptxas"
fi
startBackend "$@"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$(dirname "$(realpath "$0")")
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$WORK_DIR/runtime/common" "$WORK_DIR/runtime/fish-speech-src/fish_speech"
cp "$SCRIPT_DIR/run.sh" "$WORK_DIR/runtime/run.sh"
cat > "$WORK_DIR/runtime/fish-speech-src/fish_speech/inference_engine.py" <<'PY'
IMPORT_OK = True
PY
cat > "$WORK_DIR/runtime/common/libbackend.sh" <<'SH'
startBackend() {
python3 -c 'from fish_speech.inference_engine import IMPORT_OK; assert IMPORT_OK'
}
SH
bash "$WORK_DIR/runtime/run.sh"
echo "PASS: runtime launcher imports relocated fish-speech source"
+23
View File
@@ -0,0 +1,23 @@
.PHONY: funasr
funasr:
bash install.sh
.PHONY: run
run: funasr
@echo "Running funasr..."
bash run.sh
@echo "funasr run."
.PHONY: test
test: funasr
@echo "Testing funasr..."
bash test.sh
@echo "funasr tested."
.PHONY: protogen-clean
protogen-clean:
$(RM) backend_pb2_grpc.py backend_pb2.py
.PHONY: clean
clean: protogen-clean
rm -rf venv __pycache__
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
gRPC backend for LocalAI wrapping FunASR (SenseVoice / Paraformer).
"""
from concurrent import futures
import time
import argparse
import signal
import sys
import os
import backend_pb2
import backend_pb2_grpc
import torch
import grpc
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common'))
from grpc_auth import get_auth_interceptors
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '1'))
class BackendServicer(backend_pb2_grpc.BackendServicer):
def Health(self, request, context):
return backend_pb2.Reply(message=bytes("OK", 'utf-8'))
def LoadModel(self, request, context):
from funasr import AutoModel
device = "cpu"
if request.CUDA and torch.cuda.is_available():
device = "cuda"
elif hasattr(torch, "xpu") and torch.xpu.is_available():
device = "xpu"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
model_id = request.Model or "iic/SenseVoiceSmall"
candidate_devices = [device]
if device in ("xpu", "mps"):
candidate_devices.append("cpu")
for candidate_device in candidate_devices:
try:
print(
f"Loading FunASR model: {model_id} on {candidate_device}",
file=sys.stderr,
)
self.model = AutoModel(
model=model_id,
vad_model="fsmn-vad",
device=candidate_device,
disable_update=True,
)
print("FunASR model loaded successfully", file=sys.stderr)
break
except Exception as err:
if candidate_device != candidate_devices[-1]:
print(
f"[WARN] FunASR {candidate_device} initialization failed: "
f"{err}; retrying on cpu",
file=sys.stderr,
)
continue
print(f"[ERROR] LoadModel failed: {err}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return backend_pb2.Result(success=False, message=str(err))
return backend_pb2.Result(message="Model loaded successfully", success=True)
def AudioTranscription(self, request, context):
from funasr.utils.postprocess_utils import rich_transcription_postprocess
result_segments = []
text = ""
try:
audio_path = request.dst
if not audio_path or not os.path.exists(audio_path):
print(f"Error: Audio file not found: {audio_path}", file=sys.stderr)
return backend_pb2.TranscriptResult(segments=[], text="")
language = None
if request.language and request.language.strip():
language = request.language.strip()
kwargs = {}
if language:
kwargs["language"] = language
results = self.model.generate(input=audio_path, **kwargs)
if not results:
return backend_pb2.TranscriptResult(segments=[], text="")
for idx, r in enumerate(results):
seg_text = r.get("text", "") if isinstance(r, dict) else str(r)
seg_text = rich_transcription_postprocess(seg_text)
text += seg_text
result_segments.append(backend_pb2.TranscriptSegment(
id=idx,
start=0,
end=0,
text=seg_text,
))
except Exception as err:
print(f"Error in AudioTranscription: {err}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return backend_pb2.TranscriptResult(segments=[], text="")
return backend_pb2.TranscriptResult(segments=result_segments, text=text)
def serve(address):
server = grpc.server(
futures.ThreadPoolExecutor(max_workers=MAX_WORKERS),
options=[
('grpc.max_message_length', 50 * 1024 * 1024),
('grpc.max_send_message_length', 50 * 1024 * 1024),
('grpc.max_receive_message_length', 50 * 1024 * 1024),
],
interceptors=get_auth_interceptors(),
)
backend_pb2_grpc.add_BackendServicer_to_server(BackendServicer(), server)
server.add_insecure_port(address)
server.start()
print("Server started. Listening on: " + address, file=sys.stderr)
def signal_handler(sig, frame):
print("Received termination signal. Shutting down...")
server.stop(0)
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
while True:
time.sleep(_ONE_DAY_IN_SECONDS)
except KeyboardInterrupt:
server.stop(0)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run the gRPC server.")
parser.add_argument("--addr", default="localhost:50051", help="The address to bind the server to.")
args = parser.parse_args()
serve(args.addr)
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -e
EXTRA_PIP_INSTALL_FLAGS="--no-build-isolation"
backend_dir=$(dirname $0)
if [ -d $backend_dir/common ]; then
source $backend_dir/common/libbackend.sh
else
source $backend_dir/../common/libbackend.sh
fi
if [ "x${BUILD_PROFILE}" == "xintel" ]; then
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
fi
PYTHON_VERSION="3.12"
PYTHON_PATCH="12"
PY_STANDALONE_TAG="20251120"
installRequirements
@@ -0,0 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/cpu
torch
torchaudio
funasr
@@ -0,0 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/cu121
torch
torchaudio
funasr
@@ -0,0 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/cu130
torch
torchaudio
funasr
@@ -0,0 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/rocm7.0
torch
torchaudio
funasr
@@ -0,0 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/xpu
torch
torchaudio
funasr
@@ -0,0 +1,4 @@
--extra-index-url https://pypi.jetson-ai-lab.io/jp6/cu129/
torch
torchaudio
funasr
@@ -0,0 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/cu130
torch
torchaudio
funasr
Loaded 100 of 398 files, more files were not shown because too many files have changed in this diff. Show more