Compare commits

...

179 Commits

Author SHA1 Message Date
LocalAI [bot]
b224c96db6 fix(config): only inject llama.cpp serving options on the llama.cpp path (#10822)
SetDefaults injected the llama.cpp server options cache_reuse
(ApplyServingDefaults) and parallel (ApplyHardwareDefaults, re-applied
per selected node by the distributed router) onto every model config
regardless of backend. Every other backend ignores options it does not
understand, so this was harmless until longcat-video, which strictly
validates its options and fails LoadModel with
"unknown model option(s): cache_reuse, parallel".

Gate both injections behind a new UsesLlamaCppServingOptions allow-list
(llama-cpp plus the empty/auto-detect case that resolves to llama.cpp
from a GGUF file, mirroring how llamaCppDefaults is registered). This
follows the existing UsesLlamaSamplerDefaults precedent for llama-only
defaults. The typed NBatch field is deliberately left alone: it is a
proto field every backend simply ignores, which is why batch never
triggered the error.

Also harden the longcat-video backend to warn-and-ignore unknown model
options and request params through a testable select_known_options
helper, matching the other LocalAI Python backends, so a future
server-injected option cannot break loading again.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-14 17:46:15 +02:00
LocalAI [bot]
9f14571397 chore(model-gallery): ⬆️ update checksum (#10816)
⬆️ 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-07-14 11:25:00 +02:00
Ijas
a5aa56db81 fix: preserve uploaded file content when regenerating a non-last answer (#10819)
handleRegenerate rebuilt the outbound message from the display-only
message.files metadata ({name, type: 'file'|'image'|..., content}),
which doesn't carry the base64/textContent payload sendMessage's
file-building loop expects. As a result, regenerating any answer whose
own question had an attachment silently dropped that attachment from
the resent message. This wasn't fork-specific, but forking a chat and
then regenerating an earlier (now non-last) answer is the natural way
to hit it.

Fix by reusing the original message's already-assembled `content`
verbatim (it already has the file text / image_url / audio_url /
video_url parts embedded from the first send) instead of trying to
reconstruct it from lossy display metadata.

Fixes #10806

Assisted-by: Claude:claude-sonnet-5

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
2026-07-14 11:24:42 +02:00
LocalAI [bot]
05b8d8aafe chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to bbdf4be03aa5bc3c188b5db778b6f3fd63ceff6c (#10794)
⬆️ 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-07-14 08:38:20 +02:00
LocalAI [bot]
3c1e583985 chore: ⬆️ Update CrispStrobe/CrispASR to d76cce027e3b183fc3d8c72e976e69d11f71bc8b (#10813)
⬆️ 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-07-14 08:38:05 +02:00
LocalAI [bot]
2609848d80 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260713103604 (#10812)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-14 08:36:49 +02:00
dependabot[bot]
cdb6702ca6 chore(deps): bump actions/stale from 10.3.0 to 10.4.0 (#10807)
Bumps [actions/stale](https://github.com/actions/stale) from 10.3.0 to 10.4.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](eb5cf3af3a...1e223db275)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.4.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-07-13 22:50:01 +02:00
dependabot[bot]
0d8bea0158 chore(deps): update charset-normalizer requirement from >=3.4.7 to >=3.4.9 in /backend/python/vllm (#10809)
chore(deps): update charset-normalizer requirement

Updates the requirements on [charset-normalizer](https://github.com/jawah/charset_normalizer) to permit the latest version.
- [Release notes](https://github.com/jawah/charset_normalizer/releases)
- [Changelog](https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jawah/charset_normalizer/compare/3.4.7...3.4.9)

---
updated-dependencies:
- dependency-name: charset-normalizer
  dependency-version: 3.4.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 22:49:44 +02:00
dependabot[bot]
0b0f52bedc chore(deps): bump grpcio from 1.81.1 to 1.82.1 in /backend/python/vllm (#10808)
Bumps [grpcio](https://github.com/grpc/grpc) from 1.81.1 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.81.1...v1.82.1)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-version: 1.82.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-07-13 22:49:28 +02:00
dependabot[bot]
48b1ab28b7 chore(deps): bump vllm from 0.24.0 to 0.25.0 in /backend/python/vllm (#10811)
Bumps [vllm](https://github.com/vllm-project/vllm) from 0.24.0 to 0.25.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.24.0...v0.25.0)

---
updated-dependencies:
- dependency-name: vllm
  dependency-version: 0.25.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-07-13 22:48:35 +02:00
Dedy F. Setyawan
b10e330590 feat(react-ui): localize MediaHistory, AudioTransform, and Sound components (#10802)
- Replace hardcoded text with useTranslation hook in UI components
- Add localization support for both English (en) and Indonesian (id) locales

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
2026-07-13 11:51:23 +00:00
LocalAI [bot]
4056283aa4 [voice] feat: add managed voice cloning profiles (#10799)
* feat(ui): add voice library workflow

Give administrators a production-ready flow to record or upload consented reference audio, manage reusable profiles, inspect API usage, discover compatible models, and hand a saved voice directly to text-to-speech.

Assisted-by: Codex:gpt-5

* feat(voice): add managed voice cloning profiles

Make reusable reference voices manageable through the admin API instead of requiring model-directory and YAML edits. Discover compatible installed and gallery models from server-side backend capabilities, retain explicit model configuration controls, and stage saved references for supported backends.

Expose profile management through REST and MCP, document backend-specific behavior, and cover the workflow from profile creation through real Qwen3-TTS synthesis. Harden the agent-job HTTP test against completion racing cancellation.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-13 09:54:46 +02:00
LocalAI [bot]
b90e1cae73 chore: ⬆️ Update ggml-org/llama.cpp to 6b4dc2116a92c5c8f2782bfe51fabe5ee66fb5ef (#10797)
⬆️ 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-07-13 09:05:44 +02:00
LocalAI [bot]
c43ee40eb7 chore: ⬆️ Update CrispStrobe/CrispASR to 841281c46ce5b34323b2861ae5714c09aaa2542e (#10795)
⬆️ 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-07-13 01:10:16 +02:00
LocalAI [bot]
659b9f02e0 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260711221406 (#10796)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-13 01:10:03 +02:00
LocalAI [bot]
67c14e1b7e chore(model-gallery): ⬆️ update checksum (#10798)
⬆️ 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-07-13 01:09:13 +02:00
LocalAI [bot]
0e6241a5aa chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to d17c33d4ee2f56d15f9ca8a1bb82f7389305f838 (#10793)
⬆️ Update ServeurpersoCom/qwentts.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-07-13 01:09:03 +02:00
LocalAI [bot]
b00422e45f feat(backends): add LongCat video and avatar generation (#10792)
* feat(backends): add LongCat video and avatar generation

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] [web]

* refactor(config): declare model I/O modalities

Make model configs declare input and output modalities so capability discovery no longer branches on backend or checkpoint names. Complete the LongCat gallery and user documentation, make the SDPA patch apply to the pinned upstream revision, and stabilize the Agent Jobs race exposed by the required hook.

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

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-12 23:58:46 +02:00
LocalAI [bot]
af8f74cba2 chore: ⬆️ Update CrispStrobe/CrispASR to 4beda42f63bf5c813c1e1bb55249efb36f918c80 (#10784)
⬆️ 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-07-12 11:27:12 +02:00
LocalAI [bot]
cdd9582653 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to bfc447c47a592698b539e0b819b1a4e3c91c4730 (#10786)
⬆️ 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-07-12 11:14:22 +02:00
LocalAI [bot]
fb0f5e4bdd feat(gallery): add Qwen DFlash speculative-decoding models (#10791)
Add four ready-to-run DFlash speculative-decoding entries for the
llama.cpp backend, now that upstream DFlash support (draft-dflash) is in
the pinned llama.cpp. Each entry bundles a full target model with its
small z-lab block-diffusion drafter and sets spec_type:draft-dflash,
spec_n_max:15, and flash attention (required by DFlash):

- qwen3-4b-dflash          (Qwen3-4B + Qwen3-4B-DFlash drafter)
- qwen3.5-9b-dflash        (Qwen3.5-9B + Qwen3.5-9B-DFlash drafter)
- qwen3.6-27b-dflash       (Qwen3.6-27B dense + drafter)
- qwen3.6-35b-a3b-dflash   (Qwen3.6-35B-A3B MoE + drafter)

The 4B pair uses the base Qwen3-4B target (not Qwen3.5-4B): its drafter
reports general.name "Qwen3 4B DFlash" and is the canonical pairing
documented upstream. All drafters were downloaded and verified to carry
GGUF architecture "dflash" (not the fork-only "dflash-draft" /
"DFlashDraftModel") so they load in the upstream backend, and every
drafter SHA256 was confirmed against the downloaded bytes.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-12 11:14:06 +02:00
LocalAI [bot]
5013d53a1c chore: ⬆️ Update ggml-org/llama.cpp to e3546c7948e3af463d0b401e6421d5a4c2faf565 (#10787)
⬆️ 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-07-12 10:07:58 +02:00
LocalAI [bot]
e8d8f5b0b8 chore: ⬆️ Update ggml-org/whisper.cpp to 080bbbe85230f624f0b52127f1ae1218247989f9 (#10785)
⬆️ 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-07-12 10:07:45 +02:00
LocalAI [bot]
459ffb3054 chore: ⬆️ Update leejet/stable-diffusion.cpp to b5d812008eb7082a238fc589444544b3278187ae (#10774)
⬆️ 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-07-12 10:07:34 +02:00
LocalAI [bot]
cad07be2fc chore(model-gallery): ⬆️ update checksum (#10789)
⬆️ 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-07-11 23:41:48 +02:00
hogeheer499-commits
2634b13a5d fix(ds4): bundle transitive runtime dependencies (#10783)
Bundle the resolved dependency closure for grpc-server and ds4-worker, then validate that packaged dependencies resolve only from package/lib.

Assisted-by: Codex:gpt-5 shellcheck

Signed-off-by: JS van Dijk <267467744+hogeheer499-commits@users.noreply.github.com>
Co-authored-by: JS van Dijk <267467744+hogeheer499-commits@users.noreply.github.com>
2026-07-11 23:41:27 +02:00
LocalAI [bot]
8786eace97 chore: ⬆️ Update vllm-project/vllm cu130 wheel to 0.25.0 (#10788)
⬆️ 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-07-11 23:39:51 +02:00
Ettore Di Giacinto
a1cefe862d Rename voxtral.c to voxtral-tts.c in README
Signed-off-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-07-11 16:24:44 +02:00
LocalAI [bot]
50cd897719 docs: refresh LocalAI homepage (#10780)
* docs: refresh LocalAI homepage

Reframe the homepage around LocalAI's modular multimodal runtime, native inference engines, deployment range, and built-in platform capabilities. Remove the outdated video in favor of current product visuals and clearer paths into the documentation.

Assisted-by: Codex:gpt-5

* docs: give homepage a full-width canvas

Let the product homepage opt out of Relearn's persistent sidebar and duplicate title while preserving the documentation shell on interior pages. Tighten the responsive bounds for narrow screens.

Assisted-by: Codex:gpt-5

* docs: fit homepage to the Relearn content flow

Remove the full-width shell exception and use a single-column homepage inside the standard documentation layout. This avoids competing scroll containers and the compressed split hero.

Assisted-by: Codex:gpt-5

* docs: hide homepage scroll rail

Preserve Relearn's content scrolling while removing the visible scrollbar beside the landing-page hero.

Assisted-by: Codex:gpt-5

* docs: contain homepage sections within docs column

Prevent landing-page headings, figures, and section grids from widening Relearn's content pane or exposing overflow rails.

Assisted-by: Codex:gpt-5

* docs: remove nested homepage scrollbars

Wrap the quick-start command within its column and suppress component-level scrollbar tracks across the landing page.

Assisted-by: Codex:gpt-5

* docs: remove outdated gallery screenshot

Drop the stale Model Gallery image from the homepage until a current product visual is available.

Assisted-by: Codex:gpt-5

* docs: fix homepage architecture link

Point the homepage CTA at the generated reference/architecture route.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-11 09:33:41 +02:00
LocalAI [bot]
8ff3c8c466 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to bb250f57e48d7dbaff8f66b8125c42a14ddabbe7 (#10759)
⬆️ Update ServeurpersoCom/qwentts.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-07-11 09:17:48 +02:00
Richard Palethorpe
1f9fda7138 fix(backends): refuse foreign model loads in opus and local-store (#10769)
When a model config has no explicit backend, the model loader greedily
probes every installed backend and binds to the first Load that
succeeds. opus and local-store were the only in-tree backends with no
model artefact to validate, so they accepted anything — an LLM
installed after them could silently bind to the audio codec or the
vector store and then fail at inference with "unimplemented"
(see #9287).

opus now accepts only its own name (what the realtime WebRTC path
sends) or none. local-store namespaces are arbitrary (router caches,
biometrics, user-named stores), so core's StoreBackend now marks
genuine store loads with a store:// prefix on the gRPC model name and
the backend refuses names without it; core and backend ship from the
same release, so the convention upgrades in lockstep.

Also repair the bit-rotted 'make test-stores' bootstrap (the suite
never registered external backends, so BACKENDS_PATH was dead weight)
and add the Load-validation rule to the adding-backends checklist.

Related: #9287

Assisted-by: Claude:claude-fable-5 golangci-lint

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-07-11 09:17:34 +02:00
LocalAI [bot]
23a044ee0b chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260710141421 (#10775)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-11 09:16:12 +02:00
LocalAI [bot]
921a8ffc8b chore: ⬆️ Update mudler/moss-transcribe.cpp to 92a923dca88a41a34e47a364d55ee25731a9a0a2 (#10771)
⬆️ Update mudler/moss-transcribe.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-07-11 09:15:58 +02:00
LocalAI [bot]
a8f1c92a24 chore: ⬆️ Update CrispStrobe/CrispASR to 74efaf24d457e34cd200d138f9724987e7ececc3 (#10773)
⬆️ 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-07-11 09:15:45 +02:00
LocalAI [bot]
6084497da1 chore: ⬆️ Update ggml-org/llama.cpp to 4f37f519722aa3242eecb7649466b4a4a2d6d6da (#10772)
⬆️ 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-07-11 09:15:32 +02:00
LocalAI [bot]
2482f075a2 chore: ⬆️ Update ggml-org/whisper.cpp to 7695a5331230c585f5ce92291c4256973985ae5a (#10776)
⬆️ 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-07-11 09:14:19 +02:00
LocalAI [bot]
9b4f373bc4 chore(model-gallery): ⬆️ update checksum (#10778)
⬆️ 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-07-11 09:14:06 +02:00
LocalAI [bot]
185956154a chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to b297bc36d5e54ac37263fc8f2766e96179bf1e8a (#10761)
⬆️ 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-07-10 10:43:11 +02:00
LocalAI [bot]
d3d5488dc7 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260708043308 (#10760)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-10 10:42:56 +02:00
LocalAI [bot]
ae58115ee6 chore: ⬆️ Update leejet/stable-diffusion.cpp to cc734292286f85f9c48305d94d7fd22f42838522 (#10738)
⬆️ 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-07-10 10:42:42 +02:00
LocalAI [bot]
c7a9db29a6 chore: ⬆️ Update CrispStrobe/CrispASR to aa23c61c8ba483f539fa669708ea7ddbba72f293 (#10758)
⬆️ 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-07-10 10:42:26 +02:00
LocalAI [bot]
c46a224b44 chore: ⬆️ Update ggml-org/llama.cpp to 049326a00025d00b08cc188ed716b681e984a3f8 (#10757)
⬆️ 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-07-10 10:42:12 +02:00
LocalAI [bot]
7e8542ba32 fix(tests): make e2e backend model downloads resumable and stall-based (#10766)
The vibevoice transcription e2e hangs until the go test timeout when
the HF CDN is slow: the ASR Q4_K model is >10 GB, downloadFile capped
every curl attempt at --max-time 600 (needs a sustained ~17 MB/s to
fit), and curl's --retry restarts from byte zero, so no attempt ever
makes forward progress. This killed the job twice on PR #10764 and
previously forced skipping it on release tags (#10567).

Replace the wall-clock cap with stall detection (--speed-limit 1 MiB/s
over --speed-time 120s) and resume from the bytes already on disk with
-C -, retrying from Go because curl does not re-evaluate the resume
offset on its internal retries. Resume against the HF Xet CDN was
verified by killing a transfer mid-flight and confirming the next
invocation appended (114 MB -> 235 MB, GGUF magic intact).

Also parameterize the suite timeout (BACKEND_TEST_TIMEOUT, default
30m) and raise it to 120m for the vibevoice transcription wrapper: a
10 GB download plus 25 specs does not fit in 30m even on a good day,
and the job-level GHA timeout there is already 150m.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-10 09:19:17 +02:00
LocalAI [bot]
3c2d85aae4 feat(vibevoice-cpp): true streaming TTS (TTSStream via vv_capi_tts_stream) (#10764)
* feat(vibevoice-cpp): true streaming TTSStream via vv_capi_tts_stream

Replaces the synth-to-tempfile TTSStream hack with a real streaming path:
binds the new vv_capi_tts_stream callback ABI via a single reusable purego
callback (CGO_ENABLED=0-safe, no runtime/cgo), copies each int16 PCM window
into the gRPC results channel after the streaming WAV header.

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

* test(vibevoice-cpp): real-model streaming integration test with TTFA measurement

Gated behind VIBEVOICE_IT=1, this Ginkgo spec dlopens the engine .so and
drives the exact Go->purego->C TTSStream/TTS path against the real
vibevoice-realtime-0.5B model. It measures time-to-first-audio for the
streaming path versus the batch path and asserts the streaming win:
44-byte WAV header first, >=2 PCM windows, non-silent audio, and
TTFA < total_stream. Without the env var the spec skips so CI and
normal go test are unaffected.

Measured: TTFA 2.38s vs batch deliver-time 39.96s (first audio in 5.9%
of the batch time, ~17x faster), 18 stream chunks, non-silent 24kHz PCM.

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

* chore(vibevoice-cpp): pin streaming-decoder engine build

Bumps VIBEVOICE_CPP_VERSION to the streaming-decoder engine commit that
adds vv_capi_tts_stream (localai-org/vibevoice.cpp#8). Re-pin to the
merged master commit once that PR lands.

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

* chore(vibevoice-cpp): re-pin to merged streaming-decoder commit

localai-org/vibevoice.cpp#8 merged to master as 000e372; move the pin
off the PR branch commit onto the merged master commit.

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

* test(vibevoice-cpp): check writer errors in TTFA report (errcheck)

golangci-lint errcheck flagged the unchecked fmt.Fprintf calls that
print the streaming TTFA headline. Build the report once with
fmt.Sprintf and write it per destination with an explicitly discarded
error, matching the GinkgoWriter reporting idiom used by the other
backend tests.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 23:48:44 +00:00
LocalAI [bot]
6ceb2f86a7 chore(model-gallery): ⬆️ update checksum (#10763)
⬆️ 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-07-10 00:41:19 +02:00
LocalAI [bot]
c5b36639d4 chore(model gallery): 🤖 add 1 new models via gallery agent (#10755)
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-07-09 23:28:44 +02:00
LocalAI [bot]
94bdc825dc feat(backend): add moss-transcribe-cpp backend (MOSS-Transcribe-Diarize) (#10756)
C++/ggml transcription + speaker diarization + timestamps backend. Purego
dlopens libmoss-transcribe.so (ggml statically linked) from moss-transcribe.cpp
and serves offline AudioTranscription, parsing the [start][Sxx]text[end] output
into segments with nanosecond timestamps. Adds the importer (surfaces in
GET /backends/known), backend-matrix (Linux + Darwin/metal), backend/index.yaml,
and a gallery entry (default q5_k GGUF from mudler/moss-transcribe.cpp-gguf).

Local L0 smoke (go build + go test ./... = 16 pass, golangci-lint 0 issues)
passed against the real libmoss-transcribe.so. The pre-commit coverage gate
(full pkg/core + tests/e2e) could not run in the authoring sandbox (no live
models, port 9090 held); CI must enforce it before merge.

Assisted-by: Claude:claude-opus-4-8 golangci-lint

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 23:27:11 +02:00
Dedy F. Setyawan
294487eb61 fix(ui): prevent table container from breaking flexbox layout (#10754)
Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
2026-07-09 23:21:07 +02:00
Ettore Di Giacinto
fa3e139540 docs(readme): add face-detect.cpp, voice-detect.cpp and free-splatter.cpp to native engines list
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 11:51:28 +00:00
LocalAI [bot]
35024338a6 feat(crispasr): add F5-TTS support and gallery model (#10753)
Link the f5-tts library into the crispasr backend so CrispASR's native
F5-TTS runtime (SWivid F5-TTS, 22-layer DiT flow-matching + built-in Vocos
vocoder) is compiled in. The single self-contained GGUF auto-detects as
f5-tts through the session router, so no explicit backend selector is
needed. Add the f5-tts-crispasr gallery entry (cstr/f5-tts-GGUF) and an
env-gated e2e synthesis spec.

F5-TTS is voice-cloning only and has no baked speaker: it clones from a
reference WAV plus its transcript, supplied via the voice/voice_text
options. The gallery description documents this bring-your-own-reference
requirement.

Verified e2e on the pinned engine (278fb79): the GGUF auto-detects as
f5-tts, the reference voice loads, and synthesis produces a valid 24 kHz
mono WAV.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 08:09:00 +00:00
LocalAI [bot]
b987f39de8 feat(swagger): update swagger (#10745)
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-07-09 09:35:22 +02:00
LocalAI [bot]
16d028a127 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to e03824a9f874fe648b8f26bf293703af60afe936 (#10715)
⬆️ 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-07-09 09:05:40 +02:00
LocalAI [bot]
70e15679cd chore: ⬆️ Update ggml-org/llama.cpp to a646006f09d2f76f2d62d6c0d5e8e8490d570720 (#10747)
⬆️ 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-07-09 09:03:51 +02:00
LocalAI [bot]
5569b2de56 feat(config): context_size: -1 to auto-use model's full trained context (#10752)
* feat(config): clamp negative context_size to default in EffectiveContextSize

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

* feat(config): resolve context_size=-1 to model trained max with VRAM warn

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

* fix(config): treat negative context_size as unset when GGUF is unparseable

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

* docs(config): document context_size=-1 auto-max

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

* docs(backend): drop em dashes from EffectiveContextSize comment

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 09:03:40 +02:00
LocalAI [bot]
c9f73f40ff chore: ⬆️ Update CrispStrobe/CrispASR to 278fb7927633d47fa0aeb6f81491b9952ed17c35 (#10737)
⬆️ 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-07-09 01:13:06 +02:00
LocalAI [bot]
4cd3dbe931 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 6198a356a85ed71534c02a9c1026203389f341e5 (#10746)
⬆️ 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-07-09 01:12:48 +02:00
LocalAI [bot]
1a04b670f4 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to e93b3fedd53504c29c1c1a9bed4fbe722bbb1df5 (#10748)
⬆️ Update ServeurpersoCom/qwentts.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-07-09 01:12:35 +02:00
LocalAI [bot]
e948f27965 chore(model-gallery): ⬆️ update checksum (#10749)
⬆️ 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-07-09 01:12:22 +02:00
LocalAI [bot]
40dae953f4 feat: interleaved thinking with tool calls (reasoning_content alias + Anthropic thinking blocks) (#10744)
* feat(schema): accept reasoning_content as inbound alias for reasoning

Interleaved-thinking clients (cogito, vLLM/DeepSeek-style) emit reasoning_content
on assistant turns. Accept it as an inbound alias so reasoning survives the
tool-result loop; canonical reasoning wins when both are present. Emission is
unchanged (still reasoning).

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

* test(schema): pin interleaved reasoning+tool_calls round-trip

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

* test(openai): pin reachedTokenBudget truncation detection

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

* feat(anthropic): add thinking and signature fields to content blocks

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

* feat(anthropic): parse inbound thinking blocks into reasoning

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

* feat(anthropic): emit thinking blocks with synthetic signature on tool turns

Extract buildAnthropicContentBlocks so non-streaming content assembly is
unit-testable, and prepend a thinking block (with an opaque synthetic
signature) before text/tool_use blocks when the request opts into thinking.

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

* feat(anthropic): stream thinking_delta and signature_delta before tool_use

Extract anthropicStreamSequence so the streaming block order is unit-testable,
and emit content_block_start(thinking) -> thinking_delta -> signature_delta ->
content_block_stop before the tool_use block sequence when thinking is enabled.

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

* docs: add interleaved thinking with tool calls guide

Add a features guide describing interleaved thinking: an assistant turn
carrying reasoning and tool_calls together, the reasoning-round-trip
contract (including the reasoning_content inbound alias and Anthropic
thinking blocks with a synthetic signature), per-backend enablement
(reasoning_format for llama.cpp, reasoning_parser/tool_call_parser for
vLLM/SGLang plus the vLLM auto-config hook), a worked request/response
example, and known limitations. Cross-link from model-configuration,
text-generation, and openai-functions.

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-07-08 16:45:43 +00:00
LocalAI [bot]
8671c8adac chore(model gallery): 🤖 add 1 new models via gallery agent (#10743)
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-07-08 17:43:02 +02:00
LocalAI [bot]
d5d659bb65 fix(backends): pin grpcio-tools to installed grpcio in runProtogen (protobuf gencode mismatch) (#10735)
fix(backends): pin grpcio-tools to the installed grpcio in runProtogen

runProtogen installed grpcio-tools unpinned, so the protoc it bundles
stamped backend_pb2.py with the newest Protobuf gencode (7.35.0). When a
backend caps the protobuf runtime lower -- vLLM pins protobuf to 6.33.6 --
the import-time guarantee runtime >= gencode fails:

  google.protobuf.runtime_version.VersionError: Detected incompatible
  Protobuf Gencode/Runtime versions ... gencode 7.35.0 runtime 6.33.6

The backend crashes on `import backend_pb2` before it can serve, which
surfaces to the user as "grpc service not ready". It was mis-reported as a
ROCm/gfx1201 failure in #10718 but is not GPU-specific and affects every
vLLM variant (and any backend that caps protobuf below the latest gencode).

Pin grpcio-tools to the grpcio version the backend already installed --
they release in lockstep -- so the generated gencode stays in step with
the protobuf runtime. Falls back to unpinned when grpcio isn't present.

Closes #10718


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-08 15:24:42 +02:00
LocalAI [bot]
a0ed395cf9 fix(auth): accept EC/PS/EdDSA-signed OIDC ID tokens, not just RS256 (#10736)
The OIDC verifier was built with a bare oidc.Config{ClientID: ...}, so
go-oidc applied its default of accepting RS256-signed ID tokens only. An
identity provider configured with an EC signing key (e.g. Authentik) issues
ES256-signed tokens, and the callback failed verification with:

  failed to verify ID token: oidc: malformed jwt: unexpected signature
  algorithm "HS256"; expected ["RS256"]

surfacing to the user as HTTP 500 "failed to fetch user info" (#10677; the
underlying cause became visible after the logging fix in #10679).

Set SupportedSigningAlgs to the standard asymmetric algorithms
(RS256/384/512, ES256/384/512, PS256/384/512, EdDSA). All are verified
against the provider's published JWKS. HS256 is intentionally excluded: it
is symmetric and would validate against the client secret, a different and
security-sensitive trust model.

Tested with a functional spec that signs an ES256 ID token and confirms it
verifies with the configured algorithms and is rejected under go-oidc's
RS256-only default (using oidc.StaticKeySet, no network).

Closes #10677


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-08 15:24:29 +02:00
LocalAI [bot]
40c29db8c4 fix(logs): capture backend logs by default in single mode (#10742)
Backend log capture into the per-model BackendLogStore (which feeds the
UI "Backend Logs" page and /api/backend-logs) was opt-in and off by
default in single mode, while worker/distributed mode force-enables it
via SetBackendLoggingEnabled(true). There was no CLI flag either, so the
only way to populate the store was the Settings UI toggle - and the page
was silently empty out of the box. Distributed "just worked"; single
mode looked broken.

Default EnableBackendLogging to true in NewApplicationConfig so single
mode matches worker mode. The store is a small in-memory ring buffer, so
the cost is negligible.

Now that the default is on, loadRuntimeSettingsFromFile's usual
"only flip false->true" merge would ignore a persisted false and revert
the UI toggle-off on every restart. There is no env var/CLI flag for
this setting, so an explicit persisted value is now authoritative in
both directions, letting the toggle-off survive a restart.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-08 12:13:52 +00:00
LocalAI [bot]
0aaf7cce76 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 5c2552b6e820cc791ba9cfa6ffdfd98002b4cd82 (#10732)
⬆️ 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-07-08 08:50:00 +02:00
LocalAI [bot]
731bf04668 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260707023344 (#10733)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-08 08:49:48 +02:00
LocalAI [bot]
d829e818d0 chore: ⬆️ Update ggml-org/llama.cpp to bec4772f6a2527d371557b5d2032641e5ff7619c (#10739)
⬆️ 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-07-08 08:49:34 +02:00
github-actions[bot]
0ae84be362 chore: bump inference defaults from unsloth (#10741)
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-08 08:49:22 +02:00
LocalAI [bot]
d521608e6d chore: ⬆️ Update mudler/locate-anything.cpp to ade2634f7f79b56121125e5885628744795a478f (#10734)
⬆️ Update mudler/locate-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-07-07 22:03:16 +00:00
LocalAI [bot]
7dde5a4225 fix(diffusers,vllm-omni,tinygrad): save generated images as PNG explicitly (#10729)
The image backends call PIL Image.save(request.dst) without a format, so
Pillow infers the encoder from the file extension. The core passes an
absolute staging path ending in .tmp (e.g. /staging/localai-output-*.tmp),
which Pillow can't map to a format, raising "unknown file extension: .tmp"
and crashing the worker right after a successful GPU inference.

Pass format="PNG" explicitly. LocalAI serves generated images as PNG
regardless of the temporary path, so this is always correct and no longer
depends on the extension of the destination the core happens to allocate.

diffusers is the reported backend (#10727); vllm-omni and tinygrad carry
the identical latent crash for any .tmp staging destination.

Closes #10727


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 21:24:58 +00:00
LocalAI [bot]
cd65a1f645 fix(transcription): honor model-config language/translate + OpenAI language form field (#10731)
fix(transcription): honor model-config language/translate, add language form field

The /v1/audio/transcriptions endpoint read only input.Language /
input.Translate from the parsed request, and the request middleware never
populates those from a multipart upload -- nor did it read a `language`
form field. As a result the model config's parameters.language /
parameters.translate (a valid PredictionOptions field under `parameters:`)
were silently ignored, and multilingual models like canary defaulted to
translating into English even when the YAML set language: ru,
translate: false (#10655).

Resolve both with clear precedence: the request form field wins, then any
language on the parsed request, then the model config default. This also
makes the endpoint honor OpenAI's `language` form parameter, which was
not read before.

Applies to both the streaming and non-streaming paths (the resolved
values are built into the shared TranscriptionRequest). Note this ensures
the language/translate flags reach the backend; whether a given engine
acts on them is up to the backend.

Closes #10655


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 21:16:25 +00:00
LocalAI [bot]
97175f4b5a feat(model): debounce model loads after a failure to stop retry-storms (#10728)
A client that keeps polling a model whose load fails (e.g. a backend that
crashes deterministically on init) triggered a fresh backend start on
every request: request -> load -> crash in ~10s -> 500, repeat on the
next poll. Each attempt could leak GPU/CUDA state, and under
LOCALAI_SINGLE_ACTIVE_BACKEND it kept stealing the active slot from
healthy models. The existing loading-coalesce map only dedups
*concurrent* loads, so sequential polls were never covered.

Track load failures per modelID in ModelLoader. After a load fails,
refuse fresh load triggers for that model until a cooldown elapses,
returning a typed ModelLoadCooldownError that the HTTP layer maps to 503
with a Retry-After header. The cooldown grows exponentially per
consecutive failure (base, doubling, capped at 5m) and resets on a
successful load. The coalesced follower-retry of an in-flight burst
bypasses the gate, so a genuinely concurrent burst still gets its one
retry -- only new, independent triggers are refused, matching the
report's "refuse new load-triggers" wording.

Configurable via --model-load-failure-cooldown /
LOCALAI_MODEL_LOAD_FAILURE_COOLDOWN (default 10s, 0 disables), plumbed
through ApplicationConfig and applied unconditionally at startup.

Closes #10719


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 20:55:24 +00:00
LocalAI [bot]
1d5139f0a0 fix(gallery): make backend (re)install a clean replace instead of an overlay (#10726)
InstallBackend extracted the artifact directly into the target directory
with no pre-clean, so a reinstall overlaid the new files onto the old
ones. Files present in a previous version but absent in the new artifact
(a stale .so, an orphaned package dir) survived and could shadow the new
build at import time -- e.g. an old vllm shared object lingering next to
a freshly pulled one. Only a failed download cleaned the directory.

Stage the download/extraction into a `<name>.install-tmp` dir, validate
run.sh is present, write metadata, then atomically swap it into place
(rename current -> .install-backup, staging -> current, drop backup),
rolling back on failure. This mirrors the atomic swap UpgradeBackend
already performs, so install and upgrade now leave identical on-disk
state with no orphaned files.

Reported as part of #10720.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 20:23:13 +00:00
LocalAI [bot]
8565febe45 fix(vllm): pin L4T arm64 backend to vllm==0.24.0 for GB10 stability (#10725)
The nvidia-l4t-cuda-13-arm64 vLLM backend left `vllm` unpinned, so the
prebuilt image drifted onto whatever aarch64 wheel was latest at build
time (0.23.x). On GB10 / DGX Spark (Grace Blackwell, unified memory),
0.23 crashes deterministically during cold model loads with an empty
"Engine core initialization failed" set and pins GPU memory until a host
reboot.

vLLM 0.24.0 carries vllm-project/vllm#45179 ("release cached device
memory under pressure on UMA GPUs during weight loading"), which the
reporter verified fixes the crash on GB10. Pin the L4T requirements to
0.24.0 to match the already-pinned cublas13 build
(requirements-cublas13-after.txt) and keep the image deterministic.

Editing this file also re-triggers the single-arch L4T image build via
the path filter, republishing the gallery image with 0.24.0 (the
single-arch matrix builds again after #10703).

Closes #10722


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-07 20:18:23 +00:00
Roman Mazurenko
a3fdfbc0d1 feat(llama-cpp): add device selection option (#10724)
Allow llama.cpp model configs to select the backend devices used for offload, matching upstream --device behavior so users can exclude a display or debug GPU.

Signed-off-by: rvmzes <rvmzes@rvmzess-MacBook-Pro.local>
Co-authored-by: rvmzes <rvmzes@rvmzess-MacBook-Pro.local>
2026-07-07 20:09:05 +00:00
Tai An
2f33cc7bc4 fix(vram): report largest GGUF quant instead of whole HF repo for gallery size (#10700) (#10707)
* fix(vram): report largest GGUF quant, not whole repo, for HF gallery size (#10700)

Signed-off-by: Tai An <antai12232931@outlook.com>

* test(vram): cover multi-GGUF quant repo size estimation (#10700)

Signed-off-by: Tai An <antai12232931@outlook.com>

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-07-07 11:50:27 +00:00
LocalAI [bot]
22225217e0 docs: ⬆️ update docs version mudler/LocalAI (#10709)
⬆️ Update docs version mudler/LocalAI

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 08:55:01 +02:00
LocalAI [bot]
c1fd12a506 chore: ⬆️ Update ggml-org/llama.cpp to f36e5c348bc8795c34f9a038e58876e7a8423d4d (#10710)
⬆️ 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-07-07 08:54:33 +02:00
LocalAI [bot]
d01b2c4f46 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to 0725d2e53b7d2749a99ff33d3a460b954ffa7805 (#10711)
⬆️ Update ServeurpersoCom/qwentts.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-07-07 08:41:46 +02:00
LocalAI [bot]
fb9ff061f1 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260706123649 (#10712)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-07 08:41:34 +02:00
LocalAI [bot]
40f847745e chore: ⬆️ Update ikawrakow/ik_llama.cpp to a8cf53fd69bada5450bd653eb0e32d1113fbd7fe (#10713)
⬆️ 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-07-07 08:41:21 +02:00
LocalAI [bot]
ba9327b9f8 chore: ⬆️ Update CrispStrobe/CrispASR to 0a7643f1006c6bf2d1f37f5c63e9726f5eb4f364 (#10716)
⬆️ 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-07-07 08:41:00 +02:00
LocalAI [bot]
fd467c5b3b chore: ⬆️ Update leejet/stable-diffusion.cpp to bb84971129d2a094ab8051c6feed5406d3b4409d (#10684)
* ⬆️ Update leejet/stable-diffusion.cpp

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

* fix(stablediffusion-ggml): pass chroma knobs via model_args after upstream API change

Upstream stable-diffusion.cpp bb849711 removed the dedicated
chroma_use_dit_mask / chroma_use_t5_mask / chroma_t5_mask_pad fields from
sd_ctx_params_t and now reads them from the generic model_args key=value
spec (parse_key_value_args). Assigning the old struct members broke the
gosd.cpp build. Emit the three options into model_args instead so the
existing chroma controls keep working. Verified by building
libgosd-fallback.so against the pinned upstream commit.

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

---------

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-07-06 23:57:50 +00:00
dependabot[bot]
fa0622604a chore(deps): bump actions/cache from 4 to 6 (#10704)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 21:39:37 +02:00
Dennis Huang
ff5758113b chore(model gallery): add MiniCPM series models (#10699)
Add 9 MiniCPM models to the gallery:
- MiniCPM-V 4.6 (1.3B multimodal, edge-optimized)
- MiniCPM-V 4.6 Thinking (1.3B multimodal with reasoning)
- MiniCPM-V 4 (multimodal)
- MiniCPM-o 4.5 (8B omni-modal, vision+speech)
- MiniCPM-o 2.6 (7.6B omni-modal)
- MiniCPM5-1B (text)
- MiniCPM4.1-8B (text)
- MiniCPM4-8B (text)
- MiniCPM3-4B (text)

All sha256 checksums sourced from HuggingFace LFS metadata.

Signed-off-by: Dennis Huang <huangsiyuan20060408@hotmail.com>
2026-07-06 21:39:12 +02:00
LocalAI [bot]
29db4ab414 fix(ci): shard single-arch backend matrix under GitHub's 256-job limit (#10703)
GitHub Actions refuses to instantiate a matrix that would generate more
than 256 jobs. It does so silently: the job hangs forever at "Waiting for
pending jobs" and the whole run is marked `failure` while every other job
stays green. This is exactly what happened on the v4.6.1 tag build
(run 28786533892): the single-arch build matrix had grown to 268 entries,
so `backend-jobs-singlearch` (and its downstream merge) never produced a
single job, and the release build "failed" with no failing job to point at.

The single-arch list is the one that grows unbounded as backends are added,
so shard it across a fixed number of matrix jobs (SINGLEARCH_SHARDS=4,
~67 entries each today, headroom to ~1020 backends). Each merge shard
`needs:` only its matching build shard, preserving the "merge waits only on
its own build" property that keeps slow CUDA/ROCm builds from gating
multi-arch manifest assembly.

changed-backends.js now emits per-shard matrix/has-* outputs and throws
loudly if a shard ever reaches the 256 limit (telling the maintainer to
bump SINGLEARCH_SHARDS and add matching job blocks) instead of letting
GitHub drop the overflow silently. backend.yml and backend_pr.yml define
the four build + four merge shard jobs; multi-arch and darwin groups are
untouched.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-06 21:38:12 +02:00
weifanglab
a6cf67cc6b refactor: use slices.Contains to simplify code (#10702)
Signed-off-by: weifanglab <weifanglab@outlook.com>
2026-07-06 19:33:28 +02:00
LocalAI [bot]
85f5267ed2 fix(llama-cpp): cap single-pass embedding batch to fit VRAM (#10695)
* fix(llama-cpp): cap single-pass embedding batch to fit VRAM

Embedding/score/rerank all decode or pool the whole input in one physical
batch, so EffectiveBatchSize sized the batch to the full context window. For
a large context that makes n_ubatch huge, and the per-device CUDA compute
buffer (forward-graph scratch, ~n_ubatch * n_ctx, NOT split across GPUs)
balloons into multi-GiB: a large-context embedding model then aborts on load
(exitCode=-1) even with plenty of free VRAM. Reproduced with qwen3-embedding-4b
(context 40960 -> n_batch 40960 -> abort) and qwen3-embedding-0.6b
(n_batch 8192); pinning batch:512 avoided it.

This is the same root cause as issue #10485 (a large context turns the batch
into multi-GiB of scratch that must fit on a SINGLE card), but the single-pass
path bypassed the VRAM headroom guard the config layer already had — it
returned the unbounded context as the batch with no GPU awareness.

Make the single-pass batch VRAM-aware: cap it to the largest batch whose
compute buffer fits the per-device VRAM headroom, clamped to
[DefaultPhysicalBatch, ctx], reusing the existing computeBufferBytesPerCell and
headroom-divisor math (no duplication). Unknown per-device VRAM (0) stays
conservative (DefaultPhysicalBatch, not the context) so a detection gap can't
OOM. The GPU is resolved through an injectable package var (config.LocalGPU,
backed by sync.Once-cached xsysinfo detection) so the per-request router call
stays cheap and tests inject a deterministic device. Explicit batch: still
wins. An input longer than the cap can no longer be pooled in one pass — the
accepted tradeoff, since a batch that OOMs the device processes nothing.

Assisted-by: Claude:claude-opus-4-8 golangci-lint go-test
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(config): single-pass batch follows context on unknown VRAM

The single-pass (embedding/score/rerank) batch cap must only shrink the batch
when the per-device VRAM ceiling is KNOWN. On unknown VRAM (CPU-only or a GPU
detection gap) SinglePassBatchForContext returned DefaultPhysicalBatch, which
under-sized the batch below the context — over-trimming score/embed/rerank
inputs (the modelTokenTrim middleware regression) with no OOM benefit on CPU
where the compute buffer lives in system RAM. Return the full context instead,
preserving the original single-pass behavior; the VRAM cap stays a downward
safety that only engages when VRAM is known.

Assisted-by: Claude:claude-opus-4-8 [go-test go-vet]
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-07-06 12:56:09 +02:00
LocalAI [bot]
ed3b59baf1 fix(config): cap auto-derived context to fit VRAM (#10696)
When a model is imported without an explicit context_size, the GGUF
importer defaulted the model's context to its full trained window
(n_ctx_train). For long-context models (128k / 256k / 1M) that KV cache
cannot fit a consumer GPU, so the backend aborts on load (exitCode=-1)
even though the model file is perfectly fine. Reproduced live:
gemma-4-26b-a4b-it-qat-q4_0 defaulted to context=262144 and
qwythos-9b-claude-mythos-5-1m to 1048576, both aborting on a 20 GB card.

Instead of chasing the trained max, auto-derive a conservative default:
min(trainedMax, DefaultAutoContextSize=8192). A small model keeps its
trained window; a long-context model caps at 8k and users opt into more
via context_size. This cap applies always, including CPU / unknown-VRAM
hosts, so it never regresses those paths.

Per-device VRAM is used only as a DOWNWARD safety: when a per-device
ceiling is detected (xsysinfo.MinPerGPUVRAM) and even the 8k cap would
not fit it with headroom, step down through candidate contexts to the
largest that fits, floored at DefaultContextSize. When VRAM is unknown
(0) or no GPU is detected we do NOT clamp — the bug is GPU OOM and the
8k cap is already safe, so detection gaps must not shrink the window.

The footprint estimate reuses gpustack/gguf-parser-go's
EstimateLLaMACppRun at a given context with all layers offloaded, taking
the per-device NonUMA VRAM figure. The estimate and VRAM detection are
package vars so tests inject deterministic values. Explicit context_size
always wins (guessGGUFFromFile only acts when it is nil).

Assisted-by: Claude:claude-opus-4-8 [golangci-lint go-test]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-06 12:53:45 +02:00
LocalAI [bot]
461ae84732 fix(startup): scope generated-content and upload dirs to the current user (#10698)
The `--generated-content-path` and `--upload-path` defaults were the fixed
shared locations `/tmp/generated/content` and `/tmp/localai/upload`. On any
multi-user host these collide across accounts: macOS routes `/tmp` to the
shared `/private/tmp` for every user, so whichever account starts LocalAI
first creates the parent with 0750 perms and every other account then fails
startup with:

    unable to create ImageDir: "mkdir /tmp/generated/content: permission denied"
    unable to create UploadDir: "mkdir /tmp/localai/upload: permission denied"

The same happens on Linux once a stale root-owned `/tmp/generated` (e.g. from
a prior `sudo` run) is left behind. This bites the desktop launcher and any
app embedding the raw binary (Wingman, nib-desktop), which start `local-ai
run` with no path flags.

Default both paths under the OS temp dir (`os.TempDir()`, honoring `$TMPDIR`;
already per-user on macOS) namespaced by the current UID
(`TMPDIR/localai-<uid>/...`), so accounts never collide while the paths stay
ephemeral. Wired via new kong vars in main.go so every consumer of the raw
binary inherits the fix. All content subdirs (audio, images) derive from
`GeneratedContentDir`, so they are fixed transitively.

As defense in depth, the launcher also anchors these two paths under its own
per-user data directory (mirroring the #10610 fix for data/config), extracted
into a testable `BuildRunArgs`.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-06 12:53:25 +02:00
Tai An
2a4426c5ec fix(reasoning): don't persist request-scoped reasoning_effort as an operator disable (#10622) (#10623)
* fix(reasoning): don't persist request-scoped reasoning_effort into model config

When a model sets `reasoning_effort: none` (or any default) in its YAML
without an explicit `reasoning.disable`, ApplyReasoningEffort resolves that
default at request time and sets ReasoningConfig.DisableReasoning on the
request-scoped config copy. The post-load thinking/marker probe then wrote
that request-scoped value back into the loader's persistent config via
UpdateModelConfig, making it look as though the operator had explicitly set
reasoning.disable=true. From then on, per-request `reasoning_effort` overrides
were silently ignored (an explicit operator disable wins over a request
asking to think).

DetectThinkingSupportFromBackend only fills reasoning slots that are still
nil, so a slot already set here came from ApplyReasoningEffort, not the probe.
Snapshot which slots were nil before the probe and only persist those, so the
probe's genuine backend detection is still saved while request-time reasoning
effort never leaks into the persistent config.

Fixes #10622

Signed-off-by: Tai An <antai12232931@outlook.com>

* test(reasoning): cover persist-guard added in this PR, extract for testability

ModelInference's post-probe persistence of ReasoningConfig.DisableReasoning /
DisableReasoningTagPrefill had no test: the guard logic lived inline in a
closure only reachable through a live gRPC backend. Extract it into
persistProbedReasoning (pure refactor, no behavior change) so it can be
exercised directly against a ModelConfigLoader, then add specs covering:

- a probe-filled slot (nil beforehand) gets persisted
- a slot that already carried a request-scoped value (e.g. from
  reasoning_effort: none) is left alone, i.e. the #10622 regression stays
  fixed
- an operator's explicit persisted disable is preserved when the guard is
  false
- the media marker still persists unconditionally

Verified red/green: reverting persistProbedReasoning to the old unconditional
copy fails exactly the two guard specs.

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

* test(reasoning): ignore os.Remove error in temp file cleanup (errcheck)

Signed-off-by: Tai An <antai12232931@outlook.com>

* chore: empty commit to re-trigger flaky Agent Jobs CI test

Signed-off-by: Tai An <antai12232931@outlook.com>

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-07-06 09:23:10 +02:00
LocalAI [bot]
2348bdc16d chore: ⬆️ Update ggml-org/llama.cpp to 2da668617612d2df773f966e3b0ee22dc2beef7b (#10694)
⬆️ 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-07-06 01:46:47 +02:00
walcz-de
2ccc67bc7f feat(agents): native Prometheus metrics for agent chat runs (#10689)
Operators need a scrape-friendly signal for agent-turn health (completing,
erroring, cancelled, duration) — log-derived counters proved brittle (ANSI/
timezone parsing, restart gaps). Adds localai_agent_runs_total{agent,outcome}
and localai_agent_run_seconds histogram, recorded at the Chat() response
handoff (single choke point of the local execution path). Lazy meter init,
same pattern as the PII events counter (#10641).

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-07-06 01:06:15 +02:00
LocalAI [bot]
0a6c62bb59 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to 73fe0c67bbf0898ba2999535e0680a02a7f8537d (#10683)
⬆️ Update ServeurpersoCom/qwentts.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-07-06 01:05:43 +02:00
LocalAI [bot]
1297356e29 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to daedb763fd442e0916eb130a479fdd74947291c0 (#10682)
⬆️ 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-07-06 01:05:25 +02:00
LocalAI [bot]
3f36b1dbed chore: ⬆️ Update CrispStrobe/CrispASR to 09df654e304947f7521e1f52992ceacccf03c300 (#10693)
⬆️ 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-07-06 00:32:28 +02:00
LocalAI [bot]
783222baf4 docs: ⬆️ update docs version mudler/LocalAI (#10680)
⬆️ Update docs version mudler/LocalAI

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 00:32:00 +02:00
LocalAI [bot]
bd3f2588fd fix(ui): center the home empty-state wizard (#10691)
The no-models getting-started wizard (`.home-wizard`) rendered
left-aligned instead of centered. `.home-page` is a column flexbox with
the default `align-items: stretch`; a child with `max-width: 48rem`
cannot be stretched past its max-width, so it falls back to the
cross-start (left) edge. The populated home branch never exposed this
because its children are full-width.

Add `margin: 0 auto` to `.home-wizard` so the max-width block centers
horizontally, for both the admin getting-started wizard and the
non-admin no-models hero.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-05 13:12:09 +02:00
LocalAI [bot]
40e659974d chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260704102955 (#10668)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 10:20:19 +02:00
LocalAI [bot]
deb43e56c0 chore(model-gallery): ⬆️ update checksum (#10686)
⬆️ 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-07-05 10:20:02 +02:00
LocalAI [bot]
33869da527 chore: ⬆️ Update ggml-org/llama.cpp to 665892536dfb1b7532161e3182304bd35c33e768 (#10681)
⬆️ 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-07-05 10:19:36 +02:00
LocalAI [bot]
8059117c2d chore: ⬆️ Update CrispStrobe/CrispASR to 1109cb3fcae2e242c2b3d42ec0e3fd6e813f2ce7 (#10685)
⬆️ 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-07-05 09:29:18 +02:00
LocalAI [bot]
b0959d4756 feat(api): add GET /v1/models/capabilities endpoint (#10687)
Additive superset of /v1/models that enriches each model entry with the
capabilities it supports plus its input/output modalities
(text / image / audio / video). Clients that only understand /v1/models
are unaffected -- they simply never call the new route.

Audio and video *input* are derived from the model's multimodal limits
(vLLM limit_mm_per_prompt), which no single usecase FLAG expresses. That
gap is exactly why a plain capability list is insufficient and this
enriched endpoint exists: an attachment router can now decide whether an
image/audio/video file can go to the active model directly, or must be
converted/transcribed first.

Capability derivation lives in core/config as the single source of truth
(ModelConfig.Capabilities / InputModalities / OutputModalities /
VisionSupported / ...); the Ollama capability surface now delegates to
it instead of keeping a parallel copy. Vision is gated on
chat/completion capability so a MediaMarker hydrated onto a non-chat
model (e.g. a pure ASR/TTS backend) no longer reports a false vision
capability.

Read-only listing: no new FLAG_* flag, reuses the existing `models`
swagger tag, and intentionally exposes no MCP admin tool (there is
nothing to manage conversationally).

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-05 08:51:55 +02:00
LocalAI [bot]
9e41be4bfb fix(auth): log the real cause of OIDC/OAuth user-info failures (#10679)
The OAuth callback discarded the error returned by user-info resolution
before sending the generic 500, so real failures were completely opaque
in the logs: ID-token verification errors (e.g. issuer/audience mismatch
behind a reverse proxy), a missing id_token, claim-parse errors, or a
rejecting GitHub userinfo endpoint all collapsed into
"failed to fetch user info" with nothing logged.

Log the wrapped cause with xlog.Error (provider + error), matching the
code-exchange step just above it. The client-facing message is unchanged,
so no internal detail leaks to the browser.

Refs #10677


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-04 19:33:53 +02:00
LocalAI [bot]
38350d363e fix(backends): enable ROCm/HIP GPU offload for ggml audio backends (#10666) (#10667)
qwen3-tts-cpp, omnivoice-cpp, acestep-cpp and vibevoice-cpp shipped
rocm-* variants that silently ran on CPU ([Load] backend: CPU). Two
coupled defects:

- The Makefiles passed -DGGML_HIPBLAS=ON, but the vendored ggml only
  understands -DGGML_HIP=ON (GGML_HIPBLAS was removed upstream), so the
  ggml-hip backend target was never created and no GPU code was built.
- The CMake foreach that links the ggml GPU backends into the module
  listed blas/cuda/metal/vulkan but not hip, so even a built ggml-hip
  would not have been linked and its static backend registration would
  never run.

CUDA users were unaffected because cublas passes the correct GGML_CUDA=ON
and the foreach already links cuda. Mirror the proven llama-cpp hipblas
block (ROCm clang CC/CXX + AMDGPU_TARGETS) and add hip to each foreach.
Upstream picks the best device via ggml_backend_init_best(), so no
runtime flag is needed once HIP is compiled and linked.


Assisted-by: Claude:claude-opus-4-8[1m] [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-04 09:08:20 +02:00
LocalAI [bot]
817136c20e chore: ⬆️ Update CrispStrobe/CrispASR to f35185b876fc482fcb2053a81a2697936ed5fcc0 (#10670)
⬆️ 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-07-04 08:17:02 +02:00
LocalAI [bot]
8396ce1388 chore: ⬆️ Update ggml-org/llama.cpp to d4cff114c0084f1fbc9b4c62717eca8fb2ae494a (#10671)
⬆️ 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-07-04 08:16:41 +02:00
LocalAI [bot]
348f3c87c0 fix(gpu-libs): bundle hipBLASLt TensileLibrary data so ROCm backends stop falling back (#10660) (#10672) the
The ROCm packager copied rocBLAS kernel data (rocblas/library/*.dat) into the
bundled lib/ dir and run.sh pointed ROCBLAS_TENSILE_LIBPATH at it, but the
parallel hipBLASLt data dir (hipblaslt/library/TensileLibrary_lazy_gfx*.dat)
was never packaged and no HIPBLASLT_TENSILE_LIBPATH was set. The bundled
libhipblaslt.so therefore resolved its per-arch kernel data relative to itself,
found nothing, and silently fell back to slow generic kernels, logging:

    rocblaslt error: Cannot read "TensileLibrary_lazy_gfx1201.dat": No such file or directory
    rocblaslt error: Could not load "TensileLibrary_lazy_gfx1201.dat"

Fix, mirroring the existing rocBLAS handling:
- package-gpu-libs.sh: extract the rocblas data-dir copy into a reusable
  copy_rocm_data_dir helper and call it for both rocblas and hipblaslt.
- llama-cpp/turboquant run.sh: export HIPBLASLT_TENSILE_LIBPATH when the
  bundled hipblaslt/library dir exists.

The helper takes an optional ROCM_BASE_DIRS override so the copy is unit
testable without a real ROCm install; add a regression test that runs
package_rocm_libs against a fabricated ROCm tree and asserts both data dirs
are bundled.

Note: this bundles whatever gfx*.dat the build image's ROCm provides. If a
given arch's tensile data is absent from the shipped ROCm, that arch still
needs a ROCm bump; the packaging gap itself is fixed for every supported arch.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-04 08:14:12 +02:00
LocalAI [bot]
13310905a3 chore: ⬆️ Update ikawrakow/ik_llama.cpp to bbc7de475178dd0535c16ad85f204a2529806c9d (#10669)
⬆️ 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-07-03 23:35:41 +02:00
LocalAI [bot]
2cbb3c96b3 fix(gallery): block SSRF in gallery config URL fetch (#10665) (#10673)
POST /models/apply with an empty "id" fetches the attacker-supplied
"url" gallery config directly via http.Client, with no check that the
URL resolves to a public IP. In the default Docker deployment no API key
is configured, so any network-reachable client can coerce LocalAI into
issuing requests to internal services or cloud-metadata endpoints (and
exfiltrate a small slice of the response through the job error message).

Guard the config fetch chokepoints (GetGalleryConfigFromURL and
GetGalleryConfigFromURLWithContext, which back both the /models/apply
worker and gallery installs) with utils.ValidateExternalURL, matching
the protection already applied to the CORS proxy and image/video/audio
download paths. Only plain http(s) URLs are validated; non-network
schemes (huggingface://, github:, oci://, ollama://, file://) resolve to
fixed public services or local files and are left untouched.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-03 21:32:42 +00:00
Ettore Di Giacinto
1152acc167 Revert "feat(config): default swa_full:true for sliding-window-attention models" (#10674)
Revert "feat(config): default swa_full:true for sliding-window-attention mode…"

This reverts commit 02b007a31e.
2026-07-03 22:46:44 +02:00
walcz-de
cc8ee62db0 feat(pii): export PII/audit events as a Prometheus counter (#10641)
The PII EventStore ring buffer is capacity-bound and meant for
recent-audit browsing via /api/pii/events; operators also want a
monotonic, scrape-friendly signal on /metrics — how many
detections/masks/blocks per hour, per origin, and whether the filter
stopped firing after a deploy (silent-failure class).

EventStore.Record is the single choke point every producer already goes
through (request middleware, response scrubbing, MITM proxy
connects/intercepts), so one lazily-initialised counter there covers all
paths without touching any producer:

  localai_pii_events_total{kind, origin, action, direction}

Same lazy otel.Meter pattern as core/services/routing/billing, so the
counter lands on the Prometheus-backed global MeterProvider installed by
the monitoring service. No behaviour change; label cardinality is
bounded (enum-like fields only, no pattern IDs or user IDs).

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: stefanwalcz <stefan.walcz@walcz.de>
2026-07-03 20:36:15 +00:00
LocalAI [bot]
bfd6c09d88 chore(model gallery): 🤖 add 1 new models via gallery agent (#10663)
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-07-03 18:02:09 +02:00
Richard Palethorpe
eb32cd9073 feat(realtime): eager blocking pipeline warm-up + /backend/load API (#10662)
Realtime sessions previously lazy-loaded each pipeline sub-model (VAD,
transcription, LLM, TTS) on first use, so every cold session paid a
per-request model-load stall and load errors only surfaced mid-stream.

Warm the whole pipeline eagerly and blockingly at session start
(including the voice-gate speaker-recognition model, which an enforced
gate blocks each utterance on; compaction's summary_model stays lazy
since it only runs off the response path):
- Add backend.PreloadModel / PreloadModelByName as the single load path
  for every modality (no transcription special-case; backend-omitted
  configs are deprecated).
- The realtime session blocks on Model.Warmup and returns a
  model_load_error to the client if any stage fails to load;
  updateSession warms in the background. Opt out per pipeline with
  pipeline.disable_warmup, exposed as a UI toggle via the
  config-metadata registry.

Add a LocalAI-native POST /backend/load (and /v1/backend/load) that
pre-loads a model -- expanding realtime pipelines into their sub-models
-- as the inverse of /backend/shutdown. There is one preload engine
(backend.PreloadStages): the realtime Warmup methods, /backend/load and
the --load-to-memory startup flag all use it, so --load-to-memory now
also expands pipeline models and records load-failure traces. Pipeline
sub-model alias resolution is likewise shared
(ModelConfigLoader.LoadResolvedModelConfig). Surface the endpoint
everywhere an admin manages models:
- MCP admin tool load_model (httpapi + inproc clients, safety/catalog
  prompts, catalog/dispatch tests).
- "Load into memory" action in the React models UI.
- Swagger regenerated; docs moved to the general backend-monitor page
  since it is not realtime-specific.

Fix a Traces UI crash ("json: unsupported value: -Inf"): audio-snippet
RMS/peak now floor at a finite dBFS, and backend-trace data is sanitized
to drop non-finite floats before marshaling. The sanitizer is
copy-on-write -- it runs on every RecordBackendTrace, so containers are
only re-allocated on the paths that actually changed.

Migrate core/http/openresponses_test.go onto the prebuilt mock-backend
the rest of the http suite already uses -- it was the last spec still
pointing at a real HuggingFace model, so it 404'd wherever no vision
backend was built -- and fix its item_reference specs to send the
spec's "id" field instead of "item_id", which the handler never
accepted.

Assisted-by: Claude:claude-opus-4-8 Claude Code

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-07-03 18:00:37 +02:00
alaningtrump
80ec22945a refactor: use the built-in max/min to simplify the code (#10657)
Signed-off-by: alaningtrump <alaningtrump@outlook.com>
2026-07-03 17:59:26 +02:00
LocalAI [bot]
7a3583b52c fix(python-backends): parse tool-call arguments for chat templates and split implicit reasoning blocks (#10658)
Two bugs broke OpenAI-style tool calling on the MLX backend (and any
Python backend sharing backend/python/common), reproduced end-to-end on
LocalAI v4.5.5 with the metal-mlx backend and
mlx-community/Qwen3.5-2B-MLX-8bit.

messages_to_dicts left each tool call's function.arguments as the raw
OpenAI-wire JSON string. HuggingFace chat templates (e.g. Qwen3.5)
iterate arguments as a mapping (.items()), so any request whose history
contained a prior assistant tool_calls message failed with HTTP 500
"Generation failed: Can only get item pairs from a mapping." — breaking
every agent loop on its second turn. Decode the string back into a dict
so the template sees a mapping.

split_reasoning returned ("", text) whenever the opening think tag was
absent. Models like Qwen3.5 open the assistant turn already inside
thinking, so the generated text carries only the closing </think>; the
whole chain-of-thought leaked into content. When the opener is missing
but the closer is present, treat everything before the closer as
reasoning.

Adds platform-independent unit tests under backend/python/common
(stdlib-only, no MLX/venv required, following parent_watch_test.py).

Assisted-by: Claude Code:claude-opus-4-8

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-03 12:13:37 +02:00
LocalAI [bot]
715d4ed8e5 chore: ⬆️ Update ggml-org/llama.cpp to fdb1db877c526ec90f668eca1b858da5dba85560 (#10647)
⬆️ 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-07-03 00:46:56 +02:00
LocalAI [bot]
9fcc9c0d43 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 87fc8701ff4da81a7d2a91ec0695f95eb3066a47 (#10649)
⬆️ 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-07-03 00:46:41 +02:00
LocalAI [bot]
3c67b5b746 chore: ⬆️ Update CrispStrobe/CrispASR to 9a26976a8c8cf5af0afcdd04463cf8ba91e96a54 (#10648)
⬆️ 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-07-03 00:46:25 +02:00
LocalAI [bot]
bea66fd84e chore: ⬆️ Update leejet/stable-diffusion.cpp to 2574f5936571645f784b77623e1f09bad97d948a (#10650)
⬆️ 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-07-03 00:46:10 +02:00
LocalAI [bot]
f7a5dfd5ae chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260701212152 (#10646)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 00:45:36 +02:00
LocalAI [bot]
6bcaf30c14 chore: ⬆️ Update localai-org/privacy-filter.cpp to 735a6c28607ee82afc3a670383f41b55266a3b9a (#10628)
⬆️ Update localai-org/privacy-filter.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-07-03 00:45:17 +02:00
LocalAI [bot]
ef15b4bfda fix(vllm): install ROCm vLLM from the AMD wheel index on Python 3.12 (#10651)
* fix(vllm): install ROCm vLLM from the AMD wheel index on Python 3.12

The rocm-vllm backend crashed at load with "No module named 'vllm'".
requirements-hipblas-after.txt requested a bare `vllm`, which resolves to
the CUDA-only PyPI wheel; that wheel is unusable on an AMD GPU. vLLM's
prebuilt ROCm wheels live on a dedicated index (https://wheels.vllm.ai/rocm/)
and are published only for CPython 3.12, so on the backend's default 3.10
the installer silently falls back to the CUDA wheel.

Add a hipblas branch to backend/python/vllm/install.sh that pins Python to
3.12 and installs vllm from the ROCm wheel index, hiding the bare-`vllm`
after-file so installRequirements installs only the base ROCm
torch/transformers first and does not pull the CUDA wheel.

Fixes #10642

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

* chore(vllm): drop the dead hipblas-after requirement and its hide dance

requirements-hipblas-after.txt (a bare `vllm`) is never installed for
hipblas: installRequirements only adds requirements-${BUILD_PROFILE}-after.txt
when BUILD_TYPE != BUILD_PROFILE, and for hipblas they are equal. So the file
was dead and the install.sh hide/restore of it was a no-op. Remove both. The
hipblas branch already installs vllm explicitly from the ROCm wheel index, so
deleting the bare-`vllm` file also removes a latent CUDA-wheel trap should the
installRequirements gap ever be closed.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-03 00:44:55 +02:00
LocalAI [bot]
237bce48e8 feat(ui): forking chat - retry any answer, copy, duplicate, branch (#10645) (#10654)
* feat(ui): clone a chat into a new conversation (#10645)

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

* feat(ui): retry any assistant answer, not just the last (#10645)

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

* feat(ui): copy an entire chat to the clipboard (#10645)

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

* feat(ui): branch a new chat from any assistant answer (#10645)

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

* fix(ui): send truncated history on mid-conversation retry (#10645)

Mid-conversation retry regenerated an answer with the downstream turns
still in the model's context. handleRegenerate truncated the DOM history
via updateChatSettings (a scheduled state update), but the synchronous
sendMessage that followed read the stale, pre-truncation history from its
closure to build the outbound API payload. Thread the intended base
history explicitly through sendMessage's options.baseHistory so the
request body matches the truncated view. Backward compatible: the normal
send path (no baseHistory) is unchanged.

Also guard two minor issues in Chat.jsx: the "Branch from here" button now
renders under !isStreaming to match the retry button, and the duplicate
toast only fires when forkChat returns a chat (not on a null result).

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-03 00:04:44 +02:00
LocalAI [bot]
a4e6e01e4d fix(process): give backend workers a parent-death safety net (#10639)
* fix(grpc): self-terminate backend workers when LocalAI dies non-gracefully

Symptom: a backend model-worker subprocess (the per-model gRPC server LocalAI
spawns) can be orphaned and linger — holding VRAM and its listen port — if the
LocalAI process is killed non-gracefully (e.g. a supervisor's graceful-shutdown
grace period elapses and LocalAI is SIGKILLed) before its own teardown runs.

Root cause: LocalAI's graceful teardown (pkg/signals/handler.go installs the
SIGINT/SIGTERM handler; core/cli/run.go registers app.Shutdown ->
ModelLoader.StopAllGRPC -> process.Stop in pkg/model/process.go) only runs when
LocalAI receives a catchable signal and survives long enough to run its
handlers. Backends are spawned via github.com/mudler/go-processmanager v0.1.1,
whose getSysProcAttr() sets Setpgid:true (own process group, so the group can be
signalled) but never PR_SET_PDEATHSIG/Pdeathsig, and exposes no Config field or
option for a caller to inject/extend SysProcAttr. LocalAI fully delegates
spawning to that library (it never builds the exec.Cmd itself), so it cannot set
a kernel parent-death signal at the spawn site. If LocalAI is SIGKILLed, nothing
tells the backend to exit and it is reparented to init.

Fix: add a best-effort, backend-side safety net at the one shared choke point
every out-of-process Go backend routes through — grpc.StartServer / RunServer in
pkg/grpc. On startup it captures getppid() and polls; when the process is
reparented (getppid changes / becomes 1 — the standard POSIX signal the original
parent died) it logs and self-terminates. getppid() reparent detection is
portable (Linux + macOS), unlike Linux-only PR_SET_PDEATHSIG. Toggle via
LOCALAI_BACKEND_PARENT_WATCH (default on; off on Windows) and
LOCALAI_BACKEND_PARENT_WATCH_INTERVAL. This is strictly a backstop alongside the
existing graceful SIGTERM->grace->SIGKILL teardown, which is unchanged.

Scope/limitations: covers Go-based backends (everything using pkg/grpc). The
C++ backends (e.g. llama-cpp) and Python backends do not route through
pkg/grpc and are not covered by this mechanism — they would each need an
equivalent parent-death check (follow-up). The fully general fix is for
go-processmanager to expose SysProcAttr injection so LocalAI can set Pdeathsig
at spawn for every backend regardless of language (suggested upstream follow-up;
out of scope for this LocalAI-only PR).

Test: pkg/grpc/parentwatch_test.go builds a real test -> middle -> grandchild
process tree, lets the middle process exit to orphan the grandchild running the
real watchParentDeath, and asserts it detects the reparent and self-terminates.
Unix-only (build-tagged), runs in CI (Linux).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(process): extend parent-death backstop to C++ and Python backends

The Go parent-death watcher (pkg/grpc/parentwatch.go, commit 772b435d5)
only protects backends that route through pkg/grpc. C++ and Python
backends don't, so the originally-reported case — the llama.cpp gRPC
worker surviving a non-graceful LocalAI death — was still uncovered.

Extend the same best-effort backstop to both languages, reusing the
exact mechanism and semantics:

- capture getppid() at startup, skip if already orphaned (<=1)
- a background thread polls getppid() and self-exits on reparenting
  (getppid() != orig || == 1), portable across Linux/macOS, no-op on
  Windows
- same env vars: LOCALAI_BACKEND_PARENT_WATCH (default on; falsy
  false/0/no/off disable) and LOCALAI_BACKEND_PARENT_WATCH_INTERVAL
  (default 2s; accepts Go-style durations like 500ms/2s/1m)

C++: implemented in backend/cpp/llama-cpp (the reported, most-used C++
backend) as a dependency-free header parent_watch.h, wired into
grpc-server.cpp's main() and copied at build time via prepare.sh. C++
backends have no shared server scaffolding, so other C++ backends
(ds4, ik-llama-cpp, privacy-filter, ...) are not yet covered and would
each need the same one-line include+call as follow-ups.

Python: implemented once in the shared common/parent_watch.py and armed
from common/grpc_auth.py's get_auth_interceptors() — the single helper
every one of the 35 Python backends invokes while building its gRPC
server — so all Python backends (and future ones) are covered with no
per-backend edits and no duplicated implementation.

Tests (real process-tree reparent detection, mirroring the Go test):
- backend/cpp/llama-cpp/parent_watch_test.cpp (via run-unit-tests.sh)
- backend/python/common/parent_watch_test.py (python -m unittest)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 19:16:48 +02:00
LocalAI [bot]
6eea3ef2ac fix(backends): make backend install ops idempotent unless forced (#10643)
* fix(backends): make backend install ops idempotent unless forced

POST /backends/apply hardcoded force=true through
LocalBackendManager.InstallBackend, so applying an already-installed
backend re-downloaded and re-extracted the whole artifact every time.
API clients that ensure a backend exists at startup paid a full OCI
image pull on every boot.

Backend install ops now default to non-forced — an installed, runnable
backend short-circuits (the orphaned-meta reinstall path in
InstallBackendFromGallery is preserved) — and reinstall stays available:

- ManagementOp gains a Force field; the local manager passes it through
  instead of hardcoding true.
- /backends/apply accepts an optional "force" boolean in the body.
- The React UI install route keeps forcing, since its button doubles as
  the explicit "Reinstall backend" action.

Distributed installs already behaved this way (workers skip when the
binary exists unless force is set); this aligns single-node behavior.

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

* fix(backends): don't force-reinstall LOCALAI_EXTERNAL_BACKENDS on boot

The startup loop for LOCALAI_EXTERNAL_BACKENDS runs
InstallExternalBackend for each listed backend on every boot, and its
gallery-name path hardcoded force=true — so every start re-downloaded
and re-extracted each listed backend's OCI image even when it was
installed and runnable. Supervising apps that list several backends
paid several full OCI pulls per launch.

Give InstallExternalBackend an explicit force parameter (it only
affects the gallery-name fallback; URI installs always write) and pass:

- false from the boot loop and `local-ai backends install` (idempotent
  ensure — `backends upgrade` is the refresh path),
- op.Force from the local manager's external-URI op,
- the request's force on the worker install path and true on its
  upgrade path (behavior unchanged).

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-02 19:16:29 +02:00
LocalAI [bot]
ad97bcbbdd chore(model gallery): 🤖 add 1 new models via gallery agent (#10644)
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-07-02 19:16:09 +02:00
walcz-de
9d8ff90941 fix(cloud-proxy): parameter compatibility with newest reasoning models (#10640)
Newest cloud reasoning models reject two parameters the cloud-proxy
backend currently sends:

- Anthropic (claude-opus-4-x) and OpenAI (gpt-5.x) return 400 when
  temperature is present: "'temperature' is deprecated for this model".
  OpenAI-compatible clients typically send only the server-side DEFAULT
  sampling values rather than user intent, so the translators now forward
  neither temperature nor top_p and let the upstream apply its own
  defaults.
- OpenAI gpt-5.x rejects max_tokens ("Unsupported parameter: 'max_tokens'
  ... Use 'max_completion_tokens' instead"). The OpenAI translator now
  serializes the token limit as max_completion_tokens, which current
  chat-completions models accept.

Verified live against claude-opus-4-8, gpt-5.5 and gemini-3.1-pro
(Gemini OpenAI-compat endpoint). Tests updated to the new contract.

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: stefanwalcz <stefan.walcz@walcz.de>
2026-07-02 19:15:43 +02:00
LocalAI [bot]
29001a88c1 fix(distributed): don't let a dead worker pin the model-load advisory lock (#10600)
* fix(distributed): don't let a dead worker pin the model-load advisory lock

In distributed mode a chat request could fail with:

  failed to route model with internal loader: routing model ...:
  loading model ...: advisorylock: acquiring lock <id>:
  ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)

Root cause is two independent defects in the cross-replica model-load path:

1. SmartRouter.Route holds a per-model PostgreSQL advisory lock for the whole
   cold-load sequence, which includes installBackendOnNode -> InstallBackend,
   a NATS request-reply with a 15m deadline (DefaultBackendInstallTimeout) that
   ignored ctx. When the chosen worker died mid-install, the holder sat on the
   lock for up to 15m. The detached loadCtx (WithoutCancel) had no deadline, so
   nothing capped the hold.

2. The acquiring statement, pg_advisory_lock(), is subject to any deployment
   global lock_timeout. A common operator setting (e.g. 10s) aborts the wait
   with SQLSTATE 55P03, so every other replica's request for that model hard
   -errored instead of waiting for the in-progress load and reusing it. For the
   ~15m window the model was effectively unroutable.

Fixes:

- advisorylock.WithLockCtx (postgres): SET lock_timeout = 0 on its dedicated
  connection (RESET before it returns to the pool) so the Go context, not a
  deployment-wide GUC, governs how long we wait. Waiters now block and then
  re-check, reusing the model another replica just loaded.

- SmartRouter: bound the detached loadCtx with a single ModelLoadCeiling so the
  lock is always released in bounded time even if a sub-step wedges. Default is
  the configured backend.install deadline + 10m (staging + LoadModel margin),
  so a legitimately slow load is never cut.

- installBackendOnNode: use singleflight.DoChan + select on ctx.Done() so the
  install wait honors cancellation; the ceiling can then actually free a caller
  pinned behind a dead worker. The shared install still coalesces via
  singleflight.

Reproduced both defects as failing tests first (a real 55P03 against a
testcontainer with a short lock_timeout; a wedged install that blocks Route)
and confirmed green.

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

* fix(distributed): bound advisory-lock wait instead of disabling lock_timeout

Setting lock_timeout = 0 to override a deployment's short global lock_timeout
meant "wait forever" server-side. Safe for SmartRouter.Route (its loadCtx now
carries the model-load ceiling) but unsafe for the schema-migration callers
that pass context.Background(): a holder whose session never releases would
hang them indefinitely.

Derive the server-side lock_timeout from the caller's context instead: its
remaining budget plus a margin (so the Go context's cancellation still wins
with a clean error and the server bound is only a backstop), or a finite
30m backstop when the context has no deadline. Never zero - "wait forever"
is no longer possible, while a deployment's hostile short lock_timeout is
still overridden so legitimate cross-replica waits don't fail with 55P03.

Added a spec proving a deadline-less waiter gives up at the (shrunk) backstop
rather than hanging.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-07-02 09:52:51 +02:00
LocalAI [bot]
b0bfa0852e chore: ⬆️ Update CrispStrobe/CrispASR to fcbc8718e654995e3bd2d0c98bcb8e55e297d23c (#10634)
⬆️ 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-07-02 09:48:20 +02:00
LocalAI [bot]
39a93e91cf chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260701132215 (#10633)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-02 09:48:08 +02:00
LocalAI [bot]
26e0c98967 chore: ⬆️ Update leejet/stable-diffusion.cpp to 3590aa8d626e671a1b1dc84506ea2932a243a480 (#10631)
⬆️ 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-07-02 09:47:54 +02:00
LocalAI [bot]
9acca54b25 chore: ⬆️ Update mudler/parakeet.cpp to e8acc6172a94e20a952cf1843decace5d771a94b (#10629)
⬆️ Update mudler/parakeet.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-02 09:47:41 +02:00
LocalAI [bot]
2728e6000e chore: ⬆️ Update ikawrakow/ik_llama.cpp to 068b173649f2fd8dc96b35ada5a0b76d8985105d (#10632)
⬆️ 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-07-02 09:47:28 +02:00
LocalAI [bot]
006310d746 chore: ⬆️ Update ggml-org/llama.cpp to 4fc4ec5541b243957ae5099edb67372f8f3b550e (#10630)
⬆️ 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-07-02 09:47:15 +02:00
LocalAI [bot]
05acdb1778 chore: ⬆️ Update ggml-org/whisper.cpp to 6fc7c33b4c3a2cec83e4b65abd5e96a890480375 (#10635)
⬆️ 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-07-02 09:47:01 +02:00
LocalAI [bot]
5e68b5700c chore(model-gallery): ⬆️ update checksum (#10637)
⬆️ 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-07-02 09:26:32 +02:00
pos-ei-don
7910018249 fix(vllm): non-streaming tool-call regression after #10351 (#10638)
fix(vllm): non-streaming tool-call regression after #10351 (native_streaming is a capability flag, not a state flag)

#10351 introduced native streaming via `parser.extract_tool_calls_streaming`
and gated the post-loop `extract_tool_calls` block on `native_streaming and
not native_streaming_error`. That works for streaming requests, but for
non-streaming requests the same flag is still True (it only means "the
parser can stream", not "we actually streamed"), so the block was skipped
and the `elif` cleared `content = ""` — the tool call was silently lost.

Symptom: non-streaming chat.completions with `tools=[...]` returns
`finish_reason: "stop"` with `content: ""` and no `tool_calls`. Streaming
requests are unaffected.

Fix: gate both branches on `streaming` too, so the extract_tool_calls
block runs for non-streaming requests (and for streaming requests that
fell back to the buffered path).

Reproduction (vLLM 0.24, Qwen3-Coder-Next-NVFP4, qwen3_coder parser):

    curl -s -X POST http://localhost:8080/v1/chat/completions \
      -H 'Content-Type: application/json' \
      -d '{"model":"coder","stream":false,
           "messages":[{"role":"user","content":"7*8 via calc"}],
           "tools":[{"type":"function","function":{"name":"calc",
             "parameters":{"type":"object",
               "properties":{"expression":{"type":"string"}}}}}]}'

Before: finish_reason: "stop", content: "", tool_calls: []
After:  finish_reason: "tool_calls", tool_calls[0].function.name: "calc"

Streaming path re-verified in the same setup: delta.tool_calls arrives
token-by-token, finish_reason: "tool_calls", no raw XML in content.

Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
2026-07-02 09:26:14 +02:00
LocalAI [bot]
1a03712a6f fix(hipblas): symlink amdgpu.ids so ROCm backends find the ASIC ID table (#10627)
* fix(hipblas): symlink amdgpu.ids so ROCm backends find the ASIC ID table

ROCm's bundled libdrm_amdgpu looks up the GPU ASIC ID table at a
hardcoded fallback path, /opt/amdgpu/share/libdrm/amdgpu.ids, which is
only populated by AMD's full amdgpu-install (graphics/DKMS) stack. The
hipblas image is compute-only and doesn't have it, so every model load
logs "No such file or directory" and the GPU can't be identified.
Symlink it to the equivalent file already shipped by Ubuntu's
libdrm-amdgpu1 package.

Fixes #10624

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

* fix(hipblas): correct amdgpu.ids source package name in comment

Verified against the real rocm/dev-ubuntu-24.04:7.2.1 image with
hipblas-dev/hipblaslt-dev/rocblas-dev installed: /usr/share/libdrm/amdgpu.ids
is owned by libdrm-common, not libdrm-amdgpu1 as the comment said.

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-07-02 09:25:14 +02:00
LocalAI [bot]
703ea32de6 chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260630095652 (#10616)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-01 21:56:59 +02:00
LocalAI [bot]
751db06e35 chore: ⬆️ Update CrispStrobe/CrispASR to 8fd9db8fec8cb5e929d23d3267ed5817794feb1a (#10615)
⬆️ 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-07-01 21:56:41 +02:00
LocalAI [bot]
f46c0e9c83 docs: ⬆️ update docs version mudler/LocalAI (#10614)
⬆️ Update docs version mudler/LocalAI

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-01 21:56:21 +02:00
LocalAI [bot]
0d8adfc59a chore: ⬆️ Update ggml-org/llama.cpp to 0eca4d490e591d4e93058d07540cf47278a72577 (#10617)
⬆️ 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-07-01 09:31:50 +02:00
LocalAI [bot]
43f2615e19 chore: ⬆️ Update vllm-project/vllm cu130 wheel to 0.24.0 (#10618)
⬆️ 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-07-01 08:53:03 +02:00
LocalAI [bot]
875c539ad5 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 29431b31c89e79c10f8736e8f2742485ba1713d6 (#10620)
⬆️ 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-07-01 08:52:36 +02:00
LocalAI [bot]
d641ded194 chore: ⬆️ Update ggml-org/whisper.cpp to 0874de3e8e8e48361dba85c7fe6d176f008bf158 (#10621)
⬆️ 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-07-01 08:43:40 +02:00
LocalAI [bot]
40445fff05 chore: ⬆️ Update leejet/stable-diffusion.cpp to 484baa41e5e006c52dcd4addc38c830b9489745f (#10619)
* ⬆️ Update leejet/stable-diffusion.cpp

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

* fix(stablediffusion-ggml): adapt to new generate_image() out-param signature

leejet/stable-diffusion.cpp@484baa4 changed generate_image() from
returning sd_image_t* to returning bool with images_out/num_images_out
out-parameters (same pattern already used by generate_video()).

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-07-01 08:32:57 +02:00
Tai An
057dee956a fix(launcher): keep data/config under ~/.localai (#10610) (#10613)
The launcher starts the server with run --models-path/--backends-path but
leaves --data-path and the dynamic config dir unset, so the server falls
back to its /data and /configuration defaults.
 is kong.ExpandPath("."), i.e. the launcher process CWD
(commonly the user's home root), producing ~/data and ~/configuration
outside ~/.localai and an agent-pool stateDir under ~/data.

Pass --data-path and --localai-config-dir explicitly, rooted at the
launcher's own data directory (GetDataPath() -> ~/.localai), so data and
config stay consistent with --models-path/--backends-path.
2026-06-30 22:14:59 +02:00
Adira
4ec39bb776 fix(watchdog): don't log optional Free() as an error when backend returns Unimplemented (#10602) (#10607)
* fix(watchdog): don't log optional Free() as an error when backend returns Unimplemented (#10602)

When the watchdog evicts a model, deleteProcess calls the backend's gRPC
Free() to release VRAM before stopping the process. Free is optional:
backends that don't override it -- the generated UnimplementedBackendServer
stub, many Python/external backends, or a federation proxy in distributed
mode -- return gRPC Unimplemented. That is expected, not a failure: VRAM is
reclaimed when the local process is stopped, or by the remote unloader for
remote backends. Logging it as "WARN Error freeing GPU resources" made a
benign, optional RPC look like a fault (the alarming line in #10602, seen
in distributed mode where the model is remote and Free hits a stub).

Treat gRPC Unimplemented from Free() as a no-op logged at Debug; genuine
failures still Warn. Free() is still attempted for every backend, so any
backend that does implement it is unaffected.

Add a reusable grpcerrors.IsUnimplemented helper following the package's
existing code-based detection idiom (prefer the typed status code, fall
back to the message across non-gRPC boundaries), with table tests.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>

* fix(watchdog): log a non-Unimplemented Free() failure at error level

Per review: now that the expected gRPC Unimplemented case is split out and
logged at Debug, any remaining Free() error is a genuine failure to release
VRAM, so surface it at error level instead of warn.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>

---------

Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
2026-06-30 22:14:01 +02:00
Ettore Di Giacinto
25ecb9f015 fix(gallery): use Q8_0 for lfm2.5-8b-a1b to fix poor tool-call quality
The Q4_K_M quant degraded tool-call reliability for LFM2.5-8B-A1B.
Switch the gallery entry to the Q8_0 GGUF (sha256 verified via HF
x-linked-etag) while keeping the native jinja tool-parsing config.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
2026-06-30 17:46:20 +00:00
LocalAI [bot]
2be495f9c0 fix(kokoros): implement AudioTranscriptionLive trait stub (#10612)
The backend.proto AudioTranscriptionLive bidirectional streaming RPC added
new required trait items (AudioTranscriptionLiveStream + audio_transcription_live)
on the generated Backend trait. The kokoros (TTS) backend did not implement
them, breaking its release build with E0046 (missing trait items).

kokoros is text-to-speech and has no live-ASR support, so stub the method to
return UNIMPLEMENTED, mirroring the existing audio_transcription_stream stub.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-30 19:38:41 +02:00
LocalAI [bot]
02b007a31e feat(config): default swa_full:true for sliding-window-attention models (#10611)
LocalAI enables a cross-request prompt-prefix cache (cache_reuse, see
core/config/serving_defaults.go) so repeated prefixes — system prompts,
RAG context, agent scaffolds, multi-turn chat — are not reprocessed every
turn. For sliding-window-attention (SWA) models (Gemma 2/3, Cohere2,
Llama 4, ...) this silently does nothing: llama.cpp defaults to a reduced
SWA KV cache sized to the sliding window, and that reduced cache cannot
preserve a prompt prefix across requests, so every turn reprocesses the
whole prompt anyway.

llama.cpp's --swa-full (params.swa_full, already wired through the
LocalAI llama.cpp backend's `swa_full` option) keeps the full KV cache so
the shared prefix is reused. Enable it automatically, but only for models
that are actually SWA: detection reads the gguf-parser-normalized
`<arch>.attention.sliding_window` metadata (which also applies llama.cpp's
family rules, e.g. Phi-3 → not SWA), right where the GGUF is already
parsed for defaults. It is never applied to dense models (pure memory
waste) and never overrides an explicit user `swa_full`/`n_swa` choice.

Tradeoff: the full SWA cache scales with context_size, so it costs more
memory at large contexts — hence the SWA gating and the documented
`swa_full:false` opt-out.

Assisted-by: Claude:claude-opus-4-8 [Claude Code] golangci-lint

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-30 17:58:17 +02:00
LocalAI [bot]
fd8cebd0b3 fix(watchdog): persist UI-saved Check Interval across restarts (#10601) (#10605)
fix(watchdog): persist a UI-saved Check Interval across restarts (#10601)

The watchdog Check Interval saved via /api/settings reverted to 500ms on
every restart, while the idle/busy timeouts persisted correctly.

Root cause: NewApplicationConfig baseline-defaulted WatchDogInterval to
500ms, whereas the idle/busy timeouts default to 0. The startup loader
(loadRuntimeSettingsFromFile) applies a persisted runtime_settings.json
value only when the field is still at its zero default - its heuristic
for "this wasn't set by an env var". Because the interval was always
500ms at that point, the loader never read the persisted value back, so
the saved interval was silently discarded on each boot.

Fix: drop the non-zero baseline default so the interval behaves like the
sibling timeouts (0 = unset). The effective 500ms default is now supplied
at the watchdog layer: WithWatchdogInterval ignores a non-positive value
so DefaultWatchDogOptions' 500ms is preserved (and a 0 interval can never
turn the watchdog loop into a busy spin). Also mirror the interval in the
live config file watcher alongside idle/busy, and report the real 500ms
default (not the stale "2s") from ToRuntimeSettings.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-30 17:48:14 +02:00
LocalAI [bot]
dd625921ff fix(macos): staple the notarization ticket to the .app, not just the dmg (#10606)
Stapling only the dmg leaves the LocalAI.app bundle with no embedded
notarization ticket. Gatekeeper then falls back to an online notarization
check on first launch, so the app fails to open on a Mac that is offline or
behind a firewall, or once it has been copied out of the dmg — while it keeps
working on the (online) build host, which masks the problem.

Notarize and staple the .app before packaging it into the dmg so the bundle
verifies offline. Adds a `notarize-app` subcommand to
contrib/macos/sign-and-notarize.sh (zips the bundle for notarytool, then
staples + validates) and invokes it from dmg-launcher-darwin. Stays a no-op
when notary secrets are unset, so unsigned local/fork builds are unaffected.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: mudler <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-30 17:38:47 +02:00
LocalAI [bot]
d74f88357e fix(tests): align openresponses test model name with GGUF-derived naming (#10589) (#10609)
PR #10589 changed repo-root HuggingFace URI imports to name the model after
the selected GGUF file rather than the repository. The Open Responses API
integration test still requested the old repo-derived name
("Qwen3-VL-2B-Instruct-GGUF"), so every request 404'd on an unknown model and
the suite has failed on master since 1a4f68ed4.

Update testModel to the name the importer now registers for the default
q4_k_m quant ("Qwen3-VL-2B-Instruct-Q4_K_M") so the specs resolve the model
again. The #10589 behaviour change is intentional; only the stale test needed
updating.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-30 15:41:44 +02:00
Adira
dfaec3bd51 fix(import): strip file:// scheme from model path for local imports (#10599)
Importing a model from a local directory (e.g. a HuggingFace checkout or an
LM Studio store) via a file:// URI produced a config whose model field kept
the scheme verbatim, e.g. model: file:///Users/u/.../Qwen3-4bit. The mlx and
vllm backends treat that field as a HuggingFace repo id or local path and
reject the file:// form with "Repo id must be in the form 'repo_name' or
'namespace/repo_name'", so the model imported fine but failed to load (issue
#7461).

Add a shared LocalModelPath helper that reduces a file:// URI to the bare
filesystem path it points at and leaves HuggingFace/HTTP URIs untouched, and
route the mlx, vllm, transformers and diffusers importers (all of which pass
details.URI straight into the model field for from_pretrained-style loading)
through it.

Cover the helper directly plus end-to-end file:// import specs for the mlx and
vllm importers.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
2026-06-30 10:21:08 +02:00
LocalAI [bot]
0e381897b5 chore: ⬆️ Update ikawrakow/ik_llama.cpp to f74a6fb87b315b2c3154166e075360e15021a61d (#10598)
⬆️ 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-06-30 09:17:48 +02:00
LocalAI [bot]
b1af37257d chore: ⬆️ Update CrispStrobe/CrispASR to 3b93758f9725d400eca82976f895e4cec3f31260 (#10597)
⬆️ 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-06-30 09:17:11 +02:00
LocalAI [bot]
ebefa6dcca chore: ⬆️ Update localai-org/privacy-filter.cpp to 595f59630c69d361b5196f2aba2c71c873d0c13c (#10596)
⬆️ Update localai-org/privacy-filter.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-06-30 09:16:52 +02:00
LocalAI [bot]
605348925d chore: ⬆️ Update ggml-org/llama.cpp to 6f4f53f2b7da54fcdbbecaaa734337c337ad6176 (#10595)
⬆️ 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-06-30 09:16:37 +02:00
LocalAI [bot]
686ce10b54 chore: ⬆️ Update leejet/stable-diffusion.cpp to 3b6c9ca97cfcda8e68e719e6670d06379fcbe943 (#10594)
⬆️ 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-06-30 09:16:21 +02:00
pos-ei-don
2cee318fad fix(functions): avoid quadratic-time debug logging in CleanupLLMResult / ParseFunctionCall (#10592)
fix(functions): avoid quadratic-time debug logging in CleanupLLMResult/ParseFunctionCall

The streaming chat path (core/http/endpoints/openai/chat_stream_workers.go)
calls CleanupLLMResult / ParseFunctionCall once per delta chunk with the
*full accumulated* LLM result so far. Both functions xlog.Debug the entire
argument on entry and exit, so a single N-chunk stream emits roughly
chunk_size * N^2 bytes of debug output.

Under LOG_LEVEL=debug this was observed in a recent SGLang-via-LocalAI
session on a DGX Spark host (about 50K tokens, long streaming generation)
to drive container logs to ~96 GiB, which interacted with the streaming
hot loop on the same filesystem and contributed to a host-wide hard hang
once disk pressure built up. Workaround was setting LOG_LEVEL=info, but
the quadratic shape remains a foot-gun for anyone intentionally enabling
debug.

Replace the four result-content debug arguments with len(...) plus a
fixed-size head (200 bytes via a new truncForLog helper), bounding per-
call output to a constant. The debug signal stays useful: the first 200
chars are enough to identify which generation is in flight, and the
length lets you observe growth without paying for the payload itself.

No API change. No behaviour change for LOG_LEVEL != debug.

Signed-off-by: Poseidon <philipp.wacker@ibf-solutions.com>
Co-authored-by: Poseidon <philipp.wacker@ibf-solutions.com>
2026-06-30 09:16:03 +02:00
Adira
1a4f68ed4a fix(import): derive model name from selected GGUF for repo-root URIs (#10589)
When importing a HuggingFace GGUF model from a repository-root URI (no file
component, e.g. hf://owner/repo) with the Model Name field left blank, the
importer named the model after the repository (filepath.Base(details.URI))
instead of the GGUF file it actually selected from the repo listing (issue
#10587).

Track whether the user supplied an explicit name; the URI base is now only a
fallback. In the HuggingFace branch, once the model group is picked, re-derive
the name from the selected GGUF via a new modelNameFromShardGroup helper that
uses ShardGroup.Base minus the .gguf extension. For sharded models this yields
a clean logical name (e.g. Qwen3-30B-A3B-Q4_K_M) rather than a shard filename
like ...-00001-of-00002. An explicit name preference still always wins, and the
.gguf/URL/OCI paths are unchanged.

Add network-free unit specs covering name-from-GGUF, clean-name-from-shard-base,
and explicit-name precedence, and update the live integration specs that had
encoded the previous repo-name behaviour.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
2026-06-30 09:03:27 +02:00
Adira
28d7397743 fix(openai): stop max_tokens streaming retry loop on reasoning models (#9716) (#10448)
fix(openai): stop max_tokens streaming retry loop on reasoning models

When a thinking model spends its entire max_tokens budget on the reasoning
block, the C++ autoparser clears the raw Response and delivers reasoning-only
ChatDeltas (no content, no tool calls). ComputeChoices' empty-response retry
then fires and regenerates from scratch up to maxRetries times, each
re-consuming the whole budget, instead of terminating with finish_reason
"length" (issue #9716).

Add a reachedTokenBudget helper and suppress both the built-in and
caller-driven retries when the completion count has reached the configured
max_tokens ceiling. Report finish_reason "length" instead of "stop" in the
streaming and non-streaming chat paths when the budget was exhausted.

Adds a deterministic regression test that counts backend invocations
(previously 6, now 1) plus boundary tests for the helper.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Dennisadira <dennisadira@gmail.com>
2026-06-30 09:01:53 +02:00
Richard Palethorpe
5d0c43ec6e feat(realtime): Semantic VAD EOU token (#10444)
* feat(realtime): EOU-driven semantic_vad turn detection

Add a `semantic_vad` turn-detection mode to the realtime API that feeds
the transcription model live and decides "the user finished speaking"
from the `<EOU>` end-of-utterance token rather than from silence alone.
When EOU fires the turn commits immediately (~0.3s); otherwise it falls
back to an eagerness-scaled silence threshold (low/med/high = 8/4/2s).

Plumbing, bottom to top:

- proto: `AudioTranscriptionLive` bidirectional RPC (config-first oneof,
  mono float PCM @16k, ready-ack / Unimplemented degrade signal) plus
  `TranscriptResult.eou` for the unary retranscribe gate.
- pkg/grpc: client/server/base/embed scaffolding for the bidi stream,
  modeled on AudioTransformStream; release stream conns on terminal Recv.
- parakeet-cpp: live transcription RPC with per-C-call engine locking
  (one live stream per turn, finalize+free at commit); bump parakeet.cpp
  to ABI v5 — incremental StreamingMel (no more quadratic per-feed mel
  recompute that delayed EOU on long turns) and the <EOU>/<EOB> split;
  strip the literal <EOU>/<EOB> from offline text and set Eou.
- core/backend: LiveTranscriptionSession wrapper + pipeline
  `turn_detection:` config block (type/eagerness/retranscribe).
- realtime: semantic_vad integration — live input captions streamed as
  transcription deltas while the user speaks, EOU-immediate commit with
  eagerness fallback, optional retranscribe gate (batch re-decode must
  also end in <EOU> to confirm), clause synthesis off the LLM token
  callback, and per-turn live-transcription / model_load telemetry.
- UI: show the realtime pipeline components as a vertical list.

Docs and tests included; opt-in via the pipeline YAML or per-session
`session.update`. Non-streaming STT backends degrade to silence-only.

Assisted-by: Claude Code:claude-opus-4-8 [Read] [Edit] [Write] [Bash]
Assisted-by: Claude Code:claude-fable-5 [Read] [Edit] [Bash]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(realtime): explicit formally-verified state machines + parakeet streaming driver

The realtime API had several implicit state machines whose state was inferred
from scattered booleans, channels, and five separate mutexes, leaving
illegal/inconsistent states reachable. Make them explicit and keep the
implementation in step with a formal design; rework the parakeet streaming
backend along the same lines.

Realtime state machines (M1-M5). Each is a sealed sum-type State/Event/Effect
with a total, pure Next(state,event)->(state,[]effect) behind a single-writer
Coordinator:

  M1 conncoord    connection lifecycle: VAD toggle + once-only teardown
                  (replaces vadServerStarted + a `done` channel closed from
                  two sites).
  M2 turncoord    turn detection: collapses speechStarted and the live-stream
                  "turn open" flag into one state, so discardTurn can no longer
                  desync them and suppress the next onset.
  M3 respcoord    response coordination: serializes the dual-writer
                  start/cancel so at most one response is live; one
                  response.done per response.create.
  M4 compactcoord conversation compaction: single-flight (replaces the
                  `compacting atomic.Bool` CAS).
  M5 ttscoord     TTS pipeline: open->closing->closed, idempotent wait(),
                  rejects enqueue-after-close (was a silent drop).

The Coordinator/Sink/Next plumbing — only the sealed types and Next differed
per machine — is extracted once into core/http/endpoints/openai/coordinator as
a generic Coordinator[S,E,F]; each machine keeps its public API via type
aliases, so no sink, call-site, or test moved.

Hierarchy. session_lifecycle.fizz models M1 as the parent region with its
children (M2/M3/M4) as one statechart and asserts ChildrenDieWithParent (conn
torn => all children terminal, none start after teardown). respcoord and
compactcoord gain an absorbing Terminated state + Shutdown event; conncoord's
teardown drives the children terminal. This closes a compaction teardown gap: a
fire-and-forget compaction could outlive a torn session — compactionSink now
takes a session-scoped cancellable context + WaitGroup and joins the in-flight
summarize+evict on shutdown.

Formal verification. formal-verification/ holds one authoritative FizzBee spec
per machine plus the composition spec, each with an always-assertion and a
documented one-line edit that makes the checker fail (verified non-vacuous).
scripts/realtime-conformance.sh is fail-closed: all Go conformance suites under
-race AND a model-check of every .fizz spec; a missing FizzBee is a hard error
(only the loud REALTIME_CONFORMANCE_SKIP_FIZZBEE=1 bypasses it, never in CI).
FizzBee is pinned by sha256 and installed via scripts/install-fizzbee.sh into
.tools/ (gitignored). Wired as make test-realtime-conformance, a CI workflow,
and a pre-commit path filter. Go conformance tests are Ginkgo/Gomega (per the
repo's forbidigo lint): transition tables + fixed-seed property walks +
concurrent/-race specs, no rapid dependency. Design map:
docs/design/realtime-state-machines.md.

Parakeet streaming backend. The same treatment applied to the parakeet-cpp
streaming paths:
- AudioTranscriptionStream returns codes.Unimplemented for non-streaming models
  instead of decoding offline and emitting it as one delta + final. A client
  that asked for streaming learns the model cannot stream rather than receiving
  a batch result shaped like a stream. New grpcerrors.StreamTranscriptionUnsupported
  carries that signal; the HTTP /v1/audio/transcriptions stream path surfaces it
  as an SSE error event. Mirrors AudioTranscriptionLive, which already did this.
- utteranceBoundary (boundary.go): a single definition of the end-of-utterance
  latch, replacing three open-coded finalEou toggles. Modelled as a two-valued
  type so illegal states are unrepresentable.
- Shared decode driver (driver.go): streamFeedResult (one per-feed event) +
  feedChunk (hides the ABI v4 JSON vs text-only split) + feedSlices + flushTail.
  The feed loop is written once.
- AudioTranscriptionLive becomes a bidi adapter: it streams the per-feed
  {delta,eou,eob,words} the realtime turn detector consumes and a terminal
  FinalResult carrying only Text. Segments/duration/eou are offline-only and no
  longer produced (nor read) on the live path; liveTraceState drops the terminal
  eou and keeps the per-feed eou_events count.
- AudioTranscriptionStream + streamJSON merge into one driver-based function;
  streamSegmenter is generalized to the unified event with a text-only fallback
  that preserves the legacy (no-words) library's per-utterance segmentation.

Verified: build/vet/gofumpt clean, golangci-lint 0 issues, all coordinator and
parakeet packages under -race, the fail-closed conformance gate green, and
make test-realtime (12 e2e WS+WebRTC).

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-06-30 09:01:22 +02:00
pos-ei-don
6ab29ec8b9 fix(sglang): parse tool_call function arguments before applying the chat template (#10558)
OpenAI wire format carries `function.arguments` as a JSON-encoded string,
but chat templates (e.g. Qwen3-Coder) iterate over it as a mapping. The
vllm backend already parses arguments before applying the chat template
(PR #10256); this mirrors that fix in the sglang backend.

Without this fix the second turn of any tool-using session (assistant
returns tool_calls, user posts `role:"tool"` result, model is invoked
with arguments still as a string) crashes inside transformers' Jinja
chat-template rendering with:

  TypeError: Can only get item pairs from a mapping.
  File ".../transformers/utils/chat_template_utils.py", in render_jinja_template
  File ".../jinja2/filters.py", in do_items
      raise TypeError("Can only get item pairs from a mapping.")

Reproduced on `lmsysorg/sglang:v0.5.14` via LocalAI v4.5.4 with
`saricles/Qwen3-Coder-Next-NVFP4-GB10` (W4A4 NVFP4 / compressed-tensors)
on NVIDIA DGX Spark (GB10, sm_121).

After the patch, a tool-call roundtrip (assistant tool_calls -> tool
result -> assistant final answer) returns http=200 with the expected
follow-up content; no behaviour change on requests that don't carry
tool_calls.

Signed-off-by: Poseidon <philipp.wacker@ibf-solutions.com>
Co-authored-by: Poseidon <philipp.wacker@ibf-solutions.com>
2026-06-30 09:00:51 +02:00
dependabot[bot]
036f950b1b chore(deps): bump actions/cache from 4 to 6 (#10593)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 22:31:10 +02:00
LocalAI [bot]
5b7b914b4f chore(recon): re-pin voice/face-detect to squashed release commits (+ graph-cache fix) (#10591)
chore(recon): re-pin voice/face-detect to squashed release commits

The voice-detect.cpp and face-detect.cpp engine repos were squashed to a single
release commit, which orphaned the previous pins (voice 3d51077, face 06914b0).
Re-pin to the new single-commit SHAs (voice 1db1759, face e22260d).

These also fold in a real correctness fix: the persistent graph-cache fingerprint
now includes op_params, so two structurally identical GGML_OP_CUSTOM graphs (a
blocked 3x3 vs a blocked 1x1 strided conv) can no longer false-hit the cache and
replay the wrong kernel. voice CI was failing test_blocked/conv1x1_s2 with an
out-of-bounds write on the GGML_NATIVE=OFF build; both engine repos are now green
and WeSpeaker embed parity is 1.0 vs golden.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-29 18:48:47 +02:00
LocalAI [bot]
d1cee4c52a chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260628073537 (#10562)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-29 09:13:22 +02:00
LocalAI [bot]
baaa0fe94f chore: ⬆️ Update mudler/face-detect.cpp to 06914b077d52f90d5421299138e7be6bdd06b5e8 (#10580)
⬆️ Update mudler/face-detect.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-06-29 08:04:22 +02:00
LocalAI [bot]
c3b5c7c3fa chore: ⬆️ Update mudler/voice-detect.cpp to 3d510772357538c5182808ac7de2278b84824e24 (#10581)
⬆️ Update mudler/voice-detect.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-06-29 08:03:43 +02:00
LocalAI [bot]
bd1ec8f2c2 chore: ⬆️ Update ggml-org/llama.cpp to dbdaece23de9ac63f2e7ca9e6bfcdc4fc156a3fa (#10582)
⬆️ 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-06-29 08:03:20 +02:00
LocalAI [bot]
135debf9af chore: ⬆️ Update CrispStrobe/CrispASR to 6b50f76e59700665358a1aabf5295597fa318e06 (#10583)
⬆️ 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-06-29 08:03:06 +02:00
LocalAI [bot]
e8c18ae28e chore: ⬆️ Update leejet/stable-diffusion.cpp to c1790754d31bec0731ed5fddc9d5b9ff22ee19cd (#10584)
⬆️ 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-06-29 08:02:52 +02:00
LocalAI [bot]
c4d302e1ab chore(model-gallery): ⬆️ update checksum (#10585)
⬆️ 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-06-28 23:26:28 +02:00
LocalAI [bot]
323b57a4bc fix(oci): retry layer downloads on transient network errors (#10579)
Installing large backend images (e.g. vLLM/vLLM-omni, several GiB) over
the Web UI could fail with "failed to download layer 0: unexpected EOF"
when a single connection to the registry dropped mid-stream. The whole
install then failed with no recovery, and since the download is not
resumable, retrying from the UI restarted from zero and usually hit the
same blip again - so users saw it as a consistent, size-correlated
failure (issue #10577).

The registry transport already retries manifest/digest fetches via
defaultRetryPredicate (GetImage/GetImageDigest), but the per-layer data
stream in DownloadOCIImageTar bypassed it entirely: layer.Compressed()
+ xio.Copy ran exactly once.

Extract the per-layer copy into downloadLayerToFile, which retries on the
same transient errors (unexpected EOF, EOF, EPIPE, ECONNRESET, connection
refused) with exponential backoff, truncating any partial data before
each retry. Non-retryable errors and context cancellation still fail
fast.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-28 21:21:08 +02:00
LocalAI [bot]
3d2f639213 fix(fish-speech): allow invalid_reference_casting so tokenizers builds on darwin (#10573)
On darwin arm64 the fish-speech editable install (pip install
--no-build-isolation -e) compiles the transitive `tokenizers` Python
package's Rust extension from source, because there is no prebuilt
manylinux wheel for that platform (Linux builds never compile it, so this
only breaks on macOS). The pinned tokenizers crate fish-speech's stack
resolves to contains a `&T` -> `&mut T` cast that the macOS CI runner's
newer Rust toolchain rejects via the now-deny-by-default
`invalid_reference_casting` lint:

    error: casting `&T` to `&mut T` is undefined behavior ...
    error: could not compile `tokenizers` (lib) due to 1 previous error
    ERROR: Failed building wheel for tokenizers

This failed the fish-speech darwin/metal (mps) backend image build in the
v4.5.5 release CI while all Linux variants built fine.

Fix: export RUSTFLAGS with `-A invalid_reference_casting` (appended to any
existing value, not clobbering) before installRequirements so the
unchanged third-party crate compiles as it did under the older toolchain.
Version-agnostic and harmless on Linux, where no Rust compile happens.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-28 19:10:27 +02:00
Nicholas Ciechanowski
be1ae9338b fix(distributed): missing agent NATS permissions (#10571)
Signed-off-by: Nicholas Ciechanowski <nicholas@ciech.anow.ski>
2026-06-28 12:58:13 +02:00
LocalAI [bot]
923c47020d fix(launcher): robust binary download/upgrade (resume, rate-limit, UX) (#10575)
* fix(launcher): resume flaky downloads, drop redundant percent, fit dialogs

The binary upgrade/download flow had three rough edges:

- The status label printed "Downloading... N%" right next to a progress
  bar already showing the percent. Replace it with a human-readable byte
  readout ("Downloading... 12.3 MB / 45.6 MB").
- A failed download (GitHub releases are flaky) had no recourse and always
  restarted from byte 0. Stream to "<dest>.part" and resume via a
  "Range: bytes=N-" request (handling 206/200/416), renaming to the final
  path only after checksum verification; on checksum failure the file is
  discarded so the next attempt starts clean. Add a Retry button that
  appears on failure and resumes from the partial file.
- Progress/install dialogs were hardcoded to oversized dimensions, leaving
  a blank gap below "View Release Notes". Size each window to its content
  with a sane minimum width.

Also unify the three near-identical download-progress popups into one
Launcher.showDownloadProgressWindow helper (and delete a dead unused copy
in ui.go) so the behaviour stays consistent across every entry point.

The progress callback now reports (downloaded, total) byte counts instead
of a single fraction. Resume/retry behaviour is covered by httptest-backed
unit tests in release_manager_test.go.

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

* fix(launcher): resolve latest version via redirect to dodge GitHub API 403

On a fresh Linux start with no LocalAI installed, the download failed with
"failed to fetch latest release: status 403". The cause is the unauthenticated
api.github.com rate limit (60 requests/hour, per IP): on shared/NAT/CGNAT/cloud
addresses it is exhausted almost immediately and every request 403s.

Resolve the latest version by following the github.com "releases/latest"
redirect instead, reading the tag from the final ".../releases/tag/<tag>" URL.
That endpoint is not subject to the API rate limit. Only the version is ever
consumed by callers, so the tag is sufficient. The JSON API is kept as a
fallback, now honoring GITHUB_TOKEN and reporting rate-limit 403/429 clearly
instead of an opaque status code.

Covered by an httptest-backed unit test that asserts the redirect path is used.

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-06-28 12:57:32 +02:00
LocalAI [bot]
b7a1dec773 fix(kokoro): add explicit click dep so spacy CLI works on intel build (#10572)
The kokoro install.sh ends with `python -m spacy download en_core_web_sm`.
spaCy's CLI imports typer -> click, so click must be present at that point.

On the intel build profile, install.sh adds `--upgrade --index-strategy=unsafe-first-match`
against the Intel pip index. With that resolution strategy, click is not
resolved/installed, so the spacy CLI import fails with:

    ModuleNotFoundError: No module named 'click'
    make: *** [Makefile:3: kokoro] Error 1

Other profiles (cpu/cublas) pull click in transitively and build fine; only
the intel profile breaks. This surfaced in the v4.5.5 release CI as the
gpu-intel-kokoro backend image build failure.

Make click an explicit dependency in the base requirements.txt (installed for
every profile) so it is always present before `python -m spacy download` runs,
regardless of index resolution. Unpinned: spacy constrains the version.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-28 11:29:17 +02:00
436 changed files with 28344 additions and 2113 deletions

View File

@@ -251,6 +251,7 @@ After adding a new backend, verify:
- [ ] No YAML syntax errors (check with linter)
- [ ] No Makefile syntax errors (check with linter)
- [ ] Follows the same pattern as similar backends (e.g., if it's a transcription backend, follow `faster-whisper` pattern)
- [ ] **`Load` validates its input and refuses models it can't serve.** When a model config has no explicit `backend:`, the model loader greedily probes *every* installed backend with the model's name and binds to the first `Load` that succeeds — an accept-anything `Load` will capture arbitrary LLMs (issue #9287). Backends that load a real artefact get this for free (the load fails); backends with no artefact must gate on the name: `opus` accepts only its own name (or none), `local-store` requires the `store.NamespacePrefix` namespace marker sent by `core/backend/stores.go`.
- [ ] Documented: added to the category list in `docs/content/features/backends.md` (and any new endpoint/realtime capability documented under `docs/content/`)
- [ ] If it is an in-house native C/C++/GGML engine, added to the maintained-engines table in the top-level `README.md`

View File

@@ -7,8 +7,11 @@
# Runs only the checks relevant to what's staged:
# - Go files -> make lint + make test-coverage-check
# - core/http/react-ui -> make test-ui-coverage-check (Playwright e2e + gate)
# A commit touching neither is skipped entirely (docs/YAML/etc. can't change
# lint findings, Go coverage, or the UI).
# - realtime state machines / specs -> make test-realtime-conformance
# (respcoord/**, turncoord/**, or formal-verification/** -- a pure .fizz
# spec edit must still re-verify the design, detected separately from Go)
# A commit touching none of these is skipped entirely (other docs/YAML can't
# change lint findings, Go coverage, the UI, or the realtime conformance gate).
#
# To bypass for a single commit (e.g. a WIP checkpoint): git commit --no-verify
set -eu
@@ -20,11 +23,13 @@ staged="$(git diff --cached --name-only --diff-filter=ACMRD)"
go_changed=0
ui_changed=0
rt_changed=0
if echo "$staged" | grep -qE '\.go$'; then go_changed=1; fi
if echo "$staged" | grep -qE '^core/http/react-ui/'; then ui_changed=1; fi
if echo "$staged" | grep -qE '^(core/http/endpoints/openai/(coordinator|respcoord|turncoord|conncoord|compactcoord|ttscoord)/|formal-verification/)'; then rt_changed=1; fi
if [ "$go_changed" -eq 0 ] && [ "$ui_changed" -eq 0 ]; then
echo "pre-commit: no Go or React UI changes staged — skipping."
if [ "$go_changed" -eq 0 ] && [ "$ui_changed" -eq 0 ] && [ "$rt_changed" -eq 0 ]; then
echo "pre-commit: no Go, React UI, or realtime-spec changes staged — skipping."
exit 0
fi
@@ -57,4 +62,11 @@ if [ "$ui_changed" -eq 1 ]; then
make test-ui-coverage-check
fi
if [ "$rt_changed" -eq 1 ]; then
echo "pre-commit ▶ realtime state-machine conformance (make test-realtime-conformance) —"
echo " Go transition/rapid tests under -race + FizzBee model check of the"
echo " authoritative specs. Fail-closed: needs FizzBee (make install-fizzbee)."
make test-realtime-conformance
fi
echo "pre-commit ✓ all relevant checks passed"

View File

@@ -478,6 +478,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-longcat-video'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "longcat-video"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -790,6 +803,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1136,6 +1162,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-longcat-video'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "longcat-video"
dockerfile: "./backend/Dockerfile.python"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1344,6 +1383,19 @@ include:
backend: "vllm-omni"
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-longcat-video'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
ubuntu-version: '2404'
backend: "longcat-video"
dockerfile: "./backend/Dockerfile.python"
context: "./"
- build-type: 'l4t'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1721,6 +1773,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1760,6 +1825,19 @@ include:
backend: "parakeet-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-moss-transcribe-cpp'
base-image: "ubuntu:24.04"
ubuntu-version: '2404'
runs-on: 'ubuntu-24.04-arm'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -3597,6 +3675,115 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# moss-transcribe-cpp
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-moss-transcribe-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f32-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f16-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-moss-transcribe-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-moss-transcribe-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-arm64-moss-transcribe-cpp'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
- build-type: 'hipblas'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-rocm-hipblas-moss-transcribe-cpp'
base-image: "rocm/dev-ubuntu-24.04:7.2.1"
runs-on: 'ubuntu-latest'
skip-drivers: 'false'
backend: "moss-transcribe-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# ced
- build-type: 'cublas'
cuda-major-version: "12"
@@ -5220,6 +5407,10 @@ includeDarwin:
tag-suffix: "-metal-darwin-arm64-parakeet-cpp"
build-type: "metal"
lang: "go"
- backend: "moss-transcribe-cpp"
tag-suffix: "-metal-darwin-arm64-moss-transcribe-cpp"
build-type: "metal"
lang: "go"
- backend: "ced"
tag-suffix: "-metal-darwin-arm64-ced"
build-type: "metal"

View File

@@ -32,16 +32,30 @@ jobs:
if: github.repository == 'mudler/LocalAI'
runs-on: ubuntu-latest
outputs:
matrix-singlearch: ${{ steps.set-matrix.outputs['matrix-singlearch'] }}
matrix-multiarch: ${{ steps.set-matrix.outputs['matrix-multiarch'] }}
matrix-darwin: ${{ steps.set-matrix.outputs['matrix-darwin'] }}
merge-matrix-multiarch: ${{ steps.set-matrix.outputs['merge-matrix-multiarch'] }}
merge-matrix-singlearch: ${{ steps.set-matrix.outputs['merge-matrix-singlearch'] }}
has-backends-singlearch: ${{ steps.set-matrix.outputs['has-backends-singlearch'] }}
has-backends-multiarch: ${{ steps.set-matrix.outputs['has-backends-multiarch'] }}
has-backends-darwin: ${{ steps.set-matrix.outputs['has-backends-darwin'] }}
has-merges-multiarch: ${{ steps.set-matrix.outputs['has-merges-multiarch'] }}
has-merges-singlearch: ${{ steps.set-matrix.outputs['has-merges-singlearch'] }}
# Single-arch backends are sharded across SINGLEARCH_SHARDS matrix jobs to
# stay under GitHub's 256-jobs-per-matrix limit (see changed-backends.js).
matrix-singlearch-1: ${{ steps.set-matrix.outputs['matrix-singlearch-1'] }}
merge-matrix-singlearch-1: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-1'] }}
has-backends-singlearch-1: ${{ steps.set-matrix.outputs['has-backends-singlearch-1'] }}
has-merges-singlearch-1: ${{ steps.set-matrix.outputs['has-merges-singlearch-1'] }}
matrix-singlearch-2: ${{ steps.set-matrix.outputs['matrix-singlearch-2'] }}
merge-matrix-singlearch-2: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-2'] }}
has-backends-singlearch-2: ${{ steps.set-matrix.outputs['has-backends-singlearch-2'] }}
has-merges-singlearch-2: ${{ steps.set-matrix.outputs['has-merges-singlearch-2'] }}
matrix-singlearch-3: ${{ steps.set-matrix.outputs['matrix-singlearch-3'] }}
merge-matrix-singlearch-3: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-3'] }}
has-backends-singlearch-3: ${{ steps.set-matrix.outputs['has-backends-singlearch-3'] }}
has-merges-singlearch-3: ${{ steps.set-matrix.outputs['has-merges-singlearch-3'] }}
matrix-singlearch-4: ${{ steps.set-matrix.outputs['matrix-singlearch-4'] }}
merge-matrix-singlearch-4: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-4'] }}
has-backends-singlearch-4: ${{ steps.set-matrix.outputs['has-backends-singlearch-4'] }}
has-merges-singlearch-4: ${{ steps.set-matrix.outputs['has-merges-singlearch-4'] }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -109,9 +123,9 @@ jobs:
# take their full ~6h cold without blocking manifest assembly for the
# multi-arch backends whose per-arch digests would otherwise sit untagged
# on quay long enough to be GC'd.
backend-jobs-singlearch:
backend-jobs-singlearch-1:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch'] == 'true'
if: needs.generate-matrix.outputs['has-backends-singlearch-1'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
@@ -138,7 +152,100 @@ jobs:
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch']) }}
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-1']) }}
backend-jobs-singlearch-2:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-2'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-2']) }}
backend-jobs-singlearch-3:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-3'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-3']) }}
backend-jobs-singlearch-4:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-4'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-4']) }}
# Apply tags to per-arch digests via `imagetools create`. Split into two
# jobs that mirror the build split so each merge waits ONLY on its
@@ -174,10 +281,12 @@ jobs:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-multiarch']) }}
backend-merge-jobs-singlearch:
needs: [generate-matrix, backend-jobs-singlearch]
# See note on backend-merge-jobs-multiarch above for !cancelled().
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch'] == 'true' }}
# One merge shard per build shard: backend-merge-jobs-singlearch-<n> needs only
# backend-jobs-singlearch-<n>, preserving the "merge waits only on its own
# build" property while staying under the 256-jobs-per-matrix limit.
backend-merge-jobs-singlearch-1:
needs: [generate-matrix, backend-jobs-singlearch-1]
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch-1'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
@@ -189,7 +298,55 @@ jobs:
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch']) }}
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-1']) }}
backend-merge-jobs-singlearch-2:
needs: [generate-matrix, backend-jobs-singlearch-2]
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch-2'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-2']) }}
backend-merge-jobs-singlearch-3:
needs: [generate-matrix, backend-jobs-singlearch-3]
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch-3'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-3']) }}
backend-merge-jobs-singlearch-4:
needs: [generate-matrix, backend-jobs-singlearch-4]
if: ${{ !cancelled() && needs.generate-matrix.outputs['has-merges-singlearch-4'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
dockerUsername: ${{ secrets.DOCKERHUB_USERNAME }}
dockerPassword: ${{ secrets.DOCKERHUB_PASSWORD }}
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-4']) }}
backend-jobs-darwin:
needs: generate-matrix

View File

@@ -82,7 +82,7 @@ jobs:
# as the Linux registry cache.
- name: Restore Homebrew cache
id: brew-cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: |
~/Library/Caches/Homebrew/downloads
@@ -142,7 +142,7 @@ jobs:
- name: Save Homebrew cache
if: github.event_name != 'pull_request' && steps.brew-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
uses: actions/cache/save@v6
with:
path: |
~/Library/Caches/Homebrew/downloads
@@ -178,7 +178,7 @@ jobs:
- name: Restore ccache
if: inputs.backend == 'llama-cpp'
id: ccache-cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: ~/Library/Caches/ccache
key: ccache-llama-${{ runner.arch }}-${{ steps.llama-version.outputs.version }}-${{ github.run_id }}
@@ -211,7 +211,7 @@ jobs:
- name: Restore Python wheel cache
if: inputs.lang == 'python'
id: pyenv-cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: |
~/Library/Caches/pip
@@ -256,14 +256,14 @@ jobs:
- name: Save ccache
if: inputs.backend == 'llama-cpp' && github.event_name != 'pull_request'
uses: actions/cache/save@v4
uses: actions/cache/save@v6
with:
path: ~/Library/Caches/ccache
key: ccache-llama-${{ runner.arch }}-${{ steps.llama-version.outputs.version }}-${{ github.run_id }}
- name: Save Python wheel cache
if: inputs.lang == 'python' && github.event_name != 'pull_request' && steps.pyenv-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
uses: actions/cache/save@v6
with:
path: |
~/Library/Caches/pip

View File

@@ -11,16 +11,30 @@ jobs:
generate-matrix:
runs-on: ubuntu-latest
outputs:
matrix-singlearch: ${{ steps.set-matrix.outputs['matrix-singlearch'] }}
matrix-multiarch: ${{ steps.set-matrix.outputs['matrix-multiarch'] }}
matrix-darwin: ${{ steps.set-matrix.outputs['matrix-darwin'] }}
merge-matrix-multiarch: ${{ steps.set-matrix.outputs['merge-matrix-multiarch'] }}
merge-matrix-singlearch: ${{ steps.set-matrix.outputs['merge-matrix-singlearch'] }}
has-backends-singlearch: ${{ steps.set-matrix.outputs['has-backends-singlearch'] }}
has-backends-multiarch: ${{ steps.set-matrix.outputs['has-backends-multiarch'] }}
has-backends-darwin: ${{ steps.set-matrix.outputs['has-backends-darwin'] }}
has-merges-multiarch: ${{ steps.set-matrix.outputs['has-merges-multiarch'] }}
has-merges-singlearch: ${{ steps.set-matrix.outputs['has-merges-singlearch'] }}
# Single-arch backends are sharded across SINGLEARCH_SHARDS matrix jobs to
# stay under GitHub's 256-jobs-per-matrix limit (see changed-backends.js).
matrix-singlearch-1: ${{ steps.set-matrix.outputs['matrix-singlearch-1'] }}
merge-matrix-singlearch-1: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-1'] }}
has-backends-singlearch-1: ${{ steps.set-matrix.outputs['has-backends-singlearch-1'] }}
has-merges-singlearch-1: ${{ steps.set-matrix.outputs['has-merges-singlearch-1'] }}
matrix-singlearch-2: ${{ steps.set-matrix.outputs['matrix-singlearch-2'] }}
merge-matrix-singlearch-2: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-2'] }}
has-backends-singlearch-2: ${{ steps.set-matrix.outputs['has-backends-singlearch-2'] }}
has-merges-singlearch-2: ${{ steps.set-matrix.outputs['has-merges-singlearch-2'] }}
matrix-singlearch-3: ${{ steps.set-matrix.outputs['matrix-singlearch-3'] }}
merge-matrix-singlearch-3: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-3'] }}
has-backends-singlearch-3: ${{ steps.set-matrix.outputs['has-backends-singlearch-3'] }}
has-merges-singlearch-3: ${{ steps.set-matrix.outputs['has-merges-singlearch-3'] }}
matrix-singlearch-4: ${{ steps.set-matrix.outputs['matrix-singlearch-4'] }}
merge-matrix-singlearch-4: ${{ steps.set-matrix.outputs['merge-matrix-singlearch-4'] }}
has-backends-singlearch-4: ${{ steps.set-matrix.outputs['has-backends-singlearch-4'] }}
has-merges-singlearch-4: ${{ steps.set-matrix.outputs['has-merges-singlearch-4'] }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -71,10 +85,10 @@ jobs:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-multiarch']) }}
backend-jobs-singlearch:
backend-jobs-singlearch-1:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-1'] == 'true'
uses: ./.github/workflows/backend_build.yml
if: needs.generate-matrix.outputs['has-backends-singlearch'] == 'true'
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
@@ -98,7 +112,94 @@ jobs:
strategy:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch']) }}
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-1']) }}
backend-jobs-singlearch-2:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-2'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-2']) }}
backend-jobs-singlearch-3:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-3'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-3']) }}
backend-jobs-singlearch-4:
needs: generate-matrix
if: needs.generate-matrix.outputs['has-backends-singlearch-4'] == 'true'
uses: ./.github/workflows/backend_build.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
build-type: ${{ matrix.build-type }}
cuda-major-version: ${{ matrix.cuda-major-version }}
cuda-minor-version: ${{ matrix.cuda-minor-version }}
platforms: ${{ matrix.platforms }}
platform-tag: ${{ matrix.platform-tag || '' }}
runs-on: ${{ matrix.runs-on }}
builder-base-image: ${{ matrix.builder-base-image || '' }}
base-image: ${{ matrix.base-image }}
backend: ${{ matrix.backend }}
dockerfile: ${{ matrix.dockerfile }}
skip-drivers: ${{ matrix.skip-drivers }}
context: ${{ matrix.context }}
ubuntu-version: ${{ matrix.ubuntu-version }}
amdgpu-targets: ${{ matrix.amdgpu-targets || 'gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201' }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: true
max-parallel: 8
matrix: ${{ fromJson(needs.generate-matrix.outputs['matrix-singlearch-4']) }}
backend-merge-jobs-multiarch:
needs: [generate-matrix, backend-jobs-multiarch]
# backend_merge.yml's push-side steps are all gated on
@@ -118,9 +219,9 @@ jobs:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-multiarch']) }}
backend-merge-jobs-singlearch:
needs: [generate-matrix, backend-jobs-singlearch]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch'] == 'true' }}
backend-merge-jobs-singlearch-1:
needs: [generate-matrix, backend-jobs-singlearch-1]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch-1'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
@@ -130,7 +231,49 @@ jobs:
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch']) }}
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-1']) }}
backend-merge-jobs-singlearch-2:
needs: [generate-matrix, backend-jobs-singlearch-2]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch-2'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-2']) }}
backend-merge-jobs-singlearch-3:
needs: [generate-matrix, backend-jobs-singlearch-3]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch-3'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-3']) }}
backend-merge-jobs-singlearch-4:
needs: [generate-matrix, backend-jobs-singlearch-4]
if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.generate-matrix.outputs['has-merges-singlearch-4'] == 'true' }}
uses: ./.github/workflows/backend_merge.yml
with:
tag-latest: ${{ matrix.tag-latest }}
tag-suffix: ${{ matrix.tag-suffix }}
secrets:
quayUsername: ${{ secrets.LOCALAI_REGISTRY_USERNAME }}
quayPassword: ${{ secrets.LOCALAI_REGISTRY_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs['merge-matrix-singlearch-4']) }}
backend-jobs-darwin:
needs: generate-matrix
uses: ./.github/workflows/backend_build_darwin.yml

View File

@@ -26,6 +26,10 @@ jobs:
variable: "DS4_VERSION"
branch: "main"
file: "backend/cpp/ds4/Makefile"
- repository: "meituan-longcat/LongCat-Video"
variable: "LONGCAT_VIDEO_VERSION"
branch: "main"
file: "backend/python/longcat-video/Makefile"
- repository: "localai-org/privacy-filter.cpp"
variable: "PRIVACY_FILTER_VERSION"
branch: "master"
@@ -42,6 +46,10 @@ jobs:
variable: "PARAKEET_VERSION"
branch: "master"
file: "backend/go/parakeet-cpp/Makefile"
- repository: "mudler/moss-transcribe.cpp"
variable: "MOSS_VERSION"
branch: "master"
file: "backend/go/moss-transcribe-cpp/Makefile"
- repository: "mudler/ced.cpp"
variable: "CED_VERSION"
branch: "master"

View File

@@ -0,0 +1,69 @@
---
name: 'realtime-conformance'
# Verifies the realtime state-machine implementations conform to their formal
# designs (docs/design/realtime-state-machines.md, formal-verification/). BOTH
# layers are enforced and the gate is fail-closed: the Go conformance layer
# (respcoord + turncoord transition/rapid tests under -race) AND the FizzBee model check of
# the authoritative specs. FizzBee is pinned + checksum-verified
# (formal-verification/fizzbee.sha256), so a failed install fails the job rather
# than silently skipping verification.
on:
pull_request:
paths:
- 'core/http/endpoints/openai/coordinator/**'
- 'core/http/endpoints/openai/respcoord/**'
- 'core/http/endpoints/openai/turncoord/**'
- 'core/http/endpoints/openai/conncoord/**'
- 'core/http/endpoints/openai/compactcoord/**'
- 'core/http/endpoints/openai/ttscoord/**'
- 'formal-verification/**'
- 'scripts/realtime-conformance.sh'
- 'scripts/install-fizzbee.sh'
- '.github/workflows/realtime-conformance.yml'
push:
branches:
- master
paths:
- 'core/http/endpoints/openai/coordinator/**'
- 'core/http/endpoints/openai/respcoord/**'
- 'core/http/endpoints/openai/turncoord/**'
- 'core/http/endpoints/openai/conncoord/**'
- 'core/http/endpoints/openai/compactcoord/**'
- 'core/http/endpoints/openai/ttscoord/**'
- 'formal-verification/**'
- 'scripts/realtime-conformance.sh'
concurrency:
group: realtime-conformance-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
conformance:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.26.x']
steps:
- name: Clone
uses: actions/checkout@v7
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: false
- name: Cache FizzBee
uses: actions/cache@v6
with:
path: .tools/fizzbee
key: fizzbee-v0.5.2-${{ runner.os }}-${{ hashFiles('formal-verification/fizzbee.sha256') }}
- name: Install FizzBee (pinned, checksum-verified)
# No `|| true`: a failed/forged download must fail the job, not silently
# drop the design verification. install-fizzbee.sh is a no-op if the
# cached binary is already present and valid.
run: ./scripts/install-fizzbee.sh
- name: Run conformance gate (fail-closed)
# No skip env: both the Go conformance and the FizzBee model check are
# required. The gate auto-detects .tools/fizzbee/fizz.
run: make test-realtime-conformance

View File

@@ -11,7 +11,7 @@ jobs:
if: github.repository == 'mudler/LocalAI'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v9
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v9
with:
stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
stale-pr-message: 'This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 10 days.'

9
.gitignore vendored
View File

@@ -97,3 +97,12 @@ core/http/react-ui/test-results/
# Local Apple signing material (never commit)
.certs/
# Pinned dev tools (e.g. FizzBee for the realtime-conformance gate)
.tools/
# FizzBee model-check artifacts: the parser emits <spec>.json next to each
# .fizz and the checker writes run dirs under out/. Both are regenerated by
# the realtime-conformance gate; only the .fizz sources are authoritative.
formal-verification/*.json
formal-verification/out/

View File

@@ -171,6 +171,17 @@ RUN if [ "${BUILD_TYPE}" = "hipblas" ]; then \
ln -s /opt/rocm-**/lib/llvm/lib/libomp.so /usr/lib/libomp.so \
; fi
# ROCm's bundled libdrm_amdgpu is built with a hardcoded fallback lookup path
# for the ASIC ID table (/opt/amdgpu/share/libdrm/amdgpu.ids), which only exists
# if AMD's full amdgpu graphics/DKMS stack is installed. This compute-only image
# doesn't have it, so hipblas/rocBLAS log "No such file or directory" on every
# model load and can fail to identify the GPU. Point it at the equivalent file
# Ubuntu's libdrm-common package already ships.
RUN if [ "${BUILD_TYPE}" = "hipblas" ] && [ -f /usr/share/libdrm/amdgpu.ids ] && [ ! -e /opt/amdgpu/share/libdrm/amdgpu.ids ]; then \
mkdir -p /opt/amdgpu/share/libdrm && \
ln -s /usr/share/libdrm/amdgpu.ids /opt/amdgpu/share/libdrm/amdgpu.ids \
; fi
RUN expr "${BUILD_TYPE}" = intel && echo "intel" > /run/localai/capability || echo "not intel"
# Cuda

View File

@@ -1,5 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/crispasr backends/parakeet-cpp backends/faster-whisper backends/silero-vad backends/local-store 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/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/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
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/faster-whisper backends/silero-vad backends/local-store 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/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
GOCMD=go
GOTEST=$(GOCMD) test
@@ -405,6 +405,18 @@ test-realtime: build-mock-backend
@echo 'Running realtime e2e tests (mock backend)'
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="Realtime && !real-models" --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e
# Verify the realtime state-machine implementations conform to their formal
# designs (Go transition/rapid tests under -race + FizzBee model check of the
# authoritative specs). See docs/design/realtime-state-machines.md (Part 6) and
# docs/design/specs/README.md.
test-realtime-conformance:
GOCMD=$(GOCMD) ./scripts/realtime-conformance.sh
# Install the pinned, checksum-verified FizzBee model checker (into .tools/,
# gitignored) used by test-realtime-conformance. Idempotent; no-op if present.
install-fizzbee:
./scripts/install-fizzbee.sh
# Container-based real-model realtime testing. Build env vars / pipeline
# definition kept here so test-realtime-models-docker can drive a fully wired
# pipeline (VAD + STT + LLM + TTS) from inside a containerised runner.
@@ -553,6 +565,7 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/python/chatterbox
$(MAKE) -C backend/python/vllm
$(MAKE) -C backend/python/vllm-omni
$(MAKE) -C backend/python/longcat-video
$(MAKE) -C backend/python/sglang
$(MAKE) -C backend/python/vibevoice
$(MAKE) -C backend/python/liquid-audio
@@ -582,6 +595,7 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/python/chatterbox test
$(MAKE) -C backend/python/vllm test
$(MAKE) -C backend/python/vllm-omni test
$(MAKE) -C backend/python/longcat-video test
$(MAKE) -C backend/python/vibevoice test
$(MAKE) -C backend/python/liquid-audio test
$(MAKE) -C backend/python/moonshine test
@@ -633,6 +647,9 @@ test-extra: prepare-test-extra
## suite against it.
##
BACKEND_TEST_MODEL_URL?=https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q8_0.gguf
## Suite timeout for `go test`. Wrappers whose model download alone can eat
## most of the default (multi-GB models on a slow HF CDN day) override this.
BACKEND_TEST_TIMEOUT?=30m
## Generic target — runs the suite against whatever BACKEND_IMAGE points at.
## Depends on protogen-go so pkg/grpc/proto is generated before `go test`.
@@ -660,7 +677,7 @@ test-extra-backend: protogen-go
BACKEND_TEST_FACE_IMAGE_3_URL="$$BACKEND_TEST_FACE_IMAGE_3_URL" \
BACKEND_TEST_FACE_IMAGE_3_FILE="$$BACKEND_TEST_FACE_IMAGE_3_FILE" \
BACKEND_TEST_VERIFY_DISTANCE_CEILING="$$BACKEND_TEST_VERIFY_DISTANCE_CEILING" \
go test -v -timeout 30m ./tests/e2e-backends/...
go test -v -timeout $(BACKEND_TEST_TIMEOUT) ./tests/e2e-backends/...
## Convenience wrappers: build the image, then exercise it.
test-extra-backend-llama-cpp: docker-build-llama-cpp
@@ -1000,6 +1017,7 @@ test-extra-backend-vibevoice-cpp-tts: docker-build-vibevoice-cpp
## post-image disk budget.
test-extra-backend-vibevoice-cpp-transcription: docker-build-vibevoice-cpp
BACKEND_IMAGE=local-ai-backend:vibevoice-cpp \
BACKEND_TEST_TIMEOUT=120m \
BACKEND_TEST_MODEL_URL='https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/vibevoice-asr-q4_k.gguf#vibevoice-asr-q4_k.gguf' \
BACKEND_TEST_EXTRA_FILES='https://huggingface.co/mudler/vibevoice.cpp-models/resolve/main/tokenizer.gguf#tokenizer.gguf' \
BACKEND_TEST_AUDIO_URL=https://github.com/ggml-org/whisper.cpp/raw/master/samples/jfk.wav \
@@ -1027,7 +1045,19 @@ test-extra-backend-whisper-transcription: docker-build-whisper
## is reachable.
test-extra-backend-parakeet-cpp-transcription: docker-build-parakeet-cpp
BACKEND_IMAGE=local-ai-backend:parakeet-cpp \
BACKEND_TEST_MODEL_URL=https://huggingface.co/mudler/parakeet-cpp-gguf/resolve/main/tdt_ctc-110m-f16.gguf \
BACKEND_TEST_MODEL_URL=https://huggingface.co/mudler/parakeet-cpp-gguf/resolve/main/realtime_eou_120m-v1-f16.gguf \
BACKEND_TEST_AUDIO_URL=https://github.com/ggml-org/whisper.cpp/raw/master/samples/jfk.wav \
BACKEND_TEST_CAPS=health,load,transcription \
$(MAKE) test-extra-backend
## Audio transcription wrapper for the moss-transcribe-cpp (moss-transcribe.cpp
## ggml port) backend. Mirrors test-extra-backend-parakeet-cpp-transcription:
## drives the AudioTranscription RPC against a published MOSS GGUF using the JFK
## 11s clip from whisper.cpp's CI samples. Not part of the default test suite -
## run explicitly once the pinned model URL is reachable.
test-extra-backend-moss-transcribe-cpp-transcription: docker-build-moss-transcribe-cpp
BACKEND_IMAGE=local-ai-backend:moss-transcribe-cpp \
BACKEND_TEST_MODEL_URL=https://huggingface.co/mudler/moss-transcribe.cpp-gguf/resolve/main/moss-transcribe-q5_k.gguf \
BACKEND_TEST_AUDIO_URL=https://github.com/ggml-org/whisper.cpp/raw/master/samples/jfk.wav \
BACKEND_TEST_CAPS=health,load,transcription \
$(MAKE) test-extra-backend
@@ -1200,6 +1230,7 @@ BACKEND_STABLEDIFFUSION_GGML = stablediffusion-ggml|golang|.|--progress=plain|tr
BACKEND_WHISPER = whisper|golang|.|false|true
BACKEND_CRISPASR = crispasr|golang|.|false|true
BACKEND_PARAKEET_CPP = parakeet-cpp|golang|.|false|true
BACKEND_MOSS_TRANSCRIBE_CPP = moss-transcribe-cpp|golang|.|false|true
BACKEND_DEPTH_ANYTHING_CPP = depth-anything-cpp|golang|.|false|true
BACKEND_VOXTRAL = voxtral|golang|.|false|true
BACKEND_ACESTEP_CPP = acestep-cpp|golang|.|false|true
@@ -1225,6 +1256,7 @@ BACKEND_NEUTTS = neutts|python|.|false|true
BACKEND_KOKORO = kokoro|python|.|false|true
BACKEND_VLLM = vllm|python|.|false|true
BACKEND_VLLM_OMNI = vllm-omni|python|.|false|true
BACKEND_LONGCAT_VIDEO = longcat-video|python|.|--progress=plain|true
BACKEND_SGLANG = sglang|python|.|false|true
BACKEND_DIFFUSERS = diffusers|python|.|--progress=plain|true
BACKEND_CHATTERBOX = chatterbox|python|.|false|true
@@ -1293,6 +1325,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_STABLEDIFFUSION_GGML)))
$(eval $(call generate-docker-build-target,$(BACKEND_WHISPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_CRISPASR)))
$(eval $(call generate-docker-build-target,$(BACKEND_PARAKEET_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_MOSS_TRANSCRIBE_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_DEPTH_ANYTHING_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_VOXTRAL)))
$(eval $(call generate-docker-build-target,$(BACKEND_OPUS)))
@@ -1309,6 +1342,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_NEUTTS)))
$(eval $(call generate-docker-build-target,$(BACKEND_KOKORO)))
$(eval $(call generate-docker-build-target,$(BACKEND_VLLM)))
$(eval $(call generate-docker-build-target,$(BACKEND_VLLM_OMNI)))
$(eval $(call generate-docker-build-target,$(BACKEND_LONGCAT_VIDEO)))
$(eval $(call generate-docker-build-target,$(BACKEND_SGLANG)))
$(eval $(call generate-docker-build-target,$(BACKEND_DIFFUSERS)))
$(eval $(call generate-docker-build-target,$(BACKEND_CHATTERBOX)))
@@ -1345,7 +1379,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_SUPERTONIC)))
docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni 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-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-privacy-filter
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant 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-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-privacy-filter
########################################################
### Mock Backend for E2E Tests
@@ -1470,8 +1504,13 @@ build-launcher-darwin:
mv cmd/launcher/LocalAI.app dist/LocalAI.app
bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.app
# Wrap the (signed) app into a drag-to-Applications DMG via hdiutil, then sign the DMG.
# Notarize + staple the .app itself, then wrap it into a drag-to-Applications
# DMG via hdiutil and sign the DMG. The app is stapled BEFORE packaging so the
# bundle carries its own ticket and verifies offline (a dmg-only staple leaves
# the app relying on an online Gatekeeper check, which fails offline / once the
# app is copied out of the dmg). No-op without notary secrets.
dmg-launcher-darwin: build-launcher-darwin
bash contrib/macos/sign-and-notarize.sh notarize-app dist/LocalAI.app
rm -rf dist/dmg dist/LocalAI.dmg
mkdir -p dist/dmg
cp -R dist/LocalAI.app dist/dmg/LocalAI.app
@@ -1483,7 +1522,7 @@ dmg-launcher-darwin: build-launcher-darwin
notarize-launcher-darwin: dmg-launcher-darwin
bash contrib/macos/sign-and-notarize.sh notarize dist/LocalAI.dmg
# Single entrypoint for CI: build -> sign app -> dmg -> sign dmg -> notarize -> staple.
# Single entrypoint for CI: build -> sign app -> notarize+staple app -> dmg -> sign dmg -> notarize+staple dmg.
release-launcher-darwin: notarize-launcher-darwin
@echo "dist/LocalAI.dmg is ready"

View File

@@ -232,12 +232,16 @@ Most backends wrap a best-in-class upstream engine. A handful of them are native
| Backend | What it does |
|---------|-------------|
| [parakeet.cpp](https://github.com/mudler/parakeet.cpp) | C++/GGML port of NVIDIA NeMo Parakeet ASR (tdt/ctc/rnnt/hybrid), with cache-aware streaming transcription |
| [moss-transcribe.cpp](https://github.com/mudler/moss-transcribe.cpp) | C++/GGML port of OpenMOSS MOSS-Transcribe-Diarize: joint long-form transcription, speaker diarization and timestamping in a single pass |
| [ced.cpp](https://github.com/mudler/ced.cpp) | C++/GGML port of the CED audio-tagging models: sound-event classification (527-class AudioSet) over REST and the realtime API for live recognition |
| [voxtral.c](https://github.com/mudler/voxtral.c) | Voxtral Realtime 4B speech-to-text in pure C |
| [voice-detect.cpp](https://github.com/mudler/voice-detect.cpp) | Speaker recognition and voice analysis (ECAPA-TDNN, WeSpeaker, ERes2Net, CAM++, wav2vec2 age/gender/emotion), replacing the Python speaker-recognition backend |
| [voxtral-tts.c](https://github.com/mudler/voxtral-tts.c) | Voxtral Realtime 4B speech-to-text in pure C |
| [vibevoice.cpp](https://github.com/mudler/vibevoice.cpp) | Native port of Microsoft VibeVoice for TTS (voice cloning) and long-form ASR with speaker diarization |
| [rf-detr.cpp](https://github.com/mudler/rf-detr.cpp) | Native RF-DETR object detection and instance segmentation |
| [locate-anything.cpp](https://github.com/mudler/locate-anything.cpp) | Open-vocabulary object detection and visual grounding (LocateAnything-3B) |
| [depth-anything.cpp](https://github.com/mudler/depth-anything.cpp) | Depth Anything 3 monocular metric depth + camera pose estimation |
| [face-detect.cpp](https://github.com/mudler/face-detect.cpp) | Face detection, recognition, demographics and anti-spoofing (SCRFD/ArcFace, YuNet/SFace), replacing the Python insightface backend |
| [free-splatter.cpp](https://github.com/localai-org/free-splatter.cpp) | Pose-free 3D reconstruction (FreeSplatter): turns a handful of plain photos into 3D Gaussians, no camera poses or GPU required |
| [privacy-filter.cpp](https://github.com/localai-org/privacy-filter.cpp) | Standalone GGML PII/NER token-classification engine powering LocalAI's PII redaction tier |
| [LocalVQE](https://github.com/localai-org/LocalVQE) | Joint acoustic echo cancellation, noise suppression, and dereverberation |
| [local-store](https://github.com/mudler/LocalAI) | Local-first vector database for embeddings (shipped in-tree) |

View File

@@ -46,6 +46,7 @@ The backend system provides language-specific Dockerfiles that handle the build
- **vllm**: High-performance LLM inference
- **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
- **Vision**: mlx-vlm, rfdetr
- **Specialized**: rerankers, chatterbox, kokoro

View File

@@ -18,6 +18,18 @@ service Backend {
rpc GenerateVideo(GenerateVideoRequest) returns (Result) {}
rpc AudioTranscription(TranscriptRequest) returns (TranscriptResult) {}
rpc AudioTranscriptionStream(TranscriptRequest) returns (stream TranscriptStreamResponse) {}
// AudioTranscriptionLive is the bidirectional live-microphone ASR RPC. The
// first message MUST carry a Config; subsequent messages carry Audio frames
// (mono float PCM at config.sample_rate, 16 kHz default). After a
// successful open the backend replies with a single ready ack
// (TranscriptLiveResponse{ready:true}); backends or models without
// cache-aware streaming support return UNIMPLEMENTED instead. Newly
// finalized text streams back as deltas; eou=true marks the model's
// end-of-utterance token. One stream spans many utterances (the decoder
// resets itself after each EOU). Closing the send side finalizes: the
// backend flushes the decoder tail and emits a terminal message carrying
// final_result. A second Config mid-stream resets the decode session.
rpc AudioTranscriptionLive(stream TranscriptLiveRequest) returns (stream TranscriptLiveResponse) {}
rpc TTS(TTSRequest) returns (Result) {}
rpc TTSStream(TTSRequest) returns (stream Reply) {}
rpc SoundGeneration(SoundGenerationRequest) returns (Result) {}
@@ -479,6 +491,10 @@ message TranscriptResult {
string text = 2;
string language = 3;
float duration = 4;
// True when the decode ended on the model's end-of-utterance special token
// (<EOU>/<EOB>, emitted by cache-aware streaming models such as
// parakeet_realtime_eou_120m-v1). The marker itself is stripped from text.
bool eou = 5;
}
message TranscriptStreamResponse {
@@ -486,6 +502,34 @@ message TranscriptStreamResponse {
TranscriptResult final_result = 2;
}
// === AudioTranscriptionLive messages =====================================
message TranscriptLiveRequest {
oneof payload {
TranscriptLiveConfig config = 1;
TranscriptLiveAudio audio = 2;
}
}
message TranscriptLiveConfig {
string language = 1; // "" => model default
int32 sample_rate = 2; // 0 => 16000; backends may reject others
map<string, string> params = 3; // backend-specific tuning
}
message TranscriptLiveAudio {
repeated float pcm = 1; // mono PCM in [-1,1] at config.sample_rate
}
message TranscriptLiveResponse {
bool ready = 1; // open ack: sent once, before any delta
string delta = 2; // newly-finalized text since previous response
bool eou = 3; // <EOU> fired during this feed (the user yielded the turn)
repeated TranscriptWord words = 4; // words finalized by this feed (stream-relative ns)
TranscriptResult final_result = 5; // terminal message only, after the send side closes
bool eob = 6; // <EOB> fired: a backchannel ("uh-huh") ended — NOT a turn boundary
}
message TranscriptWord {
int64 start = 1;
int64 end = 2;
@@ -533,6 +577,10 @@ message GenerateVideoRequest {
float cfg_scale = 10; // Classifier-free guidance scale
int32 step = 11; // Number of inference steps
string dst = 12; // Output path for the generated video
string audio = 13; // Path to staged audio for audio-conditioned video
// Backend-specific per-request generation parameters. Values are strings
// and are validated/coerced by the selected backend.
map<string, string> params = 14;
}
message TTSRequest {
@@ -1212,4 +1260,3 @@ message ForwardReply {
repeated ForwardHeader headers = 2;
bytes body_chunk = 3;
}

View File

@@ -1,12 +1,14 @@
#!/bin/bash
set -e
set -euo pipefail
CURDIR=$(dirname "$(realpath "$0")")
REPO_ROOT="${CURDIR}/../../.."
PACKAGE_DIR="$CURDIR/package"
mkdir -p "$CURDIR/package/lib"
cp -avf "$CURDIR/grpc-server" "$CURDIR/package/"
cp -avf "$CURDIR/ds4-worker" "$CURDIR/package/"
cp -rfv "$CURDIR/run.sh" "$CURDIR/package/"
rm -rf "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR/lib"
cp -avf "$CURDIR/grpc-server" "$PACKAGE_DIR/"
cp -avf "$CURDIR/ds4-worker" "$PACKAGE_DIR/"
cp -rfv "$CURDIR/run.sh" "$PACKAGE_DIR/"
UNAME_S=$(uname -s)
if [ "$UNAME_S" = "Darwin" ]; then
@@ -16,25 +18,54 @@ if [ "$UNAME_S" = "Darwin" ]; then
fi
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$CURDIR/package/lib/ld.so"
LIBDIR=/lib/x86_64-linux-gnu
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$PACKAGE_DIR/lib/ld.so"
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
cp -arfLv /lib/ld-linux-aarch64.so.1 "$CURDIR/package/lib/ld.so"
LIBDIR=/lib/aarch64-linux-gnu
cp -arfLv /lib/ld-linux-aarch64.so.1 "$PACKAGE_DIR/lib/ld.so"
else
echo "package.sh: unknown architecture" >&2; exit 1
fi
for lib in libc.so.6 libgcc_s.so.1 libstdc++.so.6 libm.so.6 libgomp.so.1 \
libdl.so.2 librt.so.1 libpthread.so.0; do
cp -arfLv "$LIBDIR/$lib" "$CURDIR/package/lib/$lib"
# Bundle the complete dependency closure for both executables. In particular,
# grpc-server links the distro gRPC/protobuf/absl stack; copying only the core
# C/C++ runtime libraries leaves the scratch image unable to start.
{
ldd "$CURDIR/grpc-server"
ldd "$CURDIR/ds4-worker"
} | awk '$2 == "=>" && $3 ~ /^\// { print $3 }' | sort -u | \
while read -r so; do
cp -arfLv "$so" "$PACKAGE_DIR/lib/"
done
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
# shellcheck source=/dev/null
source "$GPU_LIB_SCRIPT" "$PACKAGE_DIR/lib"
package_gpu_libs
fi
# Resolve every dependency through the same loader and library path used by
# the from-scratch image. The loader can still search host defaults, so reject
# any absolute dependency path that escapes the package instead of accepting a
# false-positive validation against a library that scratch will not contain.
validate_packaged_binary() {
local binary="$1"
local resolution
resolution=$("$PACKAGE_DIR/lib/ld.so" \
--library-path "$PACKAGE_DIR/lib" \
--list "$PACKAGE_DIR/$binary")
printf '%s\n' "$resolution" | awk -v prefix="$PACKAGE_DIR/lib/" '
$2 == "=>" && $3 ~ /^\// && index($3, prefix) != 1 {
print "package.sh: dependency resolved outside package: " $0 > "/dev/stderr"
invalid = 1
}
END { exit invalid }
'
}
for binary in grpc-server ds4-worker; do
validate_packaged_binary "$binary"
done
echo "ds4 package contents:"
ls -lah "$CURDIR/package/" "$CURDIR/package/lib/"
ls -lah "$PACKAGE_DIR/" "$PACKAGE_DIR/lib/"

View File

@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=f96eaddba8bed6a9a5e628bbf6a566775c70b49c
IK_LLAMA_VERSION?=6198a356a85ed71534c02a9c1026203389f341e5
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=

View File

@@ -101,4 +101,13 @@ if(LLAMA_GRPC_BUILD_TESTS)
target_link_libraries(message_content_test PRIVATE ${_LLAMA_COMMON_TARGET})
target_compile_features(message_content_test PRIVATE cxx_std_17)
add_test(NAME message_content_test COMMAND message_content_test)
# Parent-death watcher test (parent_watch.h) — standard library only, but
# needs a threading runtime for std::thread.
find_package(Threads REQUIRED)
add_executable(parent_watch_test parent_watch_test.cpp parent_watch.h)
target_include_directories(parent_watch_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(parent_watch_test PRIVATE Threads::Threads)
target_compile_features(parent_watch_test PRIVATE cxx_std_17)
add_test(NAME parent_watch_test COMMAND parent_watch_test)
endif()

View File

@@ -1,5 +1,5 @@
LLAMA_VERSION?=0ed235ea2c17a19fc8238668653946721ed136fd
LLAMA_VERSION?=6b4dc2116a92c5c8f2782bfe51fabe5ee66fb5ef
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=

View File

@@ -75,6 +75,8 @@
#include <windows.h>
#endif
#include "parent_watch.h" // best-effort parent-death backstop (see header)
using grpc::Server;
using grpc::ServerBuilder;
@@ -611,6 +613,24 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
// starts with '-'. Applied once after the loop via common_params_parse.
std::vector<std::string> extra_argv;
auto add_device_options = [&](const std::string & devices) {
const std::regex regex{ R"([,]+)" };
std::sregex_token_iterator it{ devices.begin(), devices.end(), regex, -1 };
std::vector<std::string> split_arg{ it, {} };
for (std::string device : split_arg) {
const auto start = device.find_first_not_of(" \t\n\r");
if (start == std::string::npos) {
continue;
}
const auto end = device.find_last_not_of(" \t\n\r");
device = device.substr(start, end - start + 1);
extra_argv.push_back("--device");
extra_argv.push_back(device);
}
};
// decode options. Options are in form optname:optvale, or if booleans only optname.
for (int i = 0; i < request->options_size(); i++) {
std::string opt = request->options(i);
@@ -742,6 +762,10 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
params.no_op_offload = false;
}
} else if (!strcmp(optname, "device") || !strcmp(optname, "devices")) {
if (optval != NULL) {
add_device_options(optval_str);
}
} else if (!strcmp(optname, "split_mode") || !strcmp(optname, "sm")) {
// Accepts: none | layer | row | tensor (the latter requires a llama.cpp build
// that includes ggml-org/llama.cpp#19378, FlashAttention enabled, and KV-cache
@@ -3442,6 +3466,10 @@ int main(int argc, char** argv) {
}
}
// Best-effort backstop: self-terminate if the LocalAI process that spawned
// us dies without cleaning us up (see parent_watch.h).
llama_grpc::start_parent_death_watcher();
server_context ctx_server;
BackendServiceImpl service(ctx_server);

View File

@@ -0,0 +1,179 @@
// Parent-death watcher (best-effort backstop) for the llama.cpp gRPC backend.
//
// LocalAI spawns this backend as a child process and, on a clean shutdown,
// tears it down itself (SIGTERM -> grace -> SIGKILL). That graceful path only
// runs when LocalAI receives a catchable signal and lives long enough to run
// its handlers. If LocalAI is SIGKILLed (e.g. a supervising process's grace
// period elapses first), that teardown never runs and this backend would be
// reparented to init and linger, holding VRAM and its listen port.
//
// The watcher here is a best-effort backstop for exactly that case: it does
// NOT replace the graceful teardown, it only covers the "parent vanished
// without cleaning up" path. It detects reparenting: when the process that
// spawned this backend dies, the kernel reparents us to the nearest sub-reaper
// or to init (PID 1), so getppid() stops matching the value captured at
// startup. This getppid() approach is portable across Linux/macOS (unlike the
// Linux-only PR_SET_PDEATHSIG), which is why it is used here, mirroring the Go
// backends' pkg/grpc/parentwatch.go. It is disabled on Windows, which has no
// equivalent orphan-reparenting semantics.
//
// This header is intentionally dependency-free (C++ standard library only) so
// it can be exercised by a standalone unit test (parent_watch_test.cpp) without
// building the full llama.cpp + gRPC backend.
#ifndef LLAMA_GRPC_PARENT_WATCH_H
#define LLAMA_GRPC_PARENT_WATCH_H
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <string>
#include <thread>
#if !defined(_WIN32)
#include <unistd.h> // getppid(2), _exit(2)
#endif
namespace llama_grpc {
// Env var names are shared verbatim with the Go and Python backends for
// consistency across languages.
inline const char *kEnvParentWatch() { return "LOCALAI_BACKEND_PARENT_WATCH"; }
inline const char *kEnvParentWatchInterval() { return "LOCALAI_BACKEND_PARENT_WATCH_INTERVAL"; }
// Default poll interval in milliseconds. Matches the Go side's 2 * time.Second.
inline long parent_watch_default_interval_ms() { return 2000; }
namespace detail {
inline std::string trim_lower(const std::string &in, bool lower) {
size_t a = in.find_first_not_of(" \t\r\n");
size_t b = in.find_last_not_of(" \t\r\n");
if (a == std::string::npos) {
return "";
}
std::string s = in.substr(a, b - a + 1);
if (lower) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return std::tolower(c); });
}
return s;
}
} // namespace detail
// parent_watch_enabled reports whether the watcher should run. Enabled by
// default; a falsey value ("false"/"0"/"no"/"off", case-insensitive) disables
// it, matching the Go implementation's exact semantics.
inline bool parent_watch_enabled() {
#if defined(_WIN32)
return false;
#else
const char *v = std::getenv(kEnvParentWatch());
if (v == nullptr || v[0] == '\0') {
return true;
}
const std::string s = detail::trim_lower(v, true);
return !(s == "false" || s == "0" || s == "no" || s == "off");
#endif
}
// parent_watch_interval_ms returns the poll interval in milliseconds. Accepts
// Go-style duration strings ("500ms", "2s", "1m") for cross-language parity, or
// a bare number interpreted as seconds. Defaults to
// parent_watch_default_interval_ms().
inline long parent_watch_interval_ms() {
const long def = parent_watch_default_interval_ms();
const char *v = std::getenv(kEnvParentWatchInterval());
if (v == nullptr || v[0] == '\0') {
return def;
}
const std::string s = detail::trim_lower(v, false);
if (s.empty()) {
return def;
}
size_t i = 0;
while (i < s.size() && (std::isdigit((unsigned char)s[i]) || s[i] == '.')) {
i++;
}
if (i == 0) {
return def;
}
double num = 0.0;
try {
num = std::stod(s.substr(0, i));
} catch (...) {
return def;
}
const std::string unit = s.substr(i);
long ms;
if (unit == "ms") {
ms = (long)num;
} else if (unit == "s" || unit.empty()) {
ms = (long)(num * 1000.0);
} else if (unit == "m") {
ms = (long)(num * 60000.0);
} else {
return def; // unrecognized unit
}
return ms > 0 ? ms : def;
}
#if !defined(_WIN32)
// parent_died reports whether this process has been reparented away from the
// parent it had when the watcher started. Reparenting is the standard POSIX
// signal that the original parent (here, the LocalAI process that spawned this
// backend) has exited: the orphan is handed to the nearest sub-reaper or to
// init (PID 1), so getppid() no longer matches the value captured at startup.
inline bool parent_died(pid_t orig_ppid) {
const pid_t ppid = getppid();
return ppid != orig_ppid || ppid == 1;
}
// watch_parent_death polls until parent_died reports the original parent is
// gone, then invokes on_death. It blocks, so run it on its own thread.
inline void watch_parent_death(pid_t orig_ppid, long interval_ms,
const std::function<void()> &on_death) {
for (;;) {
std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms));
if (parent_died(orig_ppid)) {
on_death();
return;
}
}
}
#endif
// start_parent_death_watcher installs the best-effort safety net described in
// the file header on the calling backend process. It is a no-op when disabled,
// on Windows, or when the process is already orphaned at startup
// (getppid() <= 1). This is a backstop alongside — never a replacement for —
// LocalAI's graceful teardown.
inline void start_parent_death_watcher() {
#if !defined(_WIN32)
if (!parent_watch_enabled()) {
return;
}
const pid_t orig_ppid = getppid();
// A parent of 1 (or less) at startup means we were already orphaned (or
// launched directly under init) — there is no original parent to watch for.
if (orig_ppid <= 1) {
return;
}
const long interval_ms = parent_watch_interval_ms();
std::thread([orig_ppid, interval_ms]() {
watch_parent_death(orig_ppid, interval_ms, [orig_ppid]() {
fprintf(stderr,
"backend parent process (pid %d) exited without stopping "
"this backend; self-terminating to avoid orphaning\n",
(int)orig_ppid);
fflush(stderr);
_exit(1);
});
}).detach();
#endif
}
} // namespace llama_grpc
#endif // LLAMA_GRPC_PARENT_WATCH_H

View File

@@ -0,0 +1,197 @@
// Unit tests for the parent-death watcher (parent_watch.h).
//
// Build & run standalone (C++ standard library only, no nlohmann/json needed):
// g++ -std=c++17 -pthread parent_watch_test.cpp -o t && ./t
//
// The core test (TestDetectsReparent) builds a genuine two-level process tree
// (test -> middle -> grandchild), lets the middle process die, and asserts the
// grandchild's watch_parent_death detects the reparenting and self-terminates —
// mirroring the Go test in pkg/grpc/parentwatch_test.go, but with fork(2).
//
// On Windows this file compiles to a no-op success (the watcher is unsupported
// there), matching parent_watch.h's platform gating.
#include <cstdio>
#include <cstdlib>
#include <string>
#include "parent_watch.h"
static int failures = 0;
static void check(bool ok, const std::string &name) {
if (!ok) {
failures++;
fprintf(stderr, "FAIL: %s\n", name.c_str());
} else {
fprintf(stderr, "ok: %s\n", name.c_str());
}
}
// Env-parsing tests are platform-independent and always run.
static void test_env_parsing() {
using namespace llama_grpc;
// Interval: default when unset.
unsetenv("LOCALAI_BACKEND_PARENT_WATCH_INTERVAL");
check(parent_watch_interval_ms() == 2000, "interval default 2000ms");
setenv("LOCALAI_BACKEND_PARENT_WATCH_INTERVAL", "500ms", 1);
check(parent_watch_interval_ms() == 500, "interval 500ms");
setenv("LOCALAI_BACKEND_PARENT_WATCH_INTERVAL", "2s", 1);
check(parent_watch_interval_ms() == 2000, "interval 2s");
setenv("LOCALAI_BACKEND_PARENT_WATCH_INTERVAL", "1m", 1);
check(parent_watch_interval_ms() == 60000, "interval 1m");
setenv("LOCALAI_BACKEND_PARENT_WATCH_INTERVAL", "3", 1); // bare number -> seconds
check(parent_watch_interval_ms() == 3000, "interval bare 3 -> 3000ms");
setenv("LOCALAI_BACKEND_PARENT_WATCH_INTERVAL", "garbage", 1);
check(parent_watch_interval_ms() == 2000, "interval garbage -> default");
unsetenv("LOCALAI_BACKEND_PARENT_WATCH_INTERVAL");
#if !defined(_WIN32)
// Enabled semantics (POSIX only; always false on Windows).
unsetenv("LOCALAI_BACKEND_PARENT_WATCH");
check(parent_watch_enabled(), "enabled by default");
for (const char *falsey : {"false", "0", "no", "off", "OFF", " False "}) {
setenv("LOCALAI_BACKEND_PARENT_WATCH", falsey, 1);
check(!parent_watch_enabled(), std::string("disabled by '") + falsey + "'");
}
setenv("LOCALAI_BACKEND_PARENT_WATCH", "true", 1);
check(parent_watch_enabled(), "enabled by 'true'");
setenv("LOCALAI_BACKEND_PARENT_WATCH", "1", 1);
check(parent_watch_enabled(), "enabled by '1'");
unsetenv("LOCALAI_BACKEND_PARENT_WATCH");
#endif
}
#if !defined(_WIN32)
#include <atomic>
#include <ctime>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
static bool file_exists(const std::string &p) {
struct stat st;
return ::stat(p.c_str(), &st) == 0;
}
static bool wait_for_file(const std::string &p, int timeout_ms) {
int waited = 0;
while (waited < timeout_ms) {
if (file_exists(p)) {
return true;
}
usleep(20 * 1000);
waited += 20;
}
return false;
}
static void write_file(const std::string &p, const std::string &content) {
FILE *f = fopen(p.c_str(), "w");
if (f) {
fwrite(content.data(), 1, content.size(), f);
fclose(f);
}
}
// Builds test -> middle -> grandchild via fork(2). The grandchild arms the REAL
// watch_parent_death against middle; middle exits, orphaning the grandchild;
// the watcher must detect the reparenting and self-terminate.
static void test_detects_reparent() {
char tmpl[] = "/tmp/parentwatch_test_XXXXXX";
char *dir = mkdtemp(tmpl);
if (dir == nullptr) {
check(false, "mkdtemp");
return;
}
const std::string ready_file = std::string(dir) + "/ready";
const std::string exited_file = std::string(dir) + "/exited";
pid_t middle = fork();
if (middle < 0) {
check(false, "fork middle");
return;
}
if (middle == 0) {
// ---- middle process ----
pid_t grandchild = fork();
if (grandchild < 0) {
_exit(4);
}
if (grandchild == 0) {
// ---- grandchild process ----
pid_t orig_ppid = getppid(); // == middle
std::thread([&]() {
llama_grpc::watch_parent_death(orig_ppid, 50 /*ms*/, [&]() {
write_file(exited_file, "1");
_exit(7);
});
}).detach();
// Safety valve: never linger if something goes wrong.
std::thread([]() {
usleep(30 * 1000 * 1000);
_exit(2);
}).detach();
// Signal readiness only after the watcher captured orig_ppid.
write_file(ready_file, std::to_string(getpid()));
for (;;) {
pause();
}
}
// middle: wait until grandchild is ready, then exit to orphan it.
if (!wait_for_file(ready_file, 10000)) {
_exit(5);
}
_exit(0);
}
// ---- test (top) process ----
int status = 0;
waitpid(middle, &status, 0); // reap middle only; grandchild is orphaned
check(file_exists(ready_file), "grandchild signaled readiness");
bool detected = wait_for_file(exited_file, 10000);
check(detected, "watcher detected parent death and self-terminated");
// Best-effort cleanup: kill the grandchild if it somehow survived.
if (file_exists(ready_file)) {
FILE *f = fopen(ready_file.c_str(), "r");
if (f) {
int pid = 0;
if (fscanf(f, "%d", &pid) == 1 && pid > 1) {
kill(pid, SIGKILL);
}
fclose(f);
}
}
unlink(ready_file.c_str());
unlink(exited_file.c_str());
rmdir(dir);
}
#endif // !_WIN32
int main() {
test_env_parsing();
#if !defined(_WIN32)
test_detects_reparent();
#endif
if (failures == 0) {
fprintf(stderr, "\nAll parent_watch tests passed.\n");
return 0;
}
fprintf(stderr, "\n%d parent_watch test(s) failed.\n", failures);
return 1;
}

View File

@@ -22,6 +22,10 @@ cp -r grpc-server.cpp llama.cpp/tools/grpc-server/
# unit test (compiled only when -DLLAMA_GRPC_BUILD_TESTS=ON).
cp -r message_content.h llama.cpp/tools/grpc-server/
cp -r message_content_test.cpp llama.cpp/tools/grpc-server/
# Parent-death watcher (included by grpc-server.cpp) and its standalone unit
# test (run via backend/cpp/run-unit-tests.sh; also buildable under ctest).
cp -r parent_watch.h llama.cpp/tools/grpc-server/
cp -r parent_watch_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/

View File

@@ -36,6 +36,12 @@ else
if [ -d "$CURDIR/lib/rocblas/library" ]; then
export ROCBLAS_TENSILE_LIBPATH="$CURDIR"/lib/rocblas/library
fi
# Same for hipBLASLt (rocblaslt): the bundled libhipblaslt.so resolves its
# TensileLibrary_lazy_gfx*.dat kernel data relative to itself, so point it at
# the bundled data or it falls back to slow generic kernels (issue #10660).
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
fi
# If there is a lib/ld.so, use it

View File

@@ -8,7 +8,7 @@
# Local development: point at a working checkout instead of cloning, e.g.
# make PRIVACY_FILTER_SRC=$HOME/c/privacy-filter.cpp grpc-server
PRIVACY_FILTER_VERSION?=98f52c5ef2250f207cc6b9a6aef05393a120cb7c
PRIVACY_FILTER_VERSION?=735a6c28607ee82afc3a670383f41b55266a3b9a
PRIVACY_FILTER_REPO?=https://github.com/localai-org/privacy-filter.cpp
PRIVACY_FILTER_SRC?=

View File

@@ -54,7 +54,7 @@ for test_src in "${tests[@]}"; do
name="$(basename "$test_src" .cpp)"
bin="$(mktemp -d)/$name"
echo "==> $test_src"
if ! "$CXX" -std=c++17 -Wall -Wextra \
if ! "$CXX" -std=c++17 -Wall -Wextra -pthread \
-I"$JSON_INC" -I"$(dirname "$test_src")" \
"$test_src" -o "$bin"; then
echo "COMPILE FAILED: $test_src" >&2

View File

@@ -34,6 +34,12 @@ else
if [ -d "$CURDIR/lib/rocblas/library" ]; then
export ROCBLAS_TENSILE_LIBPATH="$CURDIR"/lib/rocblas/library
fi
# Same for hipBLASLt (rocblaslt): the bundled libhipblaslt.so resolves its
# TensileLibrary_lazy_gfx*.dat kernel data relative to itself, so point it at
# the bundled data or it falls back to slow generic kernels (issue #10660).
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
fi
# If there is a lib/ld.so, use it

View File

@@ -25,7 +25,7 @@ target_include_directories(goacestepcpp PRIVATE ${ACESTEP_DIR}/src ${ACESTEP_DIR
target_include_directories(goacestepcpp SYSTEM PRIVATE ${ACESTEP_DIR}/ggml/include)
# Link GPU backends if available (mirrors link_ggml_backends macro)
foreach(backend blas cuda metal vulkan)
foreach(backend blas cuda hip metal vulkan)
if(TARGET ggml-${backend})
target_link_libraries(goacestepcpp PRIVATE ggml-${backend})
string(TOUPPER ${backend} BACKEND_UPPER)

View File

@@ -24,7 +24,14 @@ else ifeq ($(BUILD_TYPE),openblas)
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DGGML_HIPBLAS=ON
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
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
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(OS),Darwin)

View File

@@ -142,19 +142,12 @@ func buildAnthropicRequest(opts *pb.PredictOptions, cfg *proxyConfig, stream boo
if req.MaxTokens <= 0 {
req.MaxTokens = anthropicDefaultMaxTokens
}
// Newer Anthropic models 400 when both temperature and top_p are
// set ("`temperature` and `top_p` cannot both be specified for
// this model. Please use only one.") even though their docs only
// "recommend" picking one. The OpenAI-compatible chat UI almost
// always sends both with default values, so prefer temperature
// and drop top_p when both are present.
if t := opts.GetTemperature(); t != 0 {
v := float64(t)
req.Temperature = &v
} else if t := opts.GetTopP(); t != 0 {
v := float64(t)
req.TopP = &v
}
// Do not forward temperature/top_p. Newer Anthropic reasoning models reject
// requests that carry temperature ("`temperature` is deprecated for this
// model"), and the OpenAI-compatible clients typically send only the
// server-side DEFAULT sampling values rather than user intent — dropping
// them loses nothing and lets the upstream apply its own defaults.
_ = opts
req.Tools = convertOpenAITools(opts.GetTools())
req.ToolChoice = convertOpenAIToolChoice(opts.GetToolChoice())

View File

@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"io"
"math"
"net/http"
"net/http/httptest"
"strings"
@@ -75,15 +74,16 @@ func TestPredict_Anthropic_BasicMessages(t *testing.T) {
g.Expect(captured.Messages).To(HaveLen(1))
g.Expect(captured.Messages[0].Role).To(Equal("user"))
g.Expect(captured.MaxTokens).To(Equal(int32(32)))
g.Expect(captured.Temperature).NotTo(BeNil())
g.Expect(*captured.Temperature).To(Equal(0.5))
// Anthropic 400s when both temperature and top_p are set; the
// translator must prefer temperature and drop top_p.
// Newer Anthropic reasoning models reject requests carrying temperature
// ("`temperature` is deprecated for this model"); clients typically send
// only default sampling values, so the translator forwards neither.
g.Expect(captured.Temperature).To(BeNil())
g.Expect(captured.TopP).To(BeNil())
g.Expect(captured.Stream).To(BeFalse())
}
// When only top_p is set, it should be forwarded.
// Sampling parameters are not forwarded at all — the upstream applies its
// own defaults (newest models reject explicit temperature/top_p).
func TestPredict_Anthropic_TopPOnly(t *testing.T) {
g := NewWithT(t)
srv, captured := fakeAnthropicUpstream(t, func(_ anthropicRequest) (int, string, string) {
@@ -99,11 +99,7 @@ func TestPredict_Anthropic_TopPOnly(t *testing.T) {
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(captured.Temperature).To(BeNil())
// PredictOptions.TopP is float32 on the wire; the translator widens
// to float64 so 0.9 round-trips as 0.8999999761581421… — compare
// with a small tolerance rather than exact equality.
g.Expect(captured.TopP).NotTo(BeNil())
g.Expect(math.Abs(*captured.TopP - 0.9)).To(BeNumerically("<=", 1e-6))
g.Expect(captured.TopP).To(BeNil())
}
func TestPredict_Anthropic_DefaultsMaxTokens(t *testing.T) {

View File

@@ -30,7 +30,7 @@ type openAIRequest struct {
Stream bool `json:"stream,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
MaxTokens *int32 `json:"max_tokens,omitempty"`
MaxTokens *int32 `json:"max_completion_tokens,omitempty"` // newer OpenAI models reject max_tokens ("use max_completion_tokens instead")
Stop []string `json:"stop,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
@@ -107,14 +107,10 @@ func buildOpenAIRequest(opts *pb.PredictOptions, cfg *proxyConfig, stream bool)
Tools: parseRawJSON(opts.GetTools()),
ToolChoice: parseRawJSON(opts.GetToolChoice()),
}
if t := opts.GetTemperature(); t != 0 {
v := float64(t)
req.Temperature = &v
}
if t := opts.GetTopP(); t != 0 {
v := float64(t)
req.TopP = &v
}
// Do not forward temperature/top_p. Newer OpenAI reasoning models reject
// temperature as deprecated, and clients typically send only default
// sampling values rather than user intent — let the upstream apply its
// own defaults.
if n := opts.GetTokens(); n > 0 {
req.MaxTokens = &n
}

View File

@@ -74,8 +74,9 @@ func TestPredict_OpenAI_BasicChat(t *testing.T) {
g.Expect(captured.Messages).To(HaveLen(2))
g.Expect(captured.Messages[0].Role).To(Equal("system"))
g.Expect(captured.Messages[1].Role).To(Equal("user"))
g.Expect(captured.Temperature).NotTo(BeNil())
g.Expect(*captured.Temperature).To(Equal(0.5))
// Sampling parameters are not forwarded (newest models reject explicit
// temperature); token limit is serialized as max_completion_tokens.
g.Expect(captured.Temperature).To(BeNil())
g.Expect(captured.MaxTokens).NotTo(BeNil())
g.Expect(*captured.MaxTokens).To(Equal(int32(32)))
g.Expect(captured.Stream).To(BeFalse())

View File

@@ -17,7 +17,7 @@ target_link_libraries(gocrispasr PRIVATE
crispasr-lib
parakeet canary canary_ctc cohere granite_speech granite_nle
voxtral voxtral4b qwen3_asr qwen3_tts orpheus chatterbox indextts
kokoro voxcpm2_tts m2m100 t5_translate wav2vec2-ggml vibevoice
kokoro voxcpm2_tts m2m100 t5_translate wav2vec2-ggml vibevoice f5-tts
silero-lid pyannote-seg funasr paraformer sensevoice
crisp_audio
ggml)

View File

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

View File

@@ -530,11 +530,51 @@ func setVoice(voice string) {
}
}
// applyRequestVoice distinguishes named speakers from per-request reference
// WAVs. The latter are used by F5-TTS and require the exact transcript under
// the cross-backend params.ref_text contract.
func applyRequestVoice(req *pb.TTSRequest) error {
voice := strings.TrimSpace(req.Voice)
if voice == "" {
return nil
}
info, statErr := os.Stat(voice)
looksLikeFile := filepath.IsAbs(voice) || strings.EqualFold(filepath.Ext(voice), ".wav")
if statErr == nil && info.Mode().IsRegular() {
refText := ""
if req.Params != nil {
refText = strings.TrimSpace(req.Params["ref_text"])
if refText == "" {
refText = strings.TrimSpace(req.Params["voice_text"])
}
}
if refText == "" {
return fmt.Errorf("crispasr: params.ref_text is required with a reference voice WAV")
}
if rc := CppTTSSetVoiceFile(voice, refText); rc < 0 {
return fmt.Errorf("crispasr: failed to apply reference voice %q (rc=%d)", voice, rc)
}
return nil
}
if looksLikeFile {
if statErr != nil {
return fmt.Errorf("crispasr: reference voice %q: %w", voice, statErr)
}
return fmt.Errorf("crispasr: reference voice %q is not a regular file", voice)
}
setVoice(voice)
return nil
}
func (w *CrispASR) TTS(req *pb.TTSRequest) error {
if req.Dst == "" {
return fmt.Errorf("crispasr: TTS requires a destination path")
}
setVoice(req.Voice)
if err := applyRequestVoice(req); err != nil {
return err
}
pcm, err := w.synthesize(req.Text)
if err != nil {
return err
@@ -553,7 +593,9 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
if req.Text == "" {
return fmt.Errorf("crispasr: TTSStream requires text")
}
setVoice(req.Voice)
if err := applyRequestVoice(req); err != nil {
return err
}
pcm, err := w.synthesize(req.Text)
if err != nil {
return err

View File

@@ -172,6 +172,32 @@ var _ = Describe("CrispASR", func() {
})
Context("TTS", func() {
It("applies a per-request reference WAV and transcript", func() {
refWAV := filepath.Join(GinkgoT().TempDir(), "reference.wav")
Expect(os.WriteFile(refWAV, []byte("fixture"), 0o600)).To(Succeed())
original := CppTTSSetVoiceFile
DeferCleanup(func() { CppTTSSetVoiceFile = original })
var gotPath, gotText string
CppTTSSetVoiceFile = func(path, refText string) int {
gotPath, gotText = path, refText
return 0
}
Expect(applyRequestVoice(&pb.TTSRequest{
Voice: refWAV,
Params: map[string]string{"ref_text": "The exact words in the clip."},
})).To(Succeed())
Expect(gotPath).To(Equal(refWAV))
Expect(gotText).To(Equal("The exact words in the clip."))
})
It("rejects a reference WAV without a transcript", func() {
refWAV := filepath.Join(GinkgoT().TempDir(), "reference.wav")
Expect(os.WriteFile(refWAV, []byte("fixture"), 0o600)).To(Succeed())
Expect(applyRequestVoice(&pb.TTSRequest{Voice: refWAV})).To(MatchError(ContainSubstring("params.ref_text")))
})
It("synthesizes a non-empty WAV", func() {
ttsModel := ttsModelOrSkip()
ensureLibLoaded()
@@ -189,5 +215,32 @@ var _ = Describe("CrispASR", func() {
Expect(info.Size()).To(BeNumerically(">", 1024),
"expected a non-trivial WAV, got %d bytes", info.Size())
})
It("synthesizes with F5-TTS voice cloning (reference WAV + transcript)", func() {
// F5-TTS has no baked speaker: it clones from a reference WAV and
// its transcript, supplied via the voice/voice_text options. The
// spec skips unless all three fixtures are provided.
model := os.Getenv("CRISPASR_F5_MODEL_PATH")
refWav := os.Getenv("CRISPASR_F5_REF_WAV")
refText := os.Getenv("CRISPASR_F5_REF_TEXT")
if model == "" || refWav == "" || refText == "" {
Skip("set CRISPASR_F5_MODEL_PATH, CRISPASR_F5_REF_WAV and CRISPASR_F5_REF_TEXT to run this spec")
}
ensureLibLoaded()
w := &CrispASR{}
Expect(w.Load(&pb.ModelOptions{
ModelFile: model,
Options: []string{"voice:" + refWav, "voice_text:" + refText},
})).To(Succeed())
dst := filepath.Join(GinkgoT().TempDir(), "f5.wav")
Expect(w.TTS(&pb.TTSRequest{Text: "Hello from LocalAI running F5 text to speech.", Dst: dst})).To(Succeed())
info, err := os.Stat(dst)
Expect(err).ToNot(HaveOccurred(), "synthesized WAV should exist at %q", dst)
Expect(info.Size()).To(BeNumerically(">", 1024),
"expected a non-trivial WAV, got %d bytes", info.Size())
})
})
})

View File

@@ -1,6 +1,6 @@
# face-detect backend Makefile.
#
# Upstream pin lives below as FACEDETECT_VERSION?=06914b0... (.github/bump_deps.sh
# Upstream pin lives below as FACEDETECT_VERSION?=e22260d5d5490b37b021b7f795079f386d553afd
# can find and update it - matches the voice-detect / parakeet.cpp / whisper.cpp
# convention).
#
@@ -14,7 +14,7 @@
# The default target below does the proper clone-at-pin + cmake build so CI does
# not need a side-checkout.
FACEDETECT_VERSION?=06914b077d52f90d5421299138e7be6bdd06b5e8
FACEDETECT_VERSION?=e22260d5d5490b37b021b7f795079f386d553afd
FACEDETECT_REPO?=https://github.com/mudler/face-detect.cpp
GOCMD?=go

View File

@@ -22,6 +22,7 @@ import (
"fmt"
"math"
"slices"
"strings"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
@@ -57,12 +58,18 @@ func NewStore() *Store {
}
}
// Load is a no-op — local-store has no on-disk artefact. opts.Model is
// just a namespace identifier; isolation is already handled upstream
// (ModelLoader spawns a fresh local-store process per (backend,
// model) tuple, so each namespace is its own Store{} instance).
// Load only validates the namespace — local-store has no on-disk
// artefact. opts.Model is a namespace identifier, which core's
// StoreBackend always sends with store.NamespacePrefix; anything else
// is the model loader's greedy autoload probing with a real model name,
// which must be refused or the LLM binds to the vector store. Isolation
// is already handled upstream (ModelLoader spawns a fresh local-store
// process per (backend, model) tuple, so each namespace is its own
// Store{} instance).
func (s *Store) Load(opts *pb.ModelOptions) error {
_ = opts
if !strings.HasPrefix(opts.GetModel(), store.NamespacePrefix) {
return fmt.Errorf("local-store: refusing to load %q: not a store namespace (expected %q prefix)", opts.GetModel(), store.NamespacePrefix)
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"testing"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/store"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -186,8 +187,18 @@ var _ = Describe("StoresFind", func() {
})
var _ = Describe("StoresLoad", func() {
It("is a no-op", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: "any-namespace"})).To(Succeed())
It("accepts prefixed store namespaces", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: store.NamespacePrefix + "any-namespace"})).To(Succeed())
})
It("accepts the prefix alone (default store)", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: store.NamespacePrefix})).To(Succeed())
})
It("refuses model names without the namespace prefix", func() {
err := NewStore().Load(&pb.ModelOptions{Model: "some-llm.gguf"})
Expect(err).To(MatchError(ContainSubstring("not a store namespace")))
Expect(NewStore().Load(&pb.ModelOptions{})).NotTo(Succeed())
})
})

View File

@@ -10,7 +10,7 @@ JOBS?=$(shell nproc --ignore=1)
# this on `master` always picks up the latest C-API surface (incl. the
# per-detection accessor functions used by golocateanythingcpp.go).
LOCATEANYTHING_REPO?=https://github.com/mudler/locate-anything.cpp.git
LOCATEANYTHING_VERSION?=92c1682da792c1e8a5dec91acc2be4b02c742ded
LOCATEANYTHING_VERSION?=ade2634f7f79b56121125e5885628744795a478f
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF

View File

@@ -0,0 +1,12 @@
.cache/
sources/
build/
package/
moss-transcribe-cpp-grpc
# build artifacts staged in-tree by the Makefile (cp from sources/) or
# symlinked for local dev; the real sources live in moss-transcribe.cpp upstream.
*.so
*.so.*
*.dylib
moss_transcribe_capi.h
compile_commands.json

View File

@@ -0,0 +1,98 @@
# moss-transcribe-cpp backend Makefile.
#
# Upstream pin lives below as MOSS_VERSION?=92a923dca88a41a34e47a364d55ee25731a9a0a2
# (.github/bump_deps.sh) can find and update it - matches the
# whisper.cpp / parakeet-cpp / ds4 convention.
#
# Local dev shortcut: if you already have an out-of-tree moss-transcribe.cpp
# build, you can symlink the .so + header into this directory and skip the
# clone/cmake steps entirely, e.g.:
#
# ln -sf /path/to/moss-transcribe.cpp/build-shared/libmoss-transcribe.so .
# ln -sf /path/to/moss-transcribe.cpp/include/moss_transcribe_capi.h .
# go build -o moss-transcribe-cpp-grpc .
#
# That's what the L0 smoke test uses. The default target below does the proper
# clone-at-pin + cmake build so CI doesn't need a side-checkout.
MOSS_VERSION?=92a923dca88a41a34e47a364d55ee25731a9a0a2
MOSS_REPO?=https://github.com/mudler/moss-transcribe.cpp
GOCMD?=go
GO_TAGS?=
JOBS?=$(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
BUILD_TYPE?=
NATIVE?=false
# Build ggml statically into libmoss-transcribe.so (PIC) so the shared lib is
# self-contained: dlopen needs no libggml*.so alongside it, only system libs
# (libstdc++/libgomp/libc) that the runtime image already provides. MT_SHARED
# flips ggml to a static PIC build (see the moss-transcribe.cpp CMakeLists).
CMAKE_ARGS?=-DCMAKE_BUILD_TYPE=Release -DMT_SHARED=ON -DMT_BUILD_CLI=OFF -DBUILD_SHARED_LIBS=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
endif
# moss-transcribe.cpp gates its GGML backends behind MT_GGML_* options and does
# set(GGML_<be> ${MT_GGML_<be>} CACHE BOOL "" FORCE), so a bare -DGGML_CUDA=ON is
# overwritten back to OFF and the build silently falls back to CPU. Forward the
# MT_GGML_* options instead (openblas is not gated, so -DGGML_BLAS passes through).
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS+=-DMT_GGML_CUDA=ON -DGGML_CUDA_GRAPHS=ON
else ifeq ($(BUILD_TYPE),openblas)
CMAKE_ARGS+=-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DMT_GGML_HIP=ON
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DMT_GGML_VULKAN=ON
else ifeq ($(BUILD_TYPE),metal)
CMAKE_ARGS+=-DMT_GGML_METAL=ON
endif
.PHONY: moss-transcribe-cpp-grpc package build clean purge test all
all: moss-transcribe-cpp-grpc
# Clone the upstream moss-transcribe.cpp source at the pinned commit. Directory
# acts as the target so make only re-clones when missing. After a MOSS_VERSION
# bump, run 'make purge && make' to refetch.
sources/moss-transcribe.cpp:
mkdir -p sources/moss-transcribe.cpp
cd sources/moss-transcribe.cpp && \
git init -q && \
git remote add origin $(MOSS_REPO) && \
git fetch --depth 1 origin $(MOSS_VERSION) && \
git checkout FETCH_HEAD && \
git submodule update --init --recursive --depth 1 --single-branch
# Build the shared lib + header out-of-tree, then stage them next to the Go
# sources so purego.Dlopen("libmoss-transcribe.so") and the cgo-less build both
# pick them up.
libmoss-transcribe.so: sources/moss-transcribe.cpp
cmake -B sources/moss-transcribe.cpp/build-shared -S sources/moss-transcribe.cpp $(CMAKE_ARGS)
cmake --build sources/moss-transcribe.cpp/build-shared --config Release -j$(JOBS)
cp -fv sources/moss-transcribe.cpp/build-shared/libmoss-transcribe.so* ./ 2>/dev/null || true
cp -fv sources/moss-transcribe.cpp/build-shared/libmoss-transcribe.dylib ./ 2>/dev/null || true
cp -fv sources/moss-transcribe.cpp/include/moss_transcribe_capi.h ./
moss-transcribe-cpp-grpc: libmoss-transcribe.so main.go gomosstranscribecpp.go segments.go
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o moss-transcribe-cpp-grpc .
package: moss-transcribe-cpp-grpc
bash package.sh
build: package
# Test target. The model-backed smoke test is gated on
# MOSS_BACKEND_TEST_MODEL + MOSS_BACKEND_TEST_WAV; without them that spec
# auto-skips, leaving the pure-Go parser/unit tests.
test:
LD_LIBRARY_PATH=$(CURDIR):$$LD_LIBRARY_PATH $(GOCMD) test ./... -count=1
clean: purge
rm -rf libmoss-transcribe.so* moss_transcribe_capi.h package moss-transcribe-cpp-grpc
purge:
rm -rf sources/moss-transcribe.cpp

View File

@@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/utils"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// purego-bound entry points from libmoss-transcribe.so. Names match
// moss_transcribe_capi.h exactly so a `nm libmoss-transcribe.so | grep
// moss_transcribe_capi` is enough to spot drift.
//
// The transcribe_* functions return char* declared here as uintptr so we can
// call moss_transcribe_capi_free_string on the same pointer after copying: the
// C-API contract is "caller owns and must free the returned buffer".
var (
CppAbiVersion func() int32
CppLoad func(ggufPath string) uintptr
CppFree func(ctx uintptr)
CppTranscribePath func(ctx uintptr, wavPath string, maxNew int32) uintptr
CppTranscribePcm func(ctx uintptr, samples []float32, nSamples int32, sampleRate int32, maxNew int32) uintptr
CppFreeString func(s uintptr)
CppLastError func(ctx uintptr) string
)
// MossTranscribeCpp owns a single loaded moss_transcribe_ctx. MOSS is an
// offline transcription + diarization + timestamping engine: one model, one
// context, no streaming. The C engine holds a single mutable context and is
// not reentrant, so we embed base.SingleThread — LocalAI serialises every RPC
// through the server-level lock, and only one transcription touches the engine
// at a time.
type MossTranscribeCpp struct {
base.SingleThread
ctxPtr uintptr
maxNew int32
}
// Load is the LocalAI gRPC entry point for LoadModel: it calls
// moss_transcribe_capi_load with the GGUF path and stashes the resulting
// opaque context pointer for AudioTranscription.
func (m *MossTranscribeCpp) Load(opts *pb.ModelOptions) error {
if opts.ModelFile == "" {
return errors.New("moss-transcribe-cpp: ModelFile is required")
}
// max_new_tokens caps the generated tokens per transcription; <=0 uses the
// GGUF's default_max_new_tokens (the C-API's own default). Exposed as a
// model YAML option: (key:value form, like the sibling ggml backends).
m.maxNew = int32(optInt(opts, "max_new_tokens", 0))
ctx := CppLoad(opts.ModelFile)
if ctx == 0 {
// No ctx to ask for last_error (the C-API's last-error buffer lives on
// the ctx that was never returned). Surface the path so the operator at
// least knows which load failed.
return fmt.Errorf("moss-transcribe-cpp: moss_transcribe_capi_load failed for %q", opts.ModelFile)
}
m.ctxPtr = ctx
return nil
}
// optInt reads an integer model option (key:value form) from ModelOptions,
// returning def when absent or unparseable. The options array carries the
// model YAML's options: entries (see core/config; siblings such as parakeet-cpp
// parse the same key:value form via strings.Cut on ":").
func optInt(opts *pb.ModelOptions, key string, def int) int {
for _, o := range opts.GetOptions() {
k, v, ok := strings.Cut(o, ":")
if ok && strings.TrimSpace(k) == key {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return n
}
}
}
return def
}
// AudioTranscription converts the audio at opts.Dst to a 16 kHz mono WAV and
// hands the path to moss_transcribe_capi_transcribe_path. The model emits its
// own speaker-labelled, time-aligned transcript in the compact
// "[start][Sxx]text[end]..." format (seconds); we parse it into LocalAI
// TranscriptSegments carrying int64-nanosecond timestamps and the per-segment
// speaker label.
//
// MOSS does joint transcription + diarization + timestamps in one pass, so
// translate/language/prompt/temperature/threads are not applicable and are
// ignored. Streaming is not supported (offline model).
func (m *MossTranscribeCpp) AudioTranscription(ctx context.Context, opts *pb.TranscriptRequest) (pb.TranscriptResult, error) {
if m.ctxPtr == 0 {
return pb.TranscriptResult{}, grpcerrors.ModelNotLoaded("moss-transcribe-cpp")
}
if opts.Dst == "" {
return pb.TranscriptResult{}, errors.New("moss-transcribe-cpp: TranscriptRequest.dst (audio path) is required")
}
if err := ctx.Err(); err != nil {
return pb.TranscriptResult{}, status.Error(codes.Canceled, "transcription cancelled")
}
// The C loader understands WAV; convert any input (MP3, etc.) to 16 kHz
// mono WAV first - the same normalisation every other audio backend
// (whisper, parakeet-cpp) does via utils.AudioToWav before handing the file
// to the engine.
converted, cleanup, err := convertToWavMono16k(opts.Dst)
if err != nil {
return pb.TranscriptResult{}, err
}
defer cleanup()
cstr := CppTranscribePath(m.ctxPtr, converted, m.maxNew)
if cstr == 0 {
return pb.TranscriptResult{}, fmt.Errorf("moss-transcribe-cpp: transcribe_path failed: %s", CppLastError(m.ctxPtr))
}
raw := goStringFromCPtr(cstr)
CppFreeString(cstr)
return transcriptResultFromRaw(raw), nil
}
// Free releases the underlying moss_transcribe_ctx. Called by LocalAI when the
// model is unloaded.
func (m *MossTranscribeCpp) Free() error {
if m.ctxPtr != 0 {
CppFree(m.ctxPtr)
m.ctxPtr = 0
}
return nil
}
// convertToWavMono16k converts an arbitrary audio file to a 16 kHz mono WAV in
// a fresh temp dir and returns the path together with a cleanup func the caller
// must defer. WAV inputs already at 16 kHz/mono/16-bit are passed through by
// utils.AudioToWav (hardlink/copy), everything else is transcoded via ffmpeg.
func convertToWavMono16k(path string) (string, func(), error) {
dir, err := os.MkdirTemp("", "moss-transcribe")
if err != nil {
return "", func() {}, err
}
cleanup := func() { _ = os.RemoveAll(dir) }
converted := filepath.Join(dir, "converted.wav")
if err := utils.AudioToWav(path, converted); err != nil {
cleanup()
return "", func() {}, err
}
return converted, cleanup, nil
}
// goStringFromCPtr copies a NUL-terminated C string into Go memory. cptr is the
// raw pointer returned by purego from the C-API (a malloc'd buffer the caller
// owns); callers must free it via CppFreeString after the copy lands.
//
// The uintptr->unsafe.Pointer conversion below trips go vet's unsafeptr check,
// which can't distinguish a C-owned heap pointer from Go-managed memory. It is
// safe here: the pointer addresses a malloc'd C buffer the Go GC neither tracks
// nor moves, and we dereference it immediately to copy the bytes out (the same
// pattern the whisper / parakeet-cpp backends use).
func goStringFromCPtr(cptr uintptr) string {
if cptr == 0 {
return ""
}
p := unsafe.Pointer(cptr) //nolint:govet // C-owned malloc'd buffer, not Go-GC memory (see doc above)
n := 0
for *(*byte)(unsafe.Add(p, n)) != 0 {
n++
}
return string(unsafe.Slice((*byte)(p), n))
}

View File

@@ -0,0 +1,164 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/ebitengine/purego"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMossTranscribeCpp(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "moss-transcribe-cpp Backend Suite")
}
var (
libLoadOnce sync.Once
libLoadErr error
)
// ensureLibLoaded mirrors main.go's bootstrap so a Go test can drive the C-API
// bridge without spinning up the gRPC server. Skips the current spec when
// libmoss-transcribe.so isn't loadable from cwd ($LD_LIBRARY_PATH or a symlink
// in ./).
func ensureLibLoaded() {
libLoadOnce.Do(func() {
libName := os.Getenv("MOSS_TRANSCRIBE_LIBRARY")
if libName == "" {
libName = "libmoss-transcribe.so"
}
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
libLoadErr = err
return
}
purego.RegisterLibFunc(&CppAbiVersion, lib, "moss_transcribe_capi_abi_version")
purego.RegisterLibFunc(&CppLoad, lib, "moss_transcribe_capi_load")
purego.RegisterLibFunc(&CppFree, lib, "moss_transcribe_capi_free")
purego.RegisterLibFunc(&CppTranscribePath, lib, "moss_transcribe_capi_transcribe_path")
purego.RegisterLibFunc(&CppTranscribePcm, lib, "moss_transcribe_capi_transcribe_pcm")
purego.RegisterLibFunc(&CppFreeString, lib, "moss_transcribe_capi_free_string")
purego.RegisterLibFunc(&CppLastError, lib, "moss_transcribe_capi_last_error")
})
if libLoadErr != nil {
Skip("libmoss-transcribe.so not loadable: " + libLoadErr.Error())
}
}
// fixturesOrSkip returns the model + audio paths or skips the spec if either
// env var is unset. The smoke test never runs in default CI; it needs a real
// MOSS GGUF and a WAV on disk.
func fixturesOrSkip() (string, string) {
modelPath := os.Getenv("MOSS_BACKEND_TEST_MODEL")
audioPath := os.Getenv("MOSS_BACKEND_TEST_WAV")
if modelPath == "" || audioPath == "" {
Skip("set MOSS_BACKEND_TEST_MODEL and MOSS_BACKEND_TEST_WAV to run this spec")
}
return modelPath, audioPath
}
// writeMono16kWav writes `samples` frames of 16 kHz mono 16-bit silence to
// path. The result is already in AudioToWav's target format, so the conversion
// helper copies it through without invoking ffmpeg.
func writeMono16kWav(path string, samples int) {
GinkgoHelper()
f, err := os.Create(path)
Expect(err).ToNot(HaveOccurred())
enc := wav.NewEncoder(f, 16000, 16, 1, 1)
buf := &audio.IntBuffer{
Format: &audio.Format{NumChannels: 1, SampleRate: 16000},
SourceBitDepth: 16,
Data: make([]int, samples),
}
Expect(enc.Write(buf)).To(Succeed())
Expect(enc.Close()).To(Succeed())
Expect(f.Close()).To(Succeed())
}
var _ = Describe("MossTranscribeCpp", func() {
Context("ABI / load smoke (needs libmoss-transcribe.so)", func() {
It("reports a positive ABI version", func() {
ensureLibLoaded()
Expect(CppAbiVersion()).To(BeNumerically(">=", 1))
})
It("transcribes a WAV into speaker-labelled segments", func() {
modelPath, audioPath := fixturesOrSkip()
ensureLibLoaded()
m := &MossTranscribeCpp{}
Expect(m.Load(&pb.ModelOptions{ModelFile: modelPath})).To(Succeed())
defer func() { _ = m.Free() }()
res, err := m.AudioTranscription(context.Background(), &pb.TranscriptRequest{
Dst: audioPath,
})
Expect(err).ToNot(HaveOccurred())
Expect(strings.TrimSpace(res.Text)).ToNot(BeEmpty(),
"expected non-empty transcript for %s", audioPath)
Expect(res.Segments).ToNot(BeEmpty(), "expected at least one segment")
var prevEnd int64
for i, seg := range res.Segments {
Expect(strings.TrimSpace(seg.Text)).ToNot(BeEmpty(),
"segment %d must have text", i)
Expect(seg.End).To(BeNumerically(">=", seg.Start),
"segment %d end must not precede its start", i)
Expect(seg.Start).To(BeNumerically(">=", prevEnd),
"segments must be in time order")
prevEnd = seg.End
}
})
})
Context("Load validation", func() {
It("rejects an empty ModelFile", func() {
m := &MossTranscribeCpp{}
Expect(m.Load(&pb.ModelOptions{})).To(HaveOccurred())
})
})
Context("AudioTranscription guards (no C library required)", func() {
It("returns ModelNotLoaded when no context is loaded", func() {
m := &MossTranscribeCpp{}
_, err := m.AudioTranscription(context.Background(), &pb.TranscriptRequest{Dst: "x.wav"})
Expect(err).To(HaveOccurred())
})
It("errors when the audio path is empty", func() {
m := &MossTranscribeCpp{ctxPtr: 1}
_, err := m.AudioTranscription(context.Background(), &pb.TranscriptRequest{})
Expect(err).To(HaveOccurred())
})
})
Context("convertToWavMono16k", func() {
It("returns a decodable 16kHz mono WAV copy and cleans it up", func() {
dir := GinkgoT().TempDir()
src := filepath.Join(dir, "input.wav")
writeMono16kWav(src, 16000) // 1s of silence at 16 kHz
converted, cleanup, err := convertToWavMono16k(src)
Expect(err).ToNot(HaveOccurred())
Expect(converted).ToNot(Equal(src))
Expect(converted).To(BeAnExistingFile())
cleanup()
Expect(converted).ToNot(BeAnExistingFile(), "cleanup removes the temp dir")
})
It("errors on a non-existent input rather than passing the path through", func() {
_, _, err := convertToWavMono16k(filepath.Join(GinkgoT().TempDir(), "missing.mp3"))
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -0,0 +1,70 @@
package main
// Started internally by LocalAI - one gRPC server per loaded model.
//
// Loads the moss-transcribe shared library via purego and registers the flat
// C-API entry points declared in moss_transcribe_capi.h. The library name can
// be overridden with MOSS_TRANSCRIBE_LIBRARY (mirrors the WHISPER_LIBRARY /
// PARAKEET_LIBRARY convention in the sibling backends); the default looks next
// to this binary for libmoss-transcribe.so on Linux and
// libmoss-transcribe.dylib on macOS.
import (
"flag"
"fmt"
"os"
"runtime"
"github.com/ebitengine/purego"
grpc "github.com/mudler/LocalAI/pkg/grpc"
)
var (
addr = flag.String("addr", "localhost:50051", "the address to connect to")
)
type LibFuncs struct {
FuncPtr any
Name string
}
func main() {
libName := os.Getenv("MOSS_TRANSCRIBE_LIBRARY")
if libName == "" {
if runtime.GOOS == "darwin" {
libName = "libmoss-transcribe.dylib"
} else {
libName = "libmoss-transcribe.so"
}
}
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
panic(fmt.Errorf("moss-transcribe-cpp: dlopen %q: %w", libName, err))
}
// Bound 1:1 to moss_transcribe_capi.h. The transcribe_* entry points return
// a malloc'd char* the caller owns; we register those as uintptr so we get
// the raw pointer back and can call moss_transcribe_capi_free_string on it
// (purego's string return would copy and forget the original pointer,
// leaking it on every call).
libFuncs := []LibFuncs{
{&CppAbiVersion, "moss_transcribe_capi_abi_version"},
{&CppLoad, "moss_transcribe_capi_load"},
{&CppFree, "moss_transcribe_capi_free"},
{&CppTranscribePath, "moss_transcribe_capi_transcribe_path"},
{&CppTranscribePcm, "moss_transcribe_capi_transcribe_pcm"},
{&CppFreeString, "moss_transcribe_capi_free_string"},
{&CppLastError, "moss_transcribe_capi_last_error"},
}
for _, lf := range libFuncs {
purego.RegisterLibFunc(lf.FuncPtr, lib, lf.Name)
}
fmt.Fprintf(os.Stderr, "[moss-transcribe-cpp] ABI=%d\n", CppAbiVersion())
flag.Parse()
if err := grpc.StartServer(*addr, &MossTranscribeCpp{}); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,71 @@
#!/bin/bash
#
# Bundle the moss-transcribe-cpp-grpc binary, libmoss-transcribe.so, the core
# runtime libs (libc/libstdc++/libgomp + ld.so) and the GPU runtime for the
# active BUILD_TYPE so the package is self-contained. Mirrors
# backend/go/parakeet-cpp/package.sh; run.sh routes the (CGO_ENABLED=0) binary
# through lib/ld.so so the packaged libc is used instead of the host's.
set -e
CURDIR=$(dirname "$(realpath "$0")")
REPO_ROOT="${CURDIR}/../../.."
mkdir -p "$CURDIR/package/lib"
cp -avf "$CURDIR/moss-transcribe-cpp-grpc" "$CURDIR/package/"
cp -avf "$CURDIR/run.sh" "$CURDIR/package/"
# libmoss-transcribe shared lib + any soname symlinks. On Linux this is
# libmoss-transcribe.so[.X.Y]; on macOS it is libmoss-transcribe.dylib.
# purego.Dlopen resolves it via the *_LIBRARY_PATH that run.sh points at lib/.
cp -avf "$CURDIR"/libmoss-transcribe.so* "$CURDIR/package/lib/" 2>/dev/null || true
cp -avf "$CURDIR"/libmoss-transcribe.dylib "$CURDIR/package/lib/" 2>/dev/null || true
if ! ls "$CURDIR"/package/lib/libmoss-transcribe.* >/dev/null 2>&1; then
echo "ERROR: libmoss-transcribe shared library not found in $CURDIR, run 'make' first" >&2
exit 1
fi
# Detect architecture and copy the core runtime libs libmoss-transcribe.so links
# against, plus the matching dynamic loader as lib/ld.so.
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$CURDIR/package/lib/ld.so"
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 "$CURDIR/package/lib/libc.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 "$CURDIR/package/lib/libgcc_s.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 "$CURDIR/package/lib/libstdc++.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 "$CURDIR/package/lib/libm.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 "$CURDIR/package/lib/libgomp.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 "$CURDIR/package/lib/libdl.so.2"
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 "$CURDIR/package/lib/librt.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 "$CURDIR/package/lib/libpthread.so.0"
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 "$CURDIR/package/lib/ld.so"
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 "$CURDIR/package/lib/libc.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 "$CURDIR/package/lib/libgcc_s.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 "$CURDIR/package/lib/libstdc++.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 "$CURDIR/package/lib/libm.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 "$CURDIR/package/lib/libgomp.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 "$CURDIR/package/lib/libdl.so.2"
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 "$CURDIR/package/lib/librt.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 "$CURDIR/package/lib/libpthread.so.0"
elif [ "$(uname -s)" = "Darwin" ]; then
echo "Detected Darwin — system frameworks linked dynamically, no bundled libs needed"
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries (CUDA/ROCm/Intel/Vulkan loader + ICDs + drivers) based
# on BUILD_TYPE so the backend can reach the GPU without the runtime base image
# shipping those drivers.
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah "$CURDIR/package/" "$CURDIR/package/lib/"

View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
CURDIR=$(dirname "$(realpath "$0")")
if [ "$(uname)" = "Darwin" ]; then
export DYLD_LIBRARY_PATH="$CURDIR/lib:"$CURDIR":${DYLD_LIBRARY_PATH:-}"
export MOSS_TRANSCRIBE_LIBRARY="$CURDIR/lib/libmoss-transcribe.dylib"
else
export LD_LIBRARY_PATH="$CURDIR/lib:"$CURDIR":${LD_LIBRARY_PATH:-}"
export MOSS_TRANSCRIBE_LIBRARY="$CURDIR/lib/libmoss-transcribe.so"
fi
# If a self-contained ld.so was packaged, route through it so the packaged libc
# / libstdc++ are used instead of the host's (matches the whisper / parakeet-cpp
# backends' runtime layout). Linux only.
if [ -f "$CURDIR/lib/ld.so" ]; then
echo "Using lib/ld.so"
exec "$CURDIR/lib/ld.so" "$CURDIR/moss-transcribe-cpp-grpc" "$@"
fi
exec "$CURDIR/moss-transcribe-cpp-grpc" "$@"

View File

@@ -0,0 +1,156 @@
package main
import (
"regexp"
"strconv"
"strings"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// mossSegment is one parsed unit of the MOSS transcript: a speaker-labelled
// span with fractional-second start/end times, straight from the model's own
// output. Timestamps stay in seconds here; secondsToNanos converts them at the
// TranscriptSegment boundary.
type mossSegment struct {
Start float64
End float64
Speaker string
Text string
}
// bracketRe matches one "[...]" token (timestamp or speaker tag). The MOSS
// transcript is a concatenation of "[start][Sxx]text[end]" segments, e.g.
//
// [0.28][S01] And so, my fellow Americans,[7.71][8.12][S02] ask ...[10.59]
//
// so the bracketed tokens carry all the structure and the free text lives
// between a speaker tag and the following (end) timestamp.
var bracketRe = regexp.MustCompile(`\[([^\]]*)\]`)
// speakerRe matches a speaker tag: an 'S' (any case) followed by one or more
// digits, e.g. "S01", "S12". Anything else in brackets that isn't a speaker tag
// is treated as a timestamp candidate.
var speakerRe = regexp.MustCompile(`^[Ss][0-9]+$`)
// bracketToken is a "[...]" token plus the free text that follows it up to the
// next token (or end of string). For a speaker tag that trailing text is the
// segment's transcript.
type bracketToken struct {
content string
textAfter string
}
// parseTranscript parses the compact "[start][Sxx]text[end]..." MOSS transcript
// into structured segments. It walks the bracket tokens looking for a
// start-timestamp, a speaker tag, and an end-timestamp, taking the text that
// follows the speaker tag as the segment transcript. Tokens that don't fit the
// grammar are skipped rather than aborting the parse, so a slightly malformed
// stream still yields the segments it can.
func parseTranscript(raw string) []mossSegment {
locs := bracketRe.FindAllStringSubmatchIndex(raw, -1)
toks := make([]bracketToken, len(locs))
for i, m := range locs {
// m[2]:m[3] is the capture group (the content inside the brackets);
// m[1] is the byte just past the closing ']'.
nextStart := len(raw)
if i+1 < len(locs) {
nextStart = locs[i+1][0]
}
toks[i] = bracketToken{
content: raw[m[2]:m[3]],
textAfter: raw[m[1]:nextStart],
}
}
var segs []mossSegment
i := 0
for i < len(toks) {
start, ok := parseTimestamp(toks[i].content)
if !ok {
i++
continue
}
// A start timestamp must be followed by a speaker tag; otherwise this
// isn't a segment head, so skip it.
if i+1 >= len(toks) || !isSpeakerTag(toks[i+1].content) {
i++
continue
}
speaker := strings.ToUpper(strings.TrimSpace(toks[i+1].content))
text := strings.TrimSpace(toks[i+1].textAfter)
// The end timestamp is the next token if present and numeric; a segment
// that runs to the end of the stream without a closing timestamp falls
// back to start==end.
end := start
consumed := 2
if i+2 < len(toks) {
if e, ok := parseTimestamp(toks[i+2].content); ok {
end = e
consumed = 3
}
}
segs = append(segs, mossSegment{Start: start, End: end, Speaker: speaker, Text: text})
i += consumed
}
return segs
}
// isSpeakerTag reports whether the bracket content is a MOSS speaker tag ("Sxx").
func isSpeakerTag(content string) bool {
return speakerRe.MatchString(strings.TrimSpace(content))
}
// parseTimestamp parses a bracket content as a fractional-second timestamp. A
// speaker tag ("S01") never parses as a float, so this cleanly distinguishes
// the two token kinds.
func parseTimestamp(content string) (float64, bool) {
f, err := strconv.ParseFloat(strings.TrimSpace(content), 64)
if err != nil {
return 0, false
}
return f, true
}
// secondsToNanos converts the transcript's fractional-second timestamps into
// the int64 nanoseconds LocalAI carries on TranscriptSegment, the same
// nanosecond convention the whisper / parakeet-cpp backends use.
func secondsToNanos(sec float64) int64 {
return int64(sec * 1e9)
}
// transcriptResultFromRaw parses the raw MOSS transcript and shapes it into a
// TranscriptResult. Each parsed segment becomes a TranscriptSegment with
// nanosecond start/end and the model's own speaker label; Text is the segments
// joined with single spaces. When nothing parses (no bracket structure) it
// falls back to a single whole-clip text segment so callers always get a
// transcript.
func transcriptResultFromRaw(raw string) pb.TranscriptResult {
segs := parseTranscript(raw)
if len(segs) == 0 {
text := strings.TrimSpace(raw)
return pb.TranscriptResult{
Text: text,
Segments: []*pb.TranscriptSegment{{Id: 0, Text: text}},
}
}
var full strings.Builder
pbSegs := make([]*pb.TranscriptSegment, 0, len(segs))
for id, s := range segs {
if id > 0 && s.Text != "" {
full.WriteString(" ")
}
full.WriteString(s.Text)
pbSegs = append(pbSegs, &pb.TranscriptSegment{
Id: int32(id),
Start: secondsToNanos(s.Start),
End: secondsToNanos(s.End),
Text: s.Text,
Speaker: s.Speaker,
})
}
return pb.TranscriptResult{Text: strings.TrimSpace(full.String()), Segments: pbSegs}
}

View File

@@ -0,0 +1,104 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parseTranscript ([start][Sxx]text[end] grammar)", func() {
It("parses the README two-speaker example into ordered segments", func() {
raw := "[0.28][S01] And so, my fellow Americans, ask not what your country can do for you,[7.71][8.12][S02] ask what you can do for your country.[10.59]"
segs := parseTranscript(raw)
Expect(segs).To(HaveLen(2))
Expect(segs[0].Speaker).To(Equal("S01"))
Expect(segs[0].Start).To(BeNumerically("~", 0.28, 1e-9))
Expect(segs[0].End).To(BeNumerically("~", 7.71, 1e-9))
Expect(segs[0].Text).To(Equal("And so, my fellow Americans, ask not what your country can do for you,"))
Expect(segs[1].Speaker).To(Equal("S02"))
Expect(segs[1].Start).To(BeNumerically("~", 8.12, 1e-9))
Expect(segs[1].End).To(BeNumerically("~", 10.59, 1e-9))
Expect(segs[1].Text).To(Equal("ask what you can do for your country."))
})
It("parses a single segment", func() {
segs := parseTranscript("[1.50][S01] hello world[3.25]")
Expect(segs).To(HaveLen(1))
Expect(segs[0].Start).To(BeNumerically("~", 1.50, 1e-9))
Expect(segs[0].End).To(BeNumerically("~", 3.25, 1e-9))
Expect(segs[0].Speaker).To(Equal("S01"))
Expect(segs[0].Text).To(Equal("hello world"))
})
It("uppercases a lowercase speaker tag", func() {
segs := parseTranscript("[0.00][s03] hi[0.90]")
Expect(segs).To(HaveLen(1))
Expect(segs[0].Speaker).To(Equal("S03"))
})
It("falls back to start==end when the closing timestamp is missing", func() {
segs := parseTranscript("[2.00][S01] trailing words with no end")
Expect(segs).To(HaveLen(1))
Expect(segs[0].Start).To(BeNumerically("~", 2.00, 1e-9))
Expect(segs[0].End).To(Equal(segs[0].Start))
Expect(segs[0].Text).To(Equal("trailing words with no end"))
})
It("handles multi-digit speaker ids and higher speaker counts", func() {
segs := parseTranscript("[0.0][S01] a[1.0][1.0][S12] b[2.0]")
Expect(segs).To(HaveLen(2))
Expect(segs[0].Speaker).To(Equal("S01"))
Expect(segs[1].Speaker).To(Equal("S12"))
})
It("returns no segments for text without bracket structure", func() {
Expect(parseTranscript("just some plain text")).To(BeEmpty())
Expect(parseTranscript("")).To(BeEmpty())
})
It("skips a start timestamp not followed by a speaker tag", func() {
// A stray timestamp pair without a speaker tag is not a segment head.
segs := parseTranscript("[1.0][2.0][S01] real[3.0]")
Expect(segs).To(HaveLen(1))
Expect(segs[0].Speaker).To(Equal("S01"))
Expect(segs[0].Text).To(Equal("real"))
})
})
var _ = Describe("transcriptResultFromRaw", func() {
It("shapes parsed segments into a TranscriptResult with nanosecond times and speakers", func() {
raw := "[0.28][S01] first part,[7.71][8.12][S02] second part.[10.59]"
res := transcriptResultFromRaw(raw)
Expect(res.Segments).To(HaveLen(2))
Expect(res.Segments[0].Id).To(Equal(int32(0)))
Expect(res.Segments[0].Start).To(Equal(secondsToNanos(0.28)))
Expect(res.Segments[0].End).To(Equal(secondsToNanos(7.71)))
Expect(res.Segments[0].Speaker).To(Equal("S01"))
Expect(res.Segments[0].Text).To(Equal("first part,"))
Expect(res.Segments[1].Id).To(Equal(int32(1)))
Expect(res.Segments[1].Speaker).To(Equal("S02"))
Expect(res.Segments[1].Start).To(Equal(secondsToNanos(8.12)))
// Full text is the segments joined with single spaces.
Expect(res.Text).To(Equal("first part, second part."))
})
It("falls back to a single whole-clip text segment when nothing parses", func() {
res := transcriptResultFromRaw("plain transcript with no markers")
Expect(res.Segments).To(HaveLen(1))
Expect(res.Segments[0].Text).To(Equal("plain transcript with no markers"))
Expect(res.Segments[0].Start).To(Equal(int64(0)))
Expect(res.Text).To(Equal("plain transcript with no markers"))
})
})
var _ = Describe("secondsToNanos", func() {
It("converts fractional seconds to int64 nanoseconds", func() {
Expect(secondsToNanos(0)).To(Equal(int64(0)))
Expect(secondsToNanos(1)).To(Equal(int64(1e9)))
Expect(secondsToNanos(0.28)).To(Equal(int64(0.28 * 1e9)))
})
})

View File

@@ -30,7 +30,7 @@ target_include_directories(gomnivoicecpp PRIVATE ${OMNIVOICE_DIR}/src)
target_include_directories(gomnivoicecpp SYSTEM PRIVATE ${OMNIVOICE_DIR}/ggml/include)
# Link GPU backends if the upstream ggml created them.
foreach(backend blas cuda metal vulkan sycl)
foreach(backend blas cuda hip metal vulkan sycl)
if(TARGET ggml-${backend})
target_link_libraries(gomnivoicecpp PRIVATE ggml-${backend})
if(backend STREQUAL "cuda")

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# omnivoice.cpp version
OMNIVOICE_REPO?=https://github.com/ServeurpersoCom/omnivoice.cpp
OMNIVOICE_VERSION?=0f37401bebe9b20c0160a888e592108fc1d17607
OMNIVOICE_VERSION?=bbdf4be03aa5bc3c188b5db778b6f3fd63ceff6c
SO_TARGET?=libgomnivoicecpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
@@ -24,7 +24,14 @@ else ifeq ($(BUILD_TYPE),openblas)
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DGGML_HIPBLAS=ON
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
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
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(OS),Darwin)

View File

@@ -34,7 +34,15 @@ type Opus struct {
decoders map[string]*cachedDecoder
}
// Load accepts only the codec's own name (what the realtime WebRTC path
// sends) or no name at all — there is no model artefact here, so without
// this check the model loader's greedy autoload (which probes every
// installed backend with real model names) would happily bind an LLM to
// the audio codec.
func (o *Opus) Load(opts *pb.ModelOptions) error {
if m := opts.GetModel(); m != "" && m != "opus" {
return fmt.Errorf("opus: refusing to load %q: opus is an audio codec, not a model backend", m)
}
o.decoders = make(map[string]*cachedDecoder)
go o.evictLoop()
return Init()

View File

@@ -304,6 +304,17 @@ func computeTHD(samples []int16, fundamentalHz float64, sampleRate, numHarmonics
// --- Opus specs ---
var _ = Describe("Opus Load", func() {
It("accepts its own name (the realtime WebRTC path)", func() {
Expect((&Opus{}).Load(&pb.ModelOptions{Model: "opus"})).To(Succeed())
})
It("refuses foreign model names from the greedy autoload", func() {
err := (&Opus{}).Load(&pb.ModelOptions{Model: "some-llm.gguf"})
Expect(err).To(MatchError(ContainSubstring("audio codec")))
})
})
var _ = Describe("Opus", func() {
var o *Opus

View File

@@ -1,6 +1,6 @@
# parakeet-cpp backend Makefile.
#
# Upstream pin lives below as PARAKEET_VERSION?=f469a57270a1cc4554acb15febf60e56619673b9
# Upstream pin lives below as PARAKEET_VERSION?=e8acc6172a94e20a952cf1843decace5d771a94b
# (.github/bump_deps.sh) can find and update it - matches the
# whisper.cpp / ds4 / vibevoice-cpp convention.
#
@@ -15,7 +15,7 @@
# That's what the L0 smoke test uses. The default target below does the
# proper clone-at-pin + cmake build so CI doesn't need a side-checkout.
PARAKEET_VERSION?=f469a57270a1cc4554acb15febf60e56619673b9
PARAKEET_VERSION?=e8acc6172a94e20a952cf1843decace5d771a94b
PARAKEET_REPO?=https://github.com/mudler/parakeet.cpp
GOCMD?=go

View File

@@ -0,0 +1,81 @@
package main
// utteranceBoundary is the single definition of a small state machine that was
// previously open-coded three times — as a bare `finalEou` bool with an ad-hoc
// toggle — in the live feed (live.go), the file-stream text path, and the
// file-stream JSON path (goparakeetcpp.go).
//
// It answers one running question: does the decode currently rest on an
// end-of-utterance boundary? That is the value a closing FinalResult reports as
// .Eou and the realtime turn detector treats as a commit point.
//
// parakeet auto-resets its decoder after every <EOU>/<EOB>, so one streaming
// session is a sequence of utterances and this is a LATCH, not a monotonic
// flag: it closes on an <EOU> and reopens as soon as the next utterance starts.
// (Contrast the realtime API's per-turn `eouSeen`, which only ever goes
// false->true because each turn gets a fresh stream. Here the stream outlives
// the turn, so the boundary status must be able to reopen.)
//
// The only transitions, over the events one streamFeedResult carries — an
// <EOU>, an <EOB> (backchannel), or plain speech output (text and/or words):
//
// <EOU>
// open ───────────► closed
// ▲ ▲ │ │ │
// │ └─┘ <EOB>|speech │ │ <EOU>
// │ (stay open) │ └─┘ (stay closed)
// └──────────────────┘
// <EOB>|speech
//
// open = NOT on an utterance boundary: mid-utterance, the last boundary was
// a backchannel <EOB>, or the stream just began (the initial state).
// closed = the last meaningful event was an <EOU> with no later speech: a real
// turn boundary.
//
// A feed that carries nothing (no eou/eob/text/words — e.g. a finalize flush
// that produced no tail) is a no-op and leaves the state unchanged, matching
// the legacy "leave finalEou as it was" behaviour.
//
// The state carries no data, so it is modelled as a two-valued type (a named
// bool) rather than an int enum: every inhabitant is legal, so illegal states
// are unrepresentable — the payload-free analog of the sealed sum types the
// realtime machines use (those need interfaces because their states carry data,
// e.g. Active{ID}, where "Active with no ID" is the illegal combination a scalar
// cannot even express).
type utteranceBoundary bool
const (
// boundaryOpen is the zero value (false), so a fresh decode starts open —
// exactly the legacy `var finalEou bool` (false) initial condition.
boundaryOpen utteranceBoundary = false
boundaryClosed utteranceBoundary = true
)
// observe folds one decode increment into the latch and returns the new state.
//
// <EOU> takes priority when a single feed carries both an <EOU> and speech
// (e.g. {"text":"hello","eou":1}): the utterance both produced that text AND
// ended, so the decode rests on the boundary. This matches the legacy
// eou-checked-first ordering at every call site.
func (b utteranceBoundary) observe(r streamFeedResult) utteranceBoundary {
switch {
case r.Eou:
return boundaryClosed
case r.Eob || r.Delta != "" || len(r.Words) > 0:
return boundaryOpen
default:
return b
}
}
// ended reports whether the decode currently rests on an end-of-utterance
// boundary (a real <EOU>, not a backchannel <EOB>). This is what a closing
// FinalResult carries as .Eou.
func (b utteranceBoundary) ended() bool { return b == boundaryClosed }
func (b utteranceBoundary) String() string {
if b == boundaryClosed {
return "closed"
}
return "open"
}

View File

@@ -0,0 +1,92 @@
package main
import (
"math/rand/v2"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("utteranceBoundary (decode end-of-utterance latch)", func() {
It("starts open: a fresh decode is not on a boundary", func() {
var b utteranceBoundary
Expect(b).To(Equal(boundaryOpen))
Expect(b.ended()).To(BeFalse())
})
DescribeTable("single feed transition from the open state",
func(r streamFeedResult, wantEnded bool) {
Expect(boundaryOpen.observe(r).ended()).To(Equal(wantEnded))
},
Entry("<EOU> closes it", streamFeedResult{Eou: true}, true),
Entry("<EOU> with text closes it (eou wins)", streamFeedResult{Delta: "hi", Eou: true}, true),
Entry("<EOB> stays open (backchannel is not a turn boundary)", streamFeedResult{Eob: true}, false),
Entry("plain text stays open", streamFeedResult{Delta: "hello"}, false),
Entry("words-only stays open", streamFeedResult{Words: []transcriptWord{{W: "x"}}}, false),
Entry("empty feed is a no-op (stays open)", streamFeedResult{}, false),
)
DescribeTable("single feed transition from the closed state",
func(r streamFeedResult, wantEnded bool) {
Expect(boundaryClosed.observe(r).ended()).To(Equal(wantEnded))
},
Entry("another <EOU> stays closed", streamFeedResult{Eou: true}, true),
Entry("trailing speech reopens it", streamFeedResult{Delta: "and more"}, false),
Entry("words reopen it", streamFeedResult{Words: []transcriptWord{{W: "x"}}}, false),
Entry("a backchannel <EOB> reopens it", streamFeedResult{Eob: true}, false),
Entry("empty feed is a no-op (stays closed)", streamFeedResult{}, true),
)
It("is a latch: <EOU> then trailing speech reopens, then <EOU> closes again", func() {
b := boundaryOpen
b = b.observe(streamFeedResult{Delta: "turn one", Eou: true})
Expect(b.ended()).To(BeTrue())
b = b.observe(streamFeedResult{Delta: " and more"})
Expect(b.ended()).To(BeFalse(), "trailing speech without an EOU is an open utterance")
b = b.observe(streamFeedResult{Eou: true})
Expect(b.ended()).To(BeTrue())
})
It("treats a backchannel before a real EOU correctly", func() {
b := boundaryOpen
b = b.observe(streamFeedResult{Delta: "uh huh", Eob: true})
Expect(b.ended()).To(BeFalse(), "a backchannel must not masquerade as a turn boundary")
b = b.observe(streamFeedResult{Delta: "done", Eou: true})
Expect(b.ended()).To(BeTrue())
})
It("matches the reference fold over seeded random feed sequences", func() {
// The invariant: after any sequence of feeds, ended() is true iff the
// last feed that carried ANY event was an <EOU>. <EOU> takes priority
// when a feed carries both an EOU and speech; empty feeds are ignored.
for seed := uint64(1); seed <= 200; seed++ {
rng := rand.New(rand.NewPCG(seed, seed*2654435761))
b := boundaryOpen
lastWasEou := false // reference: did the last meaningful feed end on EOU?
steps := rng.IntN(30)
for i := 0; i < steps; i++ {
var r streamFeedResult
switch rng.IntN(5) {
case 0:
r = streamFeedResult{Eou: true}
case 1:
r = streamFeedResult{Eob: true}
case 2:
r = streamFeedResult{Delta: "w"}
case 3:
r = streamFeedResult{Delta: "w", Eou: true} // eou + speech, eou wins
case 4:
r = streamFeedResult{} // empty: no-op
}
b = b.observe(r)
if r.Eou {
lastWasEou = true
} else if r.Eob || r.Delta != "" || len(r.Words) > 0 {
lastWasEou = false
}
}
Expect(b.ended()).To(Equal(lastWasEou),
"seed %d: latch disagreed with the reference fold", seed)
}
})
})

View File

@@ -0,0 +1,82 @@
package main
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// streamFeedResult is one decode increment from a cache-aware streaming session:
// the newly-finalized text plus the model's own per-feed boundary tokens
// (<EOU>/<EOB>) and word timings. It is the single event type both the live
// (bidi) and file (server-stream) paths fold over, hiding the ABI v4 JSON vs
// older text-only entry-point split behind one shape.
type streamFeedResult struct {
Delta string
Eou bool
Eob bool
Words []transcriptWord
}
// feedChunk feeds one PCM chunk to the streaming session (or finalizes it, when
// finalize is true) and returns the unified decode increment. It prefers the
// ABI v4 JSON entry points (which also carry per-word timestamps) and falls
// back to the older text-only entry points against an older libparakeet.so.
//
// This is the one place the JSON-vs-text choice is made; every consumer works
// in terms of streamFeedResult.
func (p *ParakeetCpp) feedChunk(stream uintptr, pcm []float32, finalize bool) (streamFeedResult, error) {
if CppStreamFeedJSON != nil {
doc, err := p.streamFeedDoc(stream, pcm, finalize)
if err != nil {
return streamFeedResult{}, err
}
return streamFeedResult{Delta: doc.Text, Eou: doc.Eou != 0, Eob: doc.Eob != 0, Words: doc.Words}, nil
}
delta, eou, eob, err := p.streamFeedText(stream, pcm, finalize)
if err != nil {
return streamFeedResult{}, err
}
return streamFeedResult{Delta: delta, Eou: eou, Eob: eob}, nil
}
// feedSlices feeds pcm through the session in streamChunkSamples slices,
// invoking onFeed for each decode increment. It does NOT finalize: callers
// decide when the send side is done. The file path finalizes after the whole
// file; the live path finalizes only when its request channel closes, never
// between audio messages. Slicing keeps each per-call engineMu hold short so
// concurrent unary transcription interleaves fairly (the C session buffers
// internally).
//
// If ctx is non-nil it is checked before each slice so a cancelled file
// transcription stops promptly; the live path passes nil (it is bounded by its
// request channel instead of a ctx).
func (p *ParakeetCpp) feedSlices(ctx context.Context, stream uintptr, pcm []float32, onFeed func(streamFeedResult) error) error {
for off := 0; off < len(pcm); off += streamChunkSamples {
if ctx != nil {
if err := ctx.Err(); err != nil {
return status.Error(codes.Canceled, "transcription cancelled")
}
}
end := min(off+streamChunkSamples, len(pcm))
res, err := p.feedChunk(stream, pcm[off:end], false)
if err != nil {
return err
}
if err := onFeed(res); err != nil {
return err
}
}
return nil
}
// flushTail finalizes the session once and folds the flushed tail (the last
// ~2 encoder frames of text, which only appear on finalize) through onFeed.
func (p *ParakeetCpp) flushTail(stream uintptr, onFeed func(streamFeedResult) error) error {
res, err := p.feedChunk(stream, nil, true)
if err != nil {
return err
}
return onFeed(res)
}

View File

@@ -103,12 +103,13 @@ type transcriptJSON struct {
// {"text":"...","eou":0,"eob":0,"frame_sec":0.080000,
// "words":[{"w":"...","start":0.480,"end":0.640,"conf":0.9100}, ...]}
//
// "text" is the newly-finalized text since the last call; "eou" is 1 when an
// <EOU> (end of utterance) fired this feed and "eob" is 1 when an <EOB>
// (backchannel) fired. ABI v4 conflated the two into "eou"; v5 split them, so
// we read both and treat either as an utterance boundary for segmentation.
// "words" are the words finalized this call with absolute (stream-relative)
// start/end seconds.
// "text" is the newly-finalized text since the last call. Under ABI v5 "eou"
// is 1 iff an <EOU> fired this feed (the user yielded the turn) and "eob" 1
// iff an <EOB> fired (a backchannel like "uh-huh" ended — NOT a turn
// boundary). A v4 library has no "eob" field and its "eou" conflates both
// tokens: Eob stays 0 and Eou keeps the old any-event meaning. "words" are
// the words finalized this call with absolute (stream-relative) start/end
// seconds.
type streamFeedJSON struct {
Text string `json:"text"`
Eou int `json:"eou"`
@@ -364,7 +365,7 @@ var segmentSeparators = []rune{'.', '?', '!'}
// the caller requested word granularity; token ids populate each segment's
// Tokens by time-window membership. Shared by the batched and direct paths.
func transcriptResultFromDoc(doc transcriptJSON, opts *pb.TranscriptRequest, gapFrames int) pb.TranscriptResult {
text := strings.TrimSpace(doc.Text)
text, eou := stripEouMarker(strings.TrimSpace(doc.Text))
// Frame-unit gap threshold -> seconds (NeMo segment_gap_threshold). 0 = off.
gapSeconds := 0.0
@@ -383,6 +384,7 @@ func transcriptResultFromDoc(doc transcriptJSON, opts *pb.TranscriptRequest, gap
return pb.TranscriptResult{
Text: text,
Segments: []*pb.TranscriptSegment{{Id: 0, Text: text}},
Eou: eou,
}
}
@@ -409,7 +411,25 @@ func transcriptResultFromDoc(doc transcriptJSON, opts *pb.TranscriptRequest, gap
}
segments = append(segments, seg)
}
return pb.TranscriptResult{Text: text, Segments: segments}
return pb.TranscriptResult{Text: text, Segments: segments, Eou: eou}
}
// stripEouMarker removes a trailing literal <EOU>/<EOB> from offline-decode
// text and reports whether the decode ended on an end-of-UTTERANCE token. The
// realtime EOU model's offline decode keeps the special token in the
// detokenized text (the streaming path strips it and surfaces it as flags
// instead); user-visible transcripts must never carry either marker, but only
// <EOU> may confirm the semantic_vad retranscribe cross-check — a decode
// ending on <EOB> means the last thing heard was a backchannel, not the user
// yielding the turn.
func stripEouMarker(text string) (string, bool) {
if strings.HasSuffix(text, "<EOU>") {
return strings.TrimSpace(strings.TrimSuffix(text, "<EOU>")), true
}
if strings.HasSuffix(text, "<EOB>") {
return strings.TrimSpace(strings.TrimSuffix(text, "<EOB>")), false
}
return text, false
}
// splitWordsIntoSegments groups words into segments exactly as NeMo's
@@ -476,41 +496,55 @@ func tokensInWindow(tokens []transcriptToken, start, end float64) []int32 {
return ids
}
// streamSegmenter accumulates streaming words into per-utterance segments. EOU
// is the model's own utterance boundary; each closed segment takes its start/end
// from its first/last accumulated word.
// streamSegmenter accumulates streaming decode increments into per-utterance
// segments. <EOU>/<EOB> are the model's own utterance boundaries; each closes a
// segment. When the feed carries per-word timings (ABI v4 JSON), a closed
// segment takes its start/end from its first/last word; against an older
// text-only library (no words) it falls back to segmenting the delta text, so
// the same assembler serves both paths.
type streamSegmenter struct {
segs []*pb.TranscriptSegment
cur []transcriptWord
nextID int32
segs []*pb.TranscriptSegment
cur []transcriptWord // words for the open segment (ABI v4 JSON path)
curText []string // delta text for the open segment (text-only path)
nextID int32
}
func (s *streamSegmenter) add(doc streamFeedJSON) {
s.cur = append(s.cur, doc.Words...)
// Close the segment on either turn signal: <EOU> (end of utterance) or
// <EOB> (backchannel). ABI v4 reported both via "eou"; v5 split them, so we
// OR them here to keep the v4 segmentation boundaries.
if doc.Eou != 0 || doc.Eob != 0 {
func (s *streamSegmenter) add(r streamFeedResult) {
s.cur = append(s.cur, r.Words...)
if len(r.Words) == 0 && r.Delta != "" {
// Older libparakeet.so with no per-word timing: segment from the text.
s.curText = append(s.curText, r.Delta)
}
// Both <EOU> and <EOB> reset the decoder, so both close a segment.
if r.Eou || r.Eob {
s.flush()
}
}
func (s *streamSegmenter) flush() {
if len(s.cur) == 0 {
return
switch {
case len(s.cur) > 0:
parts := make([]string, len(s.cur))
for i, w := range s.cur {
parts[i] = w.W
}
s.segs = append(s.segs, &pb.TranscriptSegment{
Id: s.nextID,
Start: secondsToNanos(s.cur[0].Start),
End: secondsToNanos(s.cur[len(s.cur)-1].End),
Text: strings.TrimSpace(strings.Join(parts, " ")),
})
s.nextID++
case len(s.curText) > 0:
// No words this segment: emit a text-only segment (no timestamps),
// skipping a purely-whitespace one as the legacy text path did.
if t := strings.TrimSpace(strings.Join(s.curText, "")); t != "" {
s.segs = append(s.segs, &pb.TranscriptSegment{Id: s.nextID, Text: t})
s.nextID++
}
}
parts := make([]string, len(s.cur))
for i, w := range s.cur {
parts[i] = w.W
}
s.segs = append(s.segs, &pb.TranscriptSegment{
Id: s.nextID,
Start: secondsToNanos(s.cur[0].Start),
End: secondsToNanos(s.cur[len(s.cur)-1].End),
Text: strings.TrimSpace(strings.Join(parts, " ")),
})
s.nextID++
s.cur = nil
s.curText = nil
}
func (s *streamSegmenter) segments() []*pb.TranscriptSegment { return s.segs }
@@ -535,18 +569,119 @@ func secondsToNanos(sec float64) int64 {
return int64(sec * 1e9)
}
// Per-C-call engine serialization for the streaming paths.
//
// Every individual C call (begin / feed / finalize / free) takes engineMu and
// re-checks ctxPtr under the lock; the lock is NEVER held across a stream's
// lifetime. This is safe because each parakeet.cpp call builds its own ggml
// graph and all streaming caches live in the session object, not the ctx —
// the only ctx-shared mutable state is last_error, which is why it is read
// under the same lock as the failing call. Holding the lock per call (rather
// than per stream, as this file previously did) keeps a long-lived live
// session from starving batched unary transcription and vice versa.
//
// A stream must not outlive its ctx (C-API contract). Free() takes engineMu
// and zeroes ctxPtr, so a racing per-call helper returns ModelNotLoaded
// instead of feeding a freed engine; streamFree of an orphaned session only
// runs the session destructor, which does not touch the ctx.
// streamBegin opens a cache-aware streaming session. A 0 stream with nil
// error means the loaded model is not a streaming model.
func (p *ParakeetCpp) streamBegin(lang string) (uintptr, error) {
p.engineMu.Lock()
defer p.engineMu.Unlock()
if p.ctxPtr == 0 {
return 0, grpcerrors.ModelNotLoaded("parakeet-cpp")
}
if CppStreamBeginLang != nil {
return CppStreamBeginLang(p.ctxPtr, lang), nil
}
return CppStreamBegin(p.ctxPtr), nil
}
func (p *ParakeetCpp) streamFree(stream uintptr) {
if stream == 0 {
return
}
p.engineMu.Lock()
defer p.engineMu.Unlock()
CppStreamFree(stream)
}
// streamFeedText runs one text-mode feed (or the finalize flush when
// finalize is true) under engineMu, returning the newly-finalized delta and
// whether an <EOU>/<EOB> fired during the call.
func (p *ParakeetCpp) streamFeedText(stream uintptr, pcm []float32, finalize bool) (delta string, eou, eob bool, err error) {
p.engineMu.Lock()
defer p.engineMu.Unlock()
if p.ctxPtr == 0 {
return "", false, false, grpcerrors.ModelNotLoaded("parakeet-cpp")
}
var ret uintptr
var events int32
if finalize {
ret = CppStreamFinalize(stream)
} else {
ret = CppStreamFeed(stream, pcm, int32(len(pcm)), unsafe.Pointer(&events))
}
if ret == 0 {
// last_error is ctx-shared: read it under the same lock as the call.
msg := CppLastError(p.ctxPtr)
if msg == "" {
msg = "unknown error"
}
return "", false, false, fmt.Errorf("parakeet-cpp: stream feed/finalize failed: %s", msg)
}
delta = goStringFromCPtr(ret)
CppFreeString(ret)
// ABI v5: eou_out is a bitmask (bit 0 = <EOU>, bit 1 = <EOB>). A v4
// library sets 0/1 for either token, which the bit-0 test reads as the
// old conflated eou — the EOB distinction simply isn't available there.
return delta, events&1 != 0, events&2 != 0, nil
}
// streamFeedDoc runs one ABI v4 JSON feed (or finalize) under engineMu and
// returns the parsed {text,eou,frame_sec,words} document.
func (p *ParakeetCpp) streamFeedDoc(stream uintptr, pcm []float32, finalize bool) (streamFeedJSON, error) {
p.engineMu.Lock()
defer p.engineMu.Unlock()
if p.ctxPtr == 0 {
return streamFeedJSON{}, grpcerrors.ModelNotLoaded("parakeet-cpp")
}
var ret uintptr
if finalize {
ret = CppStreamFinalizeJSON(stream)
} else {
ret = CppStreamFeedJSON(stream, pcm, int32(len(pcm)))
}
if ret == 0 {
msg := CppLastError(p.ctxPtr)
if msg == "" {
msg = "unknown error"
}
return streamFeedJSON{}, fmt.Errorf("parakeet-cpp: stream feed/finalize failed: %s", msg)
}
raw := goStringFromCPtr(ret)
CppFreeString(ret)
var doc streamFeedJSON
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return streamFeedJSON{}, fmt.Errorf("parakeet-cpp: decode stream json: %w", err)
}
return doc, nil
}
// AudioTranscriptionStream drives the cache-aware streaming RNN-T over the
// audio at opts.Dst: it decodes the file to 16 kHz mono PCM, feeds it in
// chunks to parakeet_capi_stream_feed, and emits each newly-finalized text
// run as a TranscriptStreamResponse delta. <EOU>/<EOB> events close the
// current segment; a closing FinalResult carries the full transcript and the
// per-utterance segments.
// audio at opts.Dst: it decodes the file to 16 kHz mono PCM, feeds it through
// the shared decode driver (feedSlices/flushTail), and emits each
// newly-finalized text run as a TranscriptStreamResponse delta. <EOU>/<EOB>
// events close the current segment; a closing FinalResult carries the full
// transcript, the per-utterance segments, and whether the file ended on an
// utterance boundary.
//
// stream_begin returns 0 for models that are not cache-aware streaming models
// (only e.g. nvidia/parakeet_realtime_eou_120m-v1 qualifies). For those we fall
// back to a single offline transcription emitted as one delta plus a closing
// FinalResult, matching LocalAI's non-streaming streaming contract (and the
// whisper backend), so the streaming endpoint works for every model.
// (only e.g. nvidia/parakeet_realtime_eou_120m-v1 qualifies). For those this
// returns codes.Unimplemented rather than faking a stream from an offline
// decode — see the stream==0 branch and grpcerrors.StreamTranscriptionUnsupported.
func (p *ParakeetCpp) AudioTranscriptionStream(ctx context.Context, opts *pb.TranscriptRequest, results chan *pb.TranscriptStreamResponse) error {
defer close(results)
@@ -560,185 +695,73 @@ func (p *ParakeetCpp) AudioTranscriptionStream(ctx context.Context, opts *pb.Tra
return status.Error(codes.Canceled, "transcription cancelled")
}
var stream uintptr
if CppStreamBeginLang != nil {
stream = CppStreamBeginLang(p.ctxPtr, opts.GetLanguage())
} else {
stream = CppStreamBegin(p.ctxPtr)
stream, err := p.streamBegin(opts.GetLanguage())
if err != nil {
return err
}
if stream == 0 {
// Not a cache-aware streaming model: run a normal offline
// transcription and emit it as one delta + a closing final result.
res, err := p.AudioTranscription(ctx, opts)
if err != nil {
return err
}
if t := strings.TrimSpace(res.Text); t != "" {
results <- &pb.TranscriptStreamResponse{Delta: t}
}
results <- &pb.TranscriptStreamResponse{FinalResult: &res}
return nil
// Not a cache-aware streaming model. Report the missing capability
// honestly instead of decoding offline and emitting it as one "delta"
// + final: a client that asked for streaming must learn the model
// cannot stream, not receive a batch result dressed as a stream (which
// is indistinguishable except qualitatively, and silently breaks any
// feature that genuinely needs incremental output). Callers wanting a
// plain transcript use the unary AudioTranscription path. This mirrors
// AudioTranscriptionLive, which already returns Unimplemented here.
return grpcerrors.StreamTranscriptionUnsupported("parakeet-cpp",
"loaded model is not a cache-aware streaming model")
}
defer CppStreamFree(stream)
// The C engine is a single shared context: a streaming session and a batched
// unary dispatch must never touch it at once, so hold engineMu for the whole
// stream. This lock is intentionally taken AFTER the non-streaming fallback
// above returns: that fallback goes through AudioTranscription -> the batcher
// -> runBatch, which itself acquires engineMu, so locking here first would
// deadlock. Do not hoist this lock above the fallback.
p.engineMu.Lock()
defer p.engineMu.Unlock()
defer p.streamFree(stream)
data, duration, err := decodeWavMono16k(opts.Dst)
if err != nil {
return err
}
// ABI v4: when the streaming JSON entry points are present, drive them so the
// per-utterance segments carry per-word start/end timestamps. Falls through to
// the text-only loop below against an older libparakeet.so. Runs under the
// engineMu already held above.
if CppStreamFeedJSON != nil {
return p.streamJSON(ctx, stream, data, duration, results)
}
// Fold the shared decode driver's per-feed increments into the streamed
// deltas and the closing batch result: words/text accumulate into
// per-utterance segments (streamSegmenter), and the utterance-boundary
// latch (boundary.go) records whether the file ended on an <EOU>. These
// are the offline path's concern — the live RPC carries none of them.
var (
full strings.Builder
segText strings.Builder
segments []*pb.TranscriptSegment
segID int32
seg streamSegmenter
boundary utteranceBoundary
)
flushSegment := func() {
t := strings.TrimSpace(segText.String())
segText.Reset()
if t == "" {
return
emit := func(r streamFeedResult) error {
if r.Delta != "" {
full.WriteString(r.Delta)
results <- &pb.TranscriptStreamResponse{Delta: r.Delta}
}
segments = append(segments, &pb.TranscriptSegment{Id: segID, Text: t})
segID++
}
// emitDelta consumes the malloc'd char* returned by feed/finalize: frees
// it, accumulates the text, and sends a delta when non-empty. A 0 return
// is an error (vs the "" empty-but-non-NULL no-new-text case).
emitDelta := func(ret uintptr) error {
if ret == 0 {
msg := CppLastError(p.ctxPtr)
if msg == "" {
msg = "unknown error"
}
return fmt.Errorf("parakeet-cpp: stream feed/finalize failed: %s", msg)
}
delta := goStringFromCPtr(ret)
CppFreeString(ret)
if delta == "" {
return nil
}
full.WriteString(delta)
segText.WriteString(delta)
results <- &pb.TranscriptStreamResponse{Delta: delta}
seg.add(r)
boundary = boundary.observe(r)
return nil
}
for off := 0; off < len(data); off += streamChunkSamples {
if err := ctx.Err(); err != nil {
return status.Error(codes.Canceled, "transcription cancelled")
}
end := min(off+streamChunkSamples, len(data))
chunk := data[off:end]
var eou int32
ret := CppStreamFeed(stream, chunk, int32(len(chunk)), unsafe.Pointer(&eou))
if err := emitDelta(ret); err != nil {
return err
}
if eou != 0 {
flushSegment()
}
}
// Flush the streaming tail (final encoder chunk).
if err := emitDelta(CppStreamFinalize(stream)); err != nil {
if err := p.feedSlices(ctx, stream, data, emit); err != nil {
return err
}
flushSegment()
text := strings.TrimSpace(full.String())
if len(segments) == 0 && text != "" {
segments = append(segments, &pb.TranscriptSegment{Id: 0, Text: text})
}
results <- &pb.TranscriptStreamResponse{
FinalResult: &pb.TranscriptResult{
Text: text,
Segments: segments,
Duration: duration,
},
}
return nil
}
// streamJSON drives the streaming JSON entry points (present since ABI v4): each
// feed/finalize returns a {text,eou,eob,frame_sec,words} document. The
// newly-finalized text is emitted as a delta (unchanged streaming contract)
// while words are accumulated into per-utterance segments (closed on <EOU> or
// <EOB>) so the closing FinalResult carries timestamped segments. Runs under
// engineMu (already held by the caller).
func (p *ParakeetCpp) streamJSON(ctx context.Context, stream uintptr, data []float32,
duration float32, results chan *pb.TranscriptStreamResponse) error {
var (
full strings.Builder
seg streamSegmenter
)
// consume frees the malloc'd char* (a 0 return is an error), parses the JSON,
// emits the delta, and routes words through the segmenter.
consume := func(ret uintptr) error {
if ret == 0 {
msg := CppLastError(p.ctxPtr)
if msg == "" {
msg = "unknown error"
}
return fmt.Errorf("parakeet-cpp: stream feed/finalize failed: %s", msg)
}
raw := goStringFromCPtr(ret)
CppFreeString(ret)
var doc streamFeedJSON
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return fmt.Errorf("parakeet-cpp: decode stream json: %w", err)
}
if doc.Text != "" {
full.WriteString(doc.Text)
results <- &pb.TranscriptStreamResponse{Delta: doc.Text}
}
seg.add(doc)
return nil
}
for off := 0; off < len(data); off += streamChunkSamples {
if err := ctx.Err(); err != nil {
return status.Error(codes.Canceled, "transcription cancelled")
}
end := min(off+streamChunkSamples, len(data))
chunk := data[off:end]
if err := consume(CppStreamFeedJSON(stream, chunk, int32(len(chunk)))); err != nil {
return err
}
}
if err := consume(CppStreamFinalizeJSON(stream)); err != nil {
if err := p.flushTail(stream, emit); err != nil {
return err
}
seg.flush() // close any trailing utterance that never saw an EOU
seg.flush() // close a trailing utterance that never saw an <EOU>
text := strings.TrimSpace(full.String())
// final.Text is the exact concatenation of the streamed deltas (full is
// their accumulation), so concat(deltas) == FinalResult.Text holds even
// when the model prepends a leading space to the first word (SentencePiece
// detokenization). This matches the whisper backend's streaming contract.
// The single-segment fallback stays trimmed.
fullText := full.String()
segments := seg.segments()
if len(segments) == 0 && text != "" {
segments = append(segments, &pb.TranscriptSegment{Id: 0, Text: text})
if trimmed := strings.TrimSpace(fullText); len(segments) == 0 && trimmed != "" {
segments = append(segments, &pb.TranscriptSegment{Id: 0, Text: trimmed})
}
results <- &pb.TranscriptStreamResponse{
FinalResult: &pb.TranscriptResult{
Text: text,
Text: fullText,
Segments: segments,
Duration: duration,
Eou: boundary.ended(),
},
}
return nil
@@ -803,6 +826,10 @@ func (p *ParakeetCpp) Free() error {
close(p.batStop)
p.batStop = nil
}
// engineMu so an in-flight streaming call (which locks per C call and
// re-checks ctxPtr under the lock) can never feed into a freed ctx.
p.engineMu.Lock()
defer p.engineMu.Unlock()
if p.ctxPtr != 0 {
CppFree(p.ctxPtr)
p.ctxPtr = 0

View File

@@ -14,6 +14,8 @@ import (
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestParakeetCpp(t *testing.T) {
@@ -201,6 +203,29 @@ var _ = Describe("ParakeetCpp", func() {
})
Context("AudioTranscriptionStream", func() {
It("returns the typed Unimplemented signal for non-streaming models (no offline fallback)", func() {
// stream_begin == 0 means the loaded model is not a cache-aware
// streaming model. The backend must surface that, not silently
// decode offline and fake a one-shot "stream".
savedBegin, savedBeginLang := CppStreamBegin, CppStreamBeginLang
defer func() { CppStreamBegin, CppStreamBeginLang = savedBegin, savedBeginLang }()
CppStreamBeginLang = nil
CppStreamBegin = func(ctx uintptr) uintptr { return 0 }
p := &ParakeetCpp{ctxPtr: 1}
results := make(chan *pb.TranscriptStreamResponse, 8)
err := p.AudioTranscriptionStream(context.Background(),
&pb.TranscriptRequest{Dst: "ignored.wav"}, results)
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
// Honest signal: nothing was emitted — no faked batch result.
var emitted []*pb.TranscriptStreamResponse
for r := range results {
emitted = append(emitted, r)
}
Expect(emitted).To(BeEmpty())
})
It("streams deltas and a closing FinalResult from a cache-aware model", func() {
// Streaming needs a cache-aware streaming model (e.g.
// realtime_eou); the offline test model would fail stream_begin.

View File

@@ -0,0 +1,186 @@
package main
import (
"strings"
"time"
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// liveSampleRate is the only PCM rate the parakeet C streaming API accepts.
const liveSampleRate = 16000
// AudioTranscriptionLive drives one cache-aware streaming session over audio
// fed incrementally by the caller (the realtime API's semantic_vad turn
// detection). Contract:
//
// - the first request must carry a Config; a Config mid-stream resets the
// decode session (free + begin) and drops accumulated transcript state;
// - a Ready ack is sent right after a successful stream_begin so callers
// can degrade synchronously when the model has no streaming support
// (LiveTranscriptionUnsupported, codes.Unimplemented);
// - every feed that produced output is forwarded as {delta, eou, words};
// the <EOU>/<EOB> flag is the model's own utterance boundary and the
// decoder auto-resets after it, so one session spans many utterances;
// - closing the send side finalizes: the held-back tail chunk is flushed
// (the last ~2 encoder frames of words only appear here) and a terminal
// FinalResult carries the full transcript Text only. Per-utterance
// segments, duration, and the terminal <EOU> flag are NOT produced here —
// the realtime core consumes the streamed per-feed tokens and the final
// Text; those batch fields are the file path's concern (see
// AudioTranscriptionStream).
//
// Engine access is serialized per C call (streamBegin/streamFeed*/streamFree
// take engineMu internally), never for the session lifetime — unary
// transcription keeps flowing between feeds.
func (p *ParakeetCpp) AudioTranscriptionLive(in <-chan *pb.TranscriptLiveRequest, out chan<- *pb.TranscriptLiveResponse) error {
defer close(out)
if p.ctxPtr == 0 {
return grpcerrors.ModelNotLoaded("parakeet-cpp")
}
first, ok := <-in
if !ok {
return nil // caller closed without sending anything
}
cfg := first.GetConfig()
if cfg == nil {
return status.Error(codes.InvalidArgument, "parakeet-cpp: first live message must carry a config")
}
if err := validateLiveConfig(cfg); err != nil {
return err
}
stream, err := p.streamBegin(cfg.GetLanguage())
if err != nil {
return err
}
if stream == 0 {
return grpcerrors.LiveTranscriptionUnsupported("parakeet-cpp",
"loaded model is not a cache-aware streaming model")
}
// stream is reassigned on a mid-stream Config reset; free whatever is
// current when the RPC unwinds.
defer func() { p.streamFree(stream) }()
out <- &pb.TranscriptLiveResponse{Ready: true}
var (
full strings.Builder
fedSecs float64
// behindSec accumulates how far decode wall time has fallen behind
// the audio it was fed. A live caller feeds in real time, so a
// persistent positive backlog means every downstream signal —
// including the <EOU> the turn detector waits on — arrives that many
// seconds late. Warned once per session; reset by a Config reset.
behindSec float64
behindWarned bool
)
// emit forwards one decode increment: it streams the per-feed tokens the
// realtime turn detector consumes (delta/eou/eob/words) and accumulates the
// running transcript for the closing FinalResult. No segmentation or
// boundary latch here — the live consumer reads only the streamed tokens
// and the final Text; per-utterance segments and the terminal <EOU> flag
// are an offline-path concern (see AudioTranscriptionStream / boundary.go).
emit := func(r streamFeedResult) error {
if r.Delta != "" {
full.WriteString(r.Delta)
}
if r.Delta != "" || r.Eou || r.Eob || len(r.Words) > 0 {
out <- &pb.TranscriptLiveResponse{
Delta: r.Delta,
Eou: r.Eou,
Eob: r.Eob,
Words: liveWordsToProto(r.Words),
}
}
return nil
}
for req := range in {
switch payload := req.GetPayload().(type) {
case *pb.TranscriptLiveRequest_Config:
if err := validateLiveConfig(payload.Config); err != nil {
return err
}
// Reset: a fresh decode session, dropping accumulated state.
p.streamFree(stream)
stream, err = p.streamBegin(payload.Config.GetLanguage())
if err != nil {
return err
}
if stream == 0 {
return grpcerrors.LiveTranscriptionUnsupported("parakeet-cpp",
"loaded model is not a cache-aware streaming model")
}
full.Reset()
fedSecs = 0
case *pb.TranscriptLiveRequest_Audio:
pcm := payload.Audio.GetPcm()
audioSec := float64(len(pcm)) / liveSampleRate
fedSecs += audioSec
start := time.Now()
// nil ctx: a live session is bounded by this request channel, not a
// context — cancellation is the caller closing the stream.
if err := p.feedSlices(nil, stream, pcm, emit); err != nil {
return err
}
wallSec := time.Since(start).Seconds()
behindSec += wallSec - audioSec
if behindSec < 0 {
behindSec = 0
}
xlog.Debug("parakeet-cpp: live feed",
"audio_ms", int(audioSec*1000), "wall_ms", int(wallSec*1000),
"behind_ms", int(behindSec*1000), "fed_s", fedSecs)
if behindSec > 1 && !behindWarned {
behindWarned = true
xlog.Warn("parakeet-cpp: live decode is falling behind real time; "+
"end-of-utterance signals will arrive late",
"behind_s", behindSec, "fed_s", fedSecs)
}
}
}
// Send side closed: flush the streaming tail and emit the final transcript.
// The live FinalResult carries only Text — the authoritative full-turn
// transcript the realtime core commits. Per-utterance segments, duration,
// and the terminal <EOU> flag are not produced on the live path.
if err := p.flushTail(stream, emit); err != nil {
return err
}
out <- &pb.TranscriptLiveResponse{
FinalResult: &pb.TranscriptResult{Text: strings.TrimSpace(full.String())},
}
return nil
}
func validateLiveConfig(cfg *pb.TranscriptLiveConfig) error {
if sr := cfg.GetSampleRate(); sr != 0 && sr != liveSampleRate {
return status.Errorf(codes.InvalidArgument,
"parakeet-cpp: unsupported live sample_rate %d (only %d)", sr, liveSampleRate)
}
return nil
}
func liveWordsToProto(words []transcriptWord) []*pb.TranscriptWord {
if len(words) == 0 {
return nil
}
out := make([]*pb.TranscriptWord, len(words))
for i, w := range words {
out[i] = &pb.TranscriptWord{
Start: secondsToNanos(w.Start),
End: secondsToNanos(w.End),
Text: w.W,
}
}
return out
}

View File

@@ -0,0 +1,417 @@
package main
import (
"sync"
"time"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// The live-RPC specs drive AudioTranscriptionLive entirely against stubbed
// Cpp* package vars (the same seam batcher_test.go uses), so they run
// without libparakeet.so.
// liveCstrPool hands out NUL-terminated C-style strings backed by Go memory
// and keeps them alive for the duration of a spec (goStringFromCPtr reads
// through the raw pointer; Go's GC must not collect the backing array while
// a stub's return value is in flight).
type liveCstrPool struct {
mu sync.Mutex
bufs [][]byte
}
func (p *liveCstrPool) cstr(s string) uintptr {
p.mu.Lock()
defer p.mu.Unlock()
b := append([]byte(s), 0)
p.bufs = append(p.bufs, b)
return uintptr(unsafe.Pointer(&b[0]))
}
// liveStubs swaps every C entry point the live path touches and returns a
// restore func for AfterEach.
func liveStubs() (restore func()) {
savedBegin, savedBeginLang := CppStreamBegin, CppStreamBeginLang
savedFeed, savedFeedJSON := CppStreamFeed, CppStreamFeedJSON
savedFinalize, savedFinalizeJSON := CppStreamFinalize, CppStreamFinalizeJSON
savedFree, savedLastError := CppStreamFree, CppLastError
savedFreeString := CppFreeString
return func() {
CppStreamBegin, CppStreamBeginLang = savedBegin, savedBeginLang
CppStreamFeed, CppStreamFeedJSON = savedFeed, savedFeedJSON
CppStreamFinalize, CppStreamFinalizeJSON = savedFinalize, savedFinalizeJSON
CppStreamFree, CppLastError = savedFree, savedLastError
CppFreeString = savedFreeString
}
}
// runLive starts the RPC on its own goroutine and returns the request
// channel plus a collector for everything the backend emitted.
func runLive(p *ParakeetCpp) (chan *pb.TranscriptLiveRequest, chan *pb.TranscriptLiveResponse, chan error) {
in := make(chan *pb.TranscriptLiveRequest)
out := make(chan *pb.TranscriptLiveResponse, 32)
errCh := make(chan error, 1)
go func() { errCh <- p.AudioTranscriptionLive(in, out) }()
return in, out, errCh
}
func liveConfig(lang string) *pb.TranscriptLiveRequest {
return &pb.TranscriptLiveRequest{
Payload: &pb.TranscriptLiveRequest_Config{Config: &pb.TranscriptLiveConfig{Language: lang}},
}
}
func liveAudio(pcm []float32) *pb.TranscriptLiveRequest {
return &pb.TranscriptLiveRequest{
Payload: &pb.TranscriptLiveRequest_Audio{Audio: &pb.TranscriptLiveAudio{Pcm: pcm}},
}
}
func collectLive(out chan *pb.TranscriptLiveResponse) []*pb.TranscriptLiveResponse {
var got []*pb.TranscriptLiveResponse
for r := range out {
got = append(got, r)
}
return got
}
var _ = Describe("AudioTranscriptionLive (stubbed C API)", func() {
var (
pool *liveCstrPool
restore func()
p *ParakeetCpp
)
BeforeEach(func() {
pool = &liveCstrPool{}
restore = liveStubs()
p = &ParakeetCpp{ctxPtr: 1}
CppStreamBeginLang = nil
CppStreamBegin = func(ctx uintptr) uintptr { return 7 }
CppStreamFree = func(s uintptr) {}
CppFreeString = func(s uintptr) {}
CppLastError = func(ctx uintptr) string { return "stub error" }
CppStreamFeed = nil
CppStreamFeedJSON = nil
CppStreamFinalize = nil
CppStreamFinalizeJSON = nil
})
AfterEach(func() { restore() })
It("rejects a stream whose first message is not a config", func() {
in, out, errCh := runLive(p)
in <- liveAudio([]float32{0.1})
close(in)
err := <-errCh
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(collectLive(out)).To(BeEmpty())
})
It("rejects a non-16k sample rate", func() {
in, _, errCh := runLive(p)
in <- &pb.TranscriptLiveRequest{
Payload: &pb.TranscriptLiveRequest_Config{Config: &pb.TranscriptLiveConfig{SampleRate: 8000}},
}
close(in)
Expect(status.Code(<-errCh)).To(Equal(codes.InvalidArgument))
})
It("returns the typed Unimplemented signal for non-streaming models, before any ack", func() {
CppStreamBegin = func(ctx uintptr) uintptr { return 0 }
in, out, errCh := runLive(p)
in <- liveConfig("")
close(in)
err := <-errCh
Expect(grpcerrors.IsLiveTranscriptionUnsupported(err)).To(BeTrue())
Expect(collectLive(out)).To(BeEmpty())
})
It("streams deltas, eou flags and words on the JSON path and finalizes on close", func() {
var freed []uintptr
CppStreamFree = func(s uintptr) { freed = append(freed, s) }
feeds := 0
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
feeds++
switch feeds {
case 1:
return pool.cstr(`{"text":"hello ","eou":0,"frame_sec":0.08,` +
`"words":[{"w":"hello","start":0.1,"end":0.4,"conf":0.9}]}`)
default:
return pool.cstr(`{"text":"world","eou":1,"frame_sec":0.08,` +
`"words":[{"w":"world","start":0.5,"end":0.8,"conf":0.9}]}`)
}
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("en")
in <- liveAudio(make([]float32, 100))
in <- liveAudio(make([]float32, 200))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4)) // ready, two deltas, final
Expect(got[0].Ready).To(BeTrue())
Expect(got[1].Delta).To(Equal("hello "))
Expect(got[1].Eou).To(BeFalse())
Expect(got[1].Words).To(HaveLen(1))
Expect(got[1].Words[0].Text).To(Equal("hello"))
Expect(got[2].Delta).To(Equal("world"))
Expect(got[2].Eou).To(BeTrue())
final := got[3].FinalResult
Expect(final).NotTo(BeNil())
Expect(final.Text).To(Equal("hello world"))
// The live FinalResult carries only Text. Per-utterance segments,
// duration and the terminal eou flag are an offline-path concern (see
// boundary.go / AudioTranscriptionStream); the realtime core reads the
// streamed per-feed tokens above plus this Text.
Expect(final.Eou).To(BeFalse())
Expect(final.Segments).To(BeEmpty())
Expect(final.Duration).To(BeZero())
Expect(freed).To(Equal([]uintptr{7}))
})
It("falls back to the text feed (eou out-param) when the JSON entry points are absent", func() {
feeds := 0
CppStreamFeed = func(s uintptr, pcm []float32, n int32, eouOut unsafe.Pointer) uintptr {
feeds++
if feeds == 2 {
*(*int32)(eouOut) = 1
return pool.cstr("done")
}
return pool.cstr("first ")
}
CppStreamFinalize = func(s uintptr) uintptr { return pool.cstr("") }
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4))
Expect(got[1].Delta).To(Equal("first "))
Expect(got[1].Eou).To(BeFalse())
Expect(got[2].Delta).To(Equal("done"))
Expect(got[2].Eou).To(BeTrue())
Expect(got[3].FinalResult.Text).To(Equal("first done"))
})
It("forwards <EOB> as eob — a backchannel, never an eou (ABI v5 JSON)", func() {
feeds := 0
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
feeds++
if feeds == 1 {
return pool.cstr(`{"text":"uh-huh","eou":0,"eob":1,"frame_sec":0.08,` +
`"words":[{"w":"uh-huh","start":0.1,"end":0.3,"conf":0.9}]}`)
}
return pool.cstr(`{"text":"the turn","eou":1,"eob":0,"frame_sec":0.08,` +
`"words":[{"w":"the","start":0.5,"end":0.6,"conf":0.9},{"w":"turn","start":0.6,"end":0.8,"conf":0.9}]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"eob":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4))
Expect(got[1].Eob).To(BeTrue())
Expect(got[1].Eou).To(BeFalse(), "a backchannel must not masquerade as a turn boundary")
Expect(got[2].Eou).To(BeTrue())
})
It("maps the v5 eou_out bitmask on the text path (bit0 <EOU>, bit1 <EOB>)", func() {
feeds := 0
CppStreamFeed = func(s uintptr, pcm []float32, n int32, eouOut unsafe.Pointer) uintptr {
feeds++
if feeds == 1 {
*(*int32)(eouOut) = 2 // <EOB> only
return pool.cstr("uh-huh")
}
*(*int32)(eouOut) = 1 // <EOU>
return pool.cstr(" done")
}
CppStreamFinalize = func(s uintptr) uintptr { return pool.cstr("") }
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4))
Expect(got[1].Eob).To(BeTrue())
Expect(got[1].Eou).To(BeFalse())
Expect(got[2].Eou).To(BeTrue())
Expect(got[2].Eob).To(BeFalse())
})
It("accumulates trailing text after an EOU into the final transcript", func() {
feeds := 0
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
feeds++
if feeds == 1 {
return pool.cstr(`{"text":"turn one","eou":1,"frame_sec":0.08,"words":[]}`)
}
return pool.cstr(`{"text":" and more","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
final := got[len(got)-1].FinalResult
Expect(final.Text).To(Equal("turn one and more"))
})
It("resets the decode session on a mid-stream config", func() {
var begun, freed int
CppStreamBegin = func(ctx uintptr) uintptr { begun++; return uintptr(10 + begun) }
CppStreamFree = func(s uintptr) { freed++ }
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"x","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveConfig("") // reset
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
final := got[len(got)-1].FinalResult
Expect(final.Text).To(Equal("x"), "pre-reset transcript dropped")
Expect(begun).To(Equal(2))
Expect(freed).To(Equal(2), "old session freed on reset, new one on unwind")
})
It("does not hold engineMu between feeds (unary work interleaves with a live session)", func() {
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
// The session is open and idle between feeds: the engine lock must be
// acquirable, which is what lets batched unary transcription proceed
// mid-session. Under stream-lifetime locking this probe would block
// until the stream ended and the Eventually would time out.
locked := make(chan struct{})
go func() {
p.engineMu.Lock()
p.engineMu.Unlock() //nolint:staticcheck // probe: acquire-release proves availability
close(locked)
}()
Eventually(locked, time.Second).Should(BeClosed())
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
collectLive(out)
})
It("errors out and reads last_error under the lock when a feed fails", func() {
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr { return 0 }
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
err := <-errCh
Expect(err).To(MatchError(ContainSubstring("stub error")))
got := collectLive(out)
Expect(got).To(HaveLen(1)) // just the ready ack
close(in)
})
})
var _ = Describe("stripEouMarker", func() {
It("strips a trailing <EOU> and reports it", func() {
text, eou := stripEouMarker("it is certainly very like the old portrait<EOU>")
Expect(text).To(Equal("it is certainly very like the old portrait"))
Expect(eou).To(BeTrue())
})
It("strips a trailing <EOB> WITHOUT reporting an utterance end", func() {
// A decode ending on a backchannel must not confirm the
// retranscribe gate — the user was acknowledging, not yielding.
text, eou := stripEouMarker("uh-huh<EOB>")
Expect(text).To(Equal("uh-huh"))
Expect(eou).To(BeFalse())
})
It("leaves marker-free text alone", func() {
text, eou := stripEouMarker("plain transcript")
Expect(text).To(Equal("plain transcript"))
Expect(eou).To(BeFalse())
})
It("does not strip a marker in the middle of the text", func() {
text, eou := stripEouMarker("a<EOU>b")
Expect(text).To(Equal("a<EOU>b"))
Expect(eou).To(BeFalse())
})
})
var _ = Describe("transcriptResultFromDoc EOU handling", func() {
It("strips the offline marker from text and sets the result flag", func() {
doc := transcriptJSON{Text: "the old portrait<EOU>"}
res := transcriptResultFromDoc(doc, &pb.TranscriptRequest{}, 0)
Expect(res.Text).To(Equal("the old portrait"))
Expect(res.Eou).To(BeTrue())
Expect(res.Segments).To(HaveLen(1))
Expect(res.Segments[0].Text).To(Equal("the old portrait"))
})
It("reports eou=false for marker-free decodes", func() {
doc := transcriptJSON{Text: "no marker here"}
res := transcriptResultFromDoc(doc, &pb.TranscriptRequest{}, 0)
Expect(res.Text).To(Equal("no marker here"))
Expect(res.Eou).To(BeFalse())
})
})

View File

@@ -106,7 +106,7 @@ var _ = Describe("transcriptResultFromDoc (multi-segment)", func() {
var _ = Describe("streaming segment assembly", func() {
It("closes a segment with start/end from its words on EOU", func() {
acc := &streamSegmenter{}
acc.add(streamFeedJSON{Text: "hello world", Eou: 1, Words: []transcriptWord{
acc.add(streamFeedResult{Delta: "hello world", Eou: true, Words: []transcriptWord{
{W: "hello", Start: 0.0, End: 0.4}, {W: "world", Start: 0.4, End: 0.9},
}})
segs := acc.segments()
@@ -118,9 +118,9 @@ var _ = Describe("streaming segment assembly", func() {
It("buffers words across feeds until EOU", func() {
acc := &streamSegmenter{}
acc.add(streamFeedJSON{Text: "hi", Eou: 0, Words: []transcriptWord{{W: "hi", Start: 0, End: 0.3}}})
acc.add(streamFeedResult{Delta: "hi", Words: []transcriptWord{{W: "hi", Start: 0, End: 0.3}}})
Expect(acc.segments()).To(BeEmpty())
acc.add(streamFeedJSON{Text: "there", Eou: 1, Words: []transcriptWord{{W: "there", Start: 0.3, End: 0.7}}})
acc.add(streamFeedResult{Delta: "there", Eou: true, Words: []transcriptWord{{W: "there", Start: 0.3, End: 0.7}}})
Expect(acc.segments()).To(HaveLen(1))
Expect(acc.segments()[0].Text).To(Equal("hi there"))
})
@@ -129,7 +129,7 @@ var _ = Describe("streaming segment assembly", func() {
// field; a backchannel must still close the segment as it did in v4.
It("closes a segment on EOB (backchannel) too", func() {
acc := &streamSegmenter{}
acc.add(streamFeedJSON{Text: "uh huh", Eou: 0, Eob: 1, Words: []transcriptWord{
acc.add(streamFeedResult{Delta: "uh huh", Eob: true, Words: []transcriptWord{
{W: "uh", Start: 0.0, End: 0.2}, {W: "huh", Start: 0.2, End: 0.5},
}})
segs := acc.segments()
@@ -137,4 +137,18 @@ var _ = Describe("streaming segment assembly", func() {
Expect(segs[0].Text).To(Equal("uh huh"))
Expect(segs[0].End).To(Equal(secondsToNanos(0.5)))
})
// Older text-only libparakeet.so: no per-word timings, so a segment is cut
// from the delta text on each <EOU>/<EOB> (no timestamps), one per utterance.
It("falls back to text segments when the feed carries no words", func() {
acc := &streamSegmenter{}
acc.add(streamFeedResult{Delta: "first turn", Eou: true})
acc.add(streamFeedResult{Delta: "second turn", Eou: true})
segs := acc.segments()
Expect(segs).To(HaveLen(2))
Expect(segs[0].Text).To(Equal("first turn"))
Expect(segs[1].Text).To(Equal("second turn"))
Expect(segs[0].Start).To(Equal(int64(0)), "no per-word timing on the text path")
Expect(segs[0].End).To(Equal(int64(0)))
})
})

View File

@@ -30,7 +30,7 @@ target_include_directories(goqwen3ttscpp PRIVATE ${QWENTTS_DIR}/src)
target_include_directories(goqwen3ttscpp SYSTEM PRIVATE ${QWENTTS_DIR}/ggml/include)
# Link GPU backends if the upstream ggml created them.
foreach(backend blas cuda metal vulkan sycl)
foreach(backend blas cuda hip metal vulkan sycl)
if(TARGET ggml-${backend})
target_link_libraries(goqwen3ttscpp PRIVATE ggml-${backend})
if(backend STREQUAL "cuda")

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# qwentts.cpp version
QWEN3TTS_REPO?=https://github.com/ServeurpersoCom/qwentts.cpp
QWEN3TTS_CPP_VERSION?=9dbe7ea26a01b30fccb117ae5e86807c1dc23d42
QWEN3TTS_CPP_VERSION?=d17c33d4ee2f56d15f9ca8a1bb82f7389305f838
SO_TARGET?=libgoqwen3ttscpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
@@ -24,7 +24,14 @@ else ifeq ($(BUILD_TYPE),openblas)
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DGGML_HIPBLAS=ON
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
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
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(OS),Darwin)

View File

@@ -0,0 +1,85 @@
# Qwen3-TTS C++ backend
This backend runs Qwen3-TTS GGUF models through
[qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp). It supports
24 kHz speech generation, streaming, named speakers, voice design, and
reference-audio cloning depending on the model variant.
## Gallery models
The following Base models accept LocalAI Voice Library profiles:
- `qwen3-tts-cpp`
- `qwen3-tts-cpp-0.6b-base-q4`
- `qwen3-tts-cpp-1.7b-base`
- `qwen3-tts-cpp-1.7b-base-q4`
Gallery models containing `customvoice` or `voicedesign` implement those Qwen
modes instead and are not advertised as raw reference-audio models.
Install a Base model with:
```bash
local-ai models install qwen3-tts-cpp
```
## Model configuration
Base filenames are detected automatically. Set `tts.voice_cloning` only when a
verified private conversion has a name that does not identify it as a Base or
VoiceClone model:
```yaml
name: private-qwen-voice
backend: qwen3-tts-cpp
parameters:
model: qwen-private/talker.gguf
known_usecases:
- tts
tts:
voice_cloning: true
audio_path: voices/default-reference.wav # optional model-wide fallback
```
The tokenizer GGUF is auto-discovered when its filename contains `tokenizer`
and it is stored beside the talker. Otherwise set
`options: ["tokenizer:qwen-private/tokenizer.gguf"]`.
`tts.voice_cloning: false` removes a model from Voice Library compatibility
results and rejects saved `localai://voice-profiles/...` references. It does
not disable Qwen's named-speaker or VoiceDesign modes. Setting it to `true`
cannot add cloning to a backend that lacks LocalAI's reference-audio contract.
Request precedence is: a request `voice`, then `tts.voice`, then
`tts.audio_path`. A saved profile supplies its private WAV and exact transcript
for that request without changing the model YAML.
## API example
Create or select a profile in **Operate → Voice Library**, then pass its stable
URI to either speech endpoint:
```bash
curl http://localhost:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{
"model": "qwen3-tts-cpp",
"input": "This request uses a saved reference voice.",
"voice": "localai://voice-profiles/PROFILE_ID"
}' \
--output speech.wav
```
## Native end-to-end test
The labeled test loads real GGUFs, synthesizes speech, streams audio, and
exercises cloning with a generated 24 kHz reference WAV:
```bash
make -C backend/go/qwen3-tts-cpp qwen3-tts-cpp
QWEN3TTS_MODEL=/path/to/qwen-talker-0.6b-base-Q8_0.gguf \
QWEN3TTS_CODEC=/path/to/qwen-tokenizer-12hz-Q8_0.gguf \
QWEN3TTS_LIBRARY=backend/go/qwen3-tts-cpp/libgoqwen3ttscpp-fallback.so \
go test ./backend/go/qwen3-tts-cpp -ginkgo.label-filter=e2e
```

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?=9956436c925a367daeab097598b1ea1f32d3503f
STABLEDIFFUSION_GGML_VERSION?=b5d812008eb7082a238fc589444544b3278187ae
CMAKE_ARGS+=-DGGML_MAX_NAME=128

View File

@@ -660,9 +660,16 @@ int load_model(const char *model, char *model_path, char* options[], int threads
ctx_params.diffusion_conv_direct = diffusion_conv_direct;
ctx_params.vae_conv_direct = vae_conv_direct;
ctx_params.force_sdxl_vae_conv_scale = force_sdxl_vae_conv_scale;
ctx_params.chroma_use_dit_mask = chroma_use_dit_mask;
ctx_params.chroma_use_t5_mask = chroma_use_t5_mask;
ctx_params.chroma_t5_mask_pad = chroma_t5_mask_pad;
// Chroma knobs: upstream dropped the dedicated chroma_use_dit_mask /
// chroma_use_t5_mask / chroma_t5_mask_pad struct fields and now reads them
// from the generic model_args key=value spec (parse_key_value_args). Emit
// them there so the existing chroma options keep working. This string must
// outlive new_sd_ctx() below.
std::string model_args_spec =
"chroma_use_dit_mask=" + std::string(chroma_use_dit_mask ? "true" : "false") +
",chroma_use_t5_mask=" + std::string(chroma_use_t5_mask ? "true" : "false") +
",chroma_t5_mask_pad=" + std::to_string(chroma_t5_mask_pad);
ctx_params.model_args = model_args_spec.c_str();
sd_ctx_t* sd_ctx = new_sd_ctx(&ctx_params);
if (sd_ctx == NULL) {
@@ -798,6 +805,7 @@ void sd_img_gen_params_set_seed(sd_img_gen_params_t *params, int64_t seed) {
int gen_image(sd_img_gen_params_t *p, int steps, char *dst, float cfg_scale, char *src_image, float strength, char *mask_image, char* ref_images[], int ref_images_count) {
sd_image_t* results;
int num_results_out = 0;
std::vector<int> skip_layers = {7, 8, 9};
@@ -994,10 +1002,14 @@ int gen_image(sd_img_gen_params_t *p, int steps, char *dst, float cfg_scale, cha
sd_ctx_params_to_str(&ctx_params),
sd_img_gen_params_to_str(p));
results = generate_image(sd_c, p);
bool gen_ok = generate_image(sd_c, p, &results, &num_results_out);
std::free(p);
if (!gen_ok || num_results_out == 0) {
results = NULL;
}
if (results == NULL) {
fprintf (stderr, "NO results\n");
if (input_image_buffer) free(input_image_buffer);

View File

@@ -50,7 +50,7 @@ target_include_directories(govibevoicecpp SYSTEM PRIVATE ${VIBEVOICE_DIR}/third_
# Link GPU backends if available — vibevoice's own CMake already links
# these to the libvibevoice STATIC library, but we re-link them on the
# MODULE so resolved symbols include all backend kernels.
foreach(backend blas cuda metal vulkan)
foreach(backend blas cuda hip metal vulkan)
if(TARGET ggml-${backend})
target_link_libraries(govibevoicecpp PRIVATE ggml-${backend})
string(TOUPPER ${backend} BACKEND_UPPER)

View File

@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1)
# already do for ik_llama.cpp / llama.cpp / whisper.cpp). Floating on
# `master` led to silent ABI breaks reaching CI — pin it.
VIBEVOICE_REPO?=https://github.com/mudler/vibevoice.cpp
VIBEVOICE_CPP_VERSION?=ad856bda6b1311b7f3d7c4a667be43eeb8a8249a
VIBEVOICE_CPP_VERSION?=000e37282bc5bb09edc20f7047a47924122ba3a0
SO_TARGET?=libgovibevoicecpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
@@ -29,7 +29,14 @@ else ifeq ($(BUILD_TYPE),openblas)
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DGGML_HIPBLAS=ON -DVIBEVOICE_GGML_HIPBLAS=ON
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
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
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON -DVIBEVOICE_GGML_VULKAN=ON
else ifeq ($(OS),Darwin)

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
@@ -10,7 +11,9 @@ import (
"path/filepath"
"runtime"
"strings"
"unsafe"
"github.com/ebitengine/purego"
laudio "github.com/mudler/LocalAI/pkg/audio"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
@@ -113,12 +116,76 @@ var (
refAudioPaths []*byte, nRefAudioPaths int32,
dstWav string,
nSteps int32, cfgScale float32, maxSpeechFrames int32, seed uint32) int32
// CppTTSStream drives vv_capi_tts_stream: it synthesizes `text` and
// invokes the C callback `cb` once per decoded PCM window instead of
// writing a file. `cb` is the address of a purego callback (see
// streamCB); `user` is an opaque pointer handed back to every
// callback invocation - we route via the package-level activeStream
// instead, so it is always nil here.
CppTTSStream func(text, voicePath string,
nSteps int32, cfgScale float32, maxFrames int32, seed uint32,
cb uintptr, user unsafe.Pointer) int32
CppASR func(srcWav string, outJSON []byte, capacity uint64,
maxNewTokens int32) int32
CppUnload func()
CppVersion func() string
)
// streamState carries the destination channel for one in-flight
// TTSStream call. vibevoice's engine is a single process-global, and
// backend calls are serialized through base.SingleThread, so a single
// package-level pointer is safe: only one TTSStream runs at a time.
type streamState struct {
results chan []byte
}
// activeStream points at the streamState for the currently-running
// TTSStream. The C callback (streamCB) and the deliverPCMForTest hook
// read it to find the channel. Guarded by base.SingleThread
// serialization; TTSStream sets it and clears it in a defer.
var activeStream *streamState
// pushPCM copies a transient int16 PCM window into a fresh little-endian
// []byte and pushes it onto the active stream. The C buffer handed to
// the callback is only valid for the duration of the call, so we must
// copy before returning. A nil/empty input or a missing active stream
// is a no-op.
func pushPCM(pcm []int16) {
s := activeStream
if s == nil || len(pcm) == 0 {
return
}
buf := make([]byte, len(pcm)*2)
for i, v := range pcm {
binary.LittleEndian.PutUint16(buf[i*2:], uint16(v))
}
s.results <- buf
}
// streamCB is the ONE reusable purego callback bound to the C ABI's
// vv_pcm_cb. purego cannot free callbacks and enforces a process-global
// limit, so we create exactly one at package init and reuse it for every
// TTSStream call - the per-call state lives in activeStream, not here.
// purego marshals the C `const int16_t*` first argument straight into a
// Go *int16, so we can unsafe.Slice it without a uintptr round-trip
// (keeps go vet clean); pushPCM copies the transient buffer out and
// returns 0 to keep synthesizing.
var streamCB = purego.NewCallback(func(samples *int16, n int32, _ uintptr) uintptr {
if activeStream == nil || samples == nil || n <= 0 {
return 0
}
pcm := unsafe.Slice(samples, int(n))
pushPCM(pcm)
return 0
})
// deliverPCMForTest exercises the exact copy-and-push path streamCB runs
// against activeStream, but from a Go []int16 - so unit tests can
// validate the callback -> channel framing without the C library.
func deliverPCMForTest(samples []int16) {
pushPCM(samples)
}
// VibevoiceCpp speaks gRPC against vibevoice.cpp's flat C ABI. The
// engine is a single global, so we serialize calls through SingleThread.
type VibevoiceCpp struct {
@@ -404,14 +471,16 @@ func (v *VibevoiceCpp) callASR(srcWav string, maxNewTokens int32) (string, error
return string(buf[:rc]), nil
}
// TTSStream is the streaming counterpart to TTS. vibevoice's C ABI is
// file-only (vv_capi_tts writes a complete WAV), so we synthesize to
// a tempfile, then emit a streaming-WAV header followed by the PCM
// body in chunks. The main reason this exists at all is the gRPC
// server wrapper (pkg/grpc/server.go:TTSStream) blocks on a channel
// that only this method can close - if we leave the default Base
// stub in place, every TTSStream call hangs until the client
// deadline.
// TTSStream is the streaming counterpart to TTS. It drives
// vv_capi_tts_stream, which synthesizes `text` and invokes our C
// callback (streamCB) once per decoded PCM window instead of writing a
// file - so the client starts receiving audio while the model is still
// generating. We first emit a streaming-WAV header, install the results
// channel as the active stream, then let the callback push each PCM
// window (copied to little-endian bytes) onto that channel. The gRPC
// server wrapper (pkg/grpc/server.go:TTSStream) blocks on the channel
// until this method closes it, so `defer close(results)` is mandatory
// even on the error paths.
func (v *VibevoiceCpp) TTSStream(req *pb.TTSRequest, results chan []byte) error {
defer close(results)
if v.ttsModel == "" {
@@ -421,28 +490,6 @@ func (v *VibevoiceCpp) TTSStream(req *pb.TTSRequest, results chan []byte) error
return fmt.Errorf("vibevoice-cpp: TTSStream requires text")
}
tmp, err := os.CreateTemp("", "vibevoice-cpp-stream-*.wav")
if err != nil {
return fmt.Errorf("vibevoice-cpp: tempfile: %w", err)
}
dst := tmp.Name()
_ = tmp.Close()
defer func() { _ = os.Remove(dst) }()
if err := v.TTS(&pb.TTSRequest{
Text: req.Text,
Voice: req.Voice,
Dst: dst,
Language: req.Language,
}); err != nil {
return err
}
wav, err := os.ReadFile(dst)
if err != nil {
return fmt.Errorf("vibevoice-cpp: read tempfile: %w", err)
}
// Streaming WAV header: declare 0xFFFFFFFF for chunk sizes so HTTP
// clients can start playback before they see the full PCM.
const streamingSize = 0xFFFFFFFF
@@ -455,18 +502,38 @@ func (v *VibevoiceCpp) TTSStream(req *pb.TTSRequest, results chan []byte) error
}
results <- hdrBuf
// PCM body: send in ~64 KB slices so the client gets multiple
// reply chunks (e2e harness asserts >=2 frames).
pcm := laudio.StripWAVHeader(wav)
const chunkBytes = 64 * 1024
for off := 0; off < len(pcm); off += chunkBytes {
end := off + chunkBytes
if end > len(pcm) {
end = len(pcm)
}
chunk := make([]byte, end-off)
copy(chunk, pcm[off:end])
results <- chunk
// vv_capi_tts_stream takes a single voice_path (realtime-0.5B path);
// unlike vv_capi_tts it has no ref_audio array. Resolve the per-call
// override when it names a voice gguf, otherwise fall back to the
// load-time default that already went to vv_capi_load.
voice := v.voice
if reqVoice := strings.TrimSpace(req.Voice); reqVoice != "" && !isRefAudioOverride(reqVoice) {
voice = resolvePath(reqVoice, v.modelRoot)
}
if req.Language != nil && *req.Language != "" {
fmt.Fprintf(os.Stderr,
"[vibevoice-cpp] note: TTSRequest.language=%q ignored - vibevoice picks language from the voice prompt\n",
*req.Language)
}
const (
defaultSteps = 20
defaultMaxFrames = 200
)
defaultCfg := float32(1.3)
// Serialized by base.SingleThread, so a single package-level
// activeStream is race-free: exactly one TTSStream runs at a time.
// The callback reads it to find `results`; clear it on the way out.
activeStream = &streamState{results: results}
defer func() { activeStream = nil }()
rc := CppTTSStream(req.Text, voice,
int32(defaultSteps), defaultCfg, int32(defaultMaxFrames), 0,
streamCB, nil)
if rc != 0 {
return fmt.Errorf("vibevoice-cpp: vv_capi_tts_stream failed (rc=%d)", rc)
}
return nil
}

View File

@@ -37,6 +37,7 @@ func main() {
libFuncs := []LibFuncs{
{&CppLoad, "vv_capi_load"},
{&CppTTS, "vv_capi_tts"},
{&CppTTSStream, "vv_capi_tts_stream"},
{&CppASR, "vv_capi_asr"},
{&CppUnload, "vv_capi_unload"},
{&CppVersion, "vv_capi_version"},

View File

@@ -0,0 +1,262 @@
package main
// Real end-to-end streaming integration test for the vibevoice-cpp
// backend. It drives the actual Go -> purego -> C path against the real
// model, proving that TTSStream delivers audio incrementally (measurable
// time-to-first-audio) while TTS only yields anything after full
// synthesis. Gated behind VIBEVOICE_IT=1 so normal `go test` and CI stay
// unaffected - it needs the built engine .so and ~1.7 GB of model files.
//
// Run:
//
// VIBEVOICE_IT=1 \
// VIBEVOICECPP_LIBRARY=<abs path to libgovibevoicecpp-fallback.so> \
// go test ./... -run TestVibevoiceCpp -v -timeout 600s
//
// Optional overrides (default to the staged bundle under
// ~/_git/vibevoice-models):
//
// VIBEVOICE_IT_MODEL vibevoice-realtime-0.5B-q8_0.gguf (abs path)
// VIBEVOICE_IT_TOKENIZER tokenizer.gguf (abs path)
// VIBEVOICE_IT_VOICE voice-en-Carter_man.gguf (abs path)
import (
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
"github.com/ebitengine/purego"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// itDefaultModelDir is where Task 6 staged the model bundle. Individual
// files can be overridden via the VIBEVOICE_IT_* env vars.
const itDefaultModelDir = "/home/mudler/_git/vibevoice-models"
// itLibLoaded guards the one-time purego Dlopen + RegisterLibFunc into
// the package-level Cpp* vars. purego cannot free a loaded library and
// the Cpp* symbols are process-global, so we bind exactly once.
var itLibLoaded sync.Once
// integrationOrSkip enforces the VIBEVOICE_IT=1 gate and binds the C ABI
// symbols into the package vars the backend calls (mirrors main.go's
// libFuncs list). It Skip()s the spec when the gate is off or the .so /
// model files are missing, so the suite stays green in every other run.
func integrationOrSkip() (model, tokenizer, voice string) {
if os.Getenv("VIBEVOICE_IT") != "1" {
Skip("VIBEVOICE_IT!=1, skipping real-model streaming integration test")
}
lib := os.Getenv("VIBEVOICECPP_LIBRARY")
if lib == "" {
Skip("VIBEVOICECPP_LIBRARY not set, cannot dlopen the engine .so")
}
if _, err := os.Stat(lib); err != nil {
Skip("engine .so not found at " + lib)
}
model = itEnvOr("VIBEVOICE_IT_MODEL", filepath.Join(itDefaultModelDir, "vibevoice-realtime-0.5B-q8_0.gguf"))
tokenizer = itEnvOr("VIBEVOICE_IT_TOKENIZER", filepath.Join(itDefaultModelDir, "tokenizer.gguf"))
voice = itEnvOr("VIBEVOICE_IT_VOICE", filepath.Join(itDefaultModelDir, "voice-en-Carter_man.gguf"))
for _, p := range []string{model, tokenizer, voice} {
if _, err := os.Stat(p); err != nil {
Skip("model file missing: " + p)
}
}
itLibLoaded.Do(func() {
handle, err := purego.Dlopen(lib, purego.RTLD_NOW|purego.RTLD_GLOBAL)
Expect(err).ToNot(HaveOccurred(), "dlopen %s", lib)
// Mirror the libFuncs list in main.go verbatim so the test binds
// the exact same symbols the production backend binary does.
purego.RegisterLibFunc(&CppLoad, handle, "vv_capi_load")
purego.RegisterLibFunc(&CppTTS, handle, "vv_capi_tts")
purego.RegisterLibFunc(&CppTTSStream, handle, "vv_capi_tts_stream")
purego.RegisterLibFunc(&CppASR, handle, "vv_capi_asr")
purego.RegisterLibFunc(&CppUnload, handle, "vv_capi_unload")
purego.RegisterLibFunc(&CppVersion, handle, "vv_capi_version")
})
return model, tokenizer, voice
}
func itEnvOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// itParseWavPCM returns the int16 PCM samples from a standard WAV file by
// locating the "data" sub-chunk, so it works regardless of how many
// bytes of header/metadata the engine wrote.
func itParseWavPCM(b []byte) []int16 {
// Find the "data" sub-chunk id, then its 4-byte little-endian size.
idx := -1
for i := 12; i+8 <= len(b); i += 2 {
if string(b[i:i+4]) == "data" {
idx = i
break
}
}
Expect(idx).To(BeNumerically(">=", 0), "no data chunk in WAV")
pcmStart := idx + 8
size := int(b[idx+4]) | int(b[idx+5])<<8 | int(b[idx+6])<<16 | int(b[idx+7])<<24
if size <= 0 || pcmStart+size > len(b) {
size = len(b) - pcmStart
}
n := size / 2
out := make([]int16, n)
for i := 0; i < n; i++ {
out[i] = int16(uint16(b[pcmStart+i*2]) | uint16(b[pcmStart+i*2+1])<<8)
}
return out
}
// itRMS computes the root-mean-square of int16 PCM as a sanity signal:
// > 0 means non-silent, and NaN/Inf-free means well-formed samples.
func itRMS(pcm []int16) float64 {
if len(pcm) == 0 {
return 0
}
var sumSq float64
for _, s := range pcm {
v := float64(s)
sumSq += v * v
}
return sqrt(sumSq / float64(len(pcm)))
}
// sqrt via Newton's method to avoid dragging math into the file's tiny
// need (keeps the dependency surface of the test minimal).
func sqrt(x float64) float64 {
if x <= 0 {
return 0
}
z := x
for i := 0; i < 40; i++ {
z = 0.5 * (z + x/z)
}
return z
}
var _ = Describe("VibeVoice-cpp real-model streaming (VIBEVOICE_IT=1)", Ordered, func() {
// The paragraph is deliberately long (multiple sentences) so the
// engine decodes several streaming windows and TTFA is meaningfully
// earlier than full synthesis.
const paragraph = "The quick brown fox jumps over the lazy dog near the riverbank. " +
"A gentle breeze carried the sound of distant bells across the quiet valley. " +
"Streaming synthesis lets you hear the very first words while the rest is still being generated."
var v *VibevoiceCpp
BeforeAll(func() {
model, tokenizer, voice := integrationOrSkip()
v = &VibevoiceCpp{}
err := v.Load(&pb.ModelOptions{
ModelFile: model,
ModelPath: filepath.Dir(model),
Options: []string{
"tokenizer=" + tokenizer,
"voice=" + voice,
},
Threads: 4,
})
Expect(err).ToNot(HaveOccurred(), "Load must succeed with the real model")
})
It("streams audio incrementally and beats the batch path to first audio", func() {
// ---- Streaming run -------------------------------------------
results := make(chan []byte, 256)
streamErr := make(chan error, 1)
start := time.Now()
go func() {
streamErr <- v.TTSStream(&pb.TTSRequest{Text: paragraph}, results)
}()
var (
header []byte
ttfa time.Duration
totalStream time.Duration
chunkCount int
pcmBytes int
firstPCM bool
)
for buf := range results {
if header == nil {
header = buf
continue
}
if len(buf) == 0 {
continue
}
if !firstPCM {
ttfa = time.Since(start)
firstPCM = true
}
chunkCount++
pcmBytes += len(buf)
}
totalStream = time.Since(start)
Expect(<-streamErr).ToNot(HaveOccurred(), "TTSStream returned an error")
// First message must be a 44-byte streaming WAV header.
Expect(header).To(HaveLen(44), "first stream message must be the WAV header")
Expect(string(header[0:4])).To(Equal("RIFF"))
Expect(string(header[8:12])).To(Equal("WAVE"))
// Streaming invariants: multiple windows, real audio, early
// delivery (first audio strictly before the stream completes).
Expect(chunkCount).To(BeNumerically(">=", 2), "expected multiple streamed PCM windows")
Expect(pcmBytes).To(BeNumerically(">", 0), "no PCM bytes streamed")
Expect(firstPCM).To(BeTrue(), "never received a PCM chunk")
Expect(ttfa).To(BeNumerically("<", totalStream), "TTFA must precede stream completion")
// ---- Batch baseline ------------------------------------------
tmp, err := os.MkdirTemp("", "vv-it-batch-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(tmp) })
dst := filepath.Join(tmp, "batch.wav")
batchStart := time.Now()
Expect(v.TTS(&pb.TTSRequest{Text: paragraph, Dst: dst})).To(Succeed())
totalBatch := time.Since(batchStart)
wav, err := os.ReadFile(dst)
Expect(err).ToNot(HaveOccurred())
Expect(len(wav)).To(BeNumerically(">", 44), "batch wav has no PCM payload")
Expect(string(wav[0:4])).To(Equal("RIFF"))
Expect(string(wav[8:12])).To(Equal("WAVE"))
batchPCM := itParseWavPCM(wav)
Expect(len(batchPCM)).To(BeNumerically(">", 0), "batch produced no samples")
rms := itRMS(batchPCM)
Expect(rms).To(BeNumerically(">", 0), "batch audio is silent")
Expect(rms).To(BeNumerically("<", 40000), "batch rms out of int16 range (corrupt samples)")
// ---- Headline numbers ----------------------------------------
// Emit to both GinkgoWriter and stderr: GinkgoWriter needs
// -ginkgo.v to surface, stderr is always captured so the headline
// TTFA vs batch numbers are never lost in an unattended run.
ratio := float64(ttfa) / float64(totalBatch)
report := fmt.Sprintf("\n================ vibevoice-cpp streaming TTFA ================\n"+
"input words : ~%d\n"+
"TTFA (first audio) : %v\n"+
"total_stream : %v\n"+
"total_batch : %v\n"+
"stream chunks : %d\n"+
"stream PCM bytes : %d\n"+
"batch samples/rms : %d / %.1f\n"+
"TTFA / total_batch : %.3f (first audio in this fraction of batch's deliver time)\n"+
"==============================================================\n\n",
len(paragraph)/6, ttfa, totalStream, totalBatch, chunkCount, pcmBytes, len(batchPCM), rms, ratio)
for _, out := range []io.Writer{GinkgoWriter, os.Stderr} {
_, _ = io.WriteString(out, report)
}
})
})

View File

@@ -0,0 +1,46 @@
package main
import (
"encoding/binary"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TTSStream callback framing", func() {
// The real C callback copies a transient int16 PCM buffer out of the
// engine into a fresh []byte and pushes it onto the active stream's
// results channel as little-endian bytes. deliverPCMForTest runs that
// exact copy-and-push path against a []int16 so we can validate the
// framing without the C library (full audio e2e is a later task).
It("copies int16 PCM from the C callback into the results channel as LE bytes", func() {
samples := []int16{0, 1, -1, 32767, -32768, 1234, -4321}
prev := activeStream
DeferCleanup(func() { activeStream = prev })
s := &streamState{results: make(chan []byte, 1)}
activeStream = s
deliverPCMForTest(samples)
var got []byte
Eventually(s.results).Should(Receive(&got))
Expect(got).To(HaveLen(len(samples) * 2))
want := make([]byte, len(samples)*2)
for i, v := range samples {
binary.LittleEndian.PutUint16(want[i*2:], uint16(v))
}
Expect(got).To(Equal(want))
})
It("is a no-op when there is no active stream", func() {
prev := activeStream
DeferCleanup(func() { activeStream = prev })
activeStream = nil
// Must not panic when no stream is installed.
Expect(func() { deliverPCMForTest([]int16{1, 2, 3}) }).ToNot(Panic())
})
})

View File

@@ -1,6 +1,6 @@
# voice-detect backend Makefile.
#
# Upstream pin lives below as VOICEDETECT_VERSION?=3d51077... (.github/bump_deps.sh
# Upstream pin lives below as VOICEDETECT_VERSION?=1db1759572c90faef6f3a78c36b5941a096a9f89
# can find and update it - matches the parakeet.cpp / whisper.cpp / ds4 convention).
#
# Local dev shortcut: if you already have an out-of-tree voice-detect.cpp build,
@@ -13,7 +13,7 @@
# The default target below does the proper clone-at-pin + cmake build so CI does
# not need a side-checkout.
VOICEDETECT_VERSION?=3d510772357538c5182808ac7de2278b84824e24
VOICEDETECT_VERSION?=1db1759572c90faef6f3a78c36b5941a096a9f89
VOICEDETECT_REPO?=https://github.com/mudler/voice-detect.cpp
GOCMD?=go

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?=0ae02cdb2c7317b50991367c165736ce42ed96ac
WHISPER_CPP_VERSION?=080bbbe85230f624f0b52127f1ae1218247989f9
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -178,6 +178,35 @@
nvidia-cuda-12: "cuda12-parakeet-cpp"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-parakeet-cpp"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-parakeet-cpp"
- &mosstranscribecpp
name: "moss-transcribe-cpp"
alias: "moss-transcribe-cpp"
license: mit
icon: https://avatars.githubusercontent.com/u/95302084
description: |
moss-transcribe.cpp is a C++/ggml port of OpenMOSS MOSS-Transcribe-Diarize, an end-to-end audio understanding model.
It does joint long-form transcription, speaker diarization and timestamping in a single pass, emitting a speaker-labelled,
time-aligned transcript, and runs on CPU, NVIDIA CUDA, AMD ROCm/HIP, Intel SYCL, Vulkan, Apple Metal and NVIDIA Jetson (L4T) targets.
urls:
- https://github.com/mudler/moss-transcribe.cpp
tags:
- audio-transcription
- CPU
- GPU
- CUDA
- HIP
capabilities:
default: "cpu-moss-transcribe-cpp"
nvidia: "cuda12-moss-transcribe-cpp"
intel: "intel-sycl-f16-moss-transcribe-cpp"
metal: "metal-moss-transcribe-cpp"
amd: "rocm-moss-transcribe-cpp"
vulkan: "vulkan-moss-transcribe-cpp"
nvidia-l4t: "nvidia-l4t-arm64-moss-transcribe-cpp"
nvidia-cuda-13: "cuda13-moss-transcribe-cpp"
nvidia-cuda-12: "cuda12-moss-transcribe-cpp"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-moss-transcribe-cpp"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-moss-transcribe-cpp"
- &ced
name: "ced"
alias: "ced"
@@ -795,6 +824,30 @@
nvidia-cuda-12: "cuda12-vllm-omni"
nvidia-cuda-13: "cuda13-vllm-omni"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-vllm-omni"
- &longcat-video
name: "longcat-video"
alias: "longcat-video"
license: mit
urls:
- https://github.com/meituan-longcat/LongCat-Video
tags:
- text-to-video
- image-to-video
- audio-to-video
- avatar-generation
- video-generation
- CUDA
icon: https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/longcat-video_logo.svg
description: |
LongCat-Video generation for text, image, and audio-conditioned avatars.
Supports LongCat-Video and LongCat-Video-Avatar-1.5, including multi-segment
talking-head continuation and an SDPA path for NVIDIA Blackwell ARM64 systems.
Requires Linux with an NVIDIA CUDA GPU; CPU, ROCm, and macOS are unsupported.
capabilities:
nvidia: "cuda12-longcat-video"
nvidia-cuda-12: "cuda12-longcat-video"
nvidia-cuda-13: "cuda13-longcat-video"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-longcat-video"
- &mlx
name: "mlx"
icon: https://avatars.githubusercontent.com/u/102832242?s=200&v=4
@@ -2784,6 +2837,121 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-parakeet-cpp"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-parakeet-cpp
## moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "moss-transcribe-cpp-development"
capabilities:
default: "cpu-moss-transcribe-cpp-development"
nvidia: "cuda12-moss-transcribe-cpp-development"
intel: "intel-sycl-f16-moss-transcribe-cpp-development"
metal: "metal-moss-transcribe-cpp-development"
amd: "rocm-moss-transcribe-cpp-development"
vulkan: "vulkan-moss-transcribe-cpp-development"
nvidia-l4t: "nvidia-l4t-arm64-moss-transcribe-cpp-development"
nvidia-cuda-13: "cuda13-moss-transcribe-cpp-development"
nvidia-cuda-12: "cuda12-moss-transcribe-cpp-development"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-moss-transcribe-cpp-development"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-moss-transcribe-cpp-development"
- !!merge <<: *mosstranscribecpp
name: "nvidia-l4t-arm64-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-arm64-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-arm64-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "nvidia-l4t-arm64-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-arm64-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-nvidia-l4t-arm64-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "cuda13-nvidia-l4t-arm64-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-cuda-13-arm64-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-cuda-13-arm64-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "cuda13-nvidia-l4t-arm64-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "cpu-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-cpu-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "cpu-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-cpu-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "metal-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-metal-darwin-arm64-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "metal-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "cuda12-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-12-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "cuda12-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "rocm-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-gpu-rocm-hipblas-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "rocm-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-gpu-rocm-hipblas-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "intel-sycl-f32-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f32-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-gpu-intel-sycl-f32-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "intel-sycl-f32-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-sycl-f32-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-gpu-intel-sycl-f32-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "intel-sycl-f16-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f16-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-gpu-intel-sycl-f16-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "intel-sycl-f16-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-sycl-f16-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-gpu-intel-sycl-f16-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "vulkan-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-gpu-vulkan-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "vulkan-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-vulkan-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-gpu-vulkan-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "cuda13-moss-transcribe-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-13-moss-transcribe-cpp
- !!merge <<: *mosstranscribecpp
name: "cuda13-moss-transcribe-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-moss-transcribe-cpp"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-moss-transcribe-cpp
## ced
- !!merge <<: *ced
name: "ced-development"
@@ -3461,6 +3629,44 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-vllm-omni"
mirrors:
- localai/localai-backends:master-gpu-rocm-hipblas-vllm-omni
# longcat-video
- !!merge <<: *longcat-video
name: "longcat-video-development"
capabilities:
nvidia: "cuda12-longcat-video-development"
nvidia-cuda-12: "cuda12-longcat-video-development"
nvidia-cuda-13: "cuda13-longcat-video-development"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-longcat-video-development"
- !!merge <<: *longcat-video
name: "cuda12-longcat-video"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-longcat-video"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-12-longcat-video
- !!merge <<: *longcat-video
name: "cuda13-longcat-video"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-longcat-video"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-13-longcat-video
- !!merge <<: *longcat-video
name: "cuda13-nvidia-l4t-arm64-longcat-video"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-cuda-13-arm64-longcat-video"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-cuda-13-arm64-longcat-video
- !!merge <<: *longcat-video
name: "cuda12-longcat-video-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-longcat-video"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-longcat-video
- !!merge <<: *longcat-video
name: "cuda13-longcat-video-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-longcat-video"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-longcat-video
- !!merge <<: *longcat-video
name: "cuda13-nvidia-l4t-arm64-longcat-video-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-longcat-video"
mirrors:
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-longcat-video
# rfdetr
- !!merge <<: *rfdetr
name: "rfdetr-development"

View File

@@ -27,6 +27,7 @@ The Python backends use a unified build system based on `libbackend.sh` that pro
### Computer Vision
- **diffusers** - Stable Diffusion and image generation
- **longcat-video** - CUDA video and speech-driven avatar generation with LongCat-Video
- **mlx-vlm** - Vision-language models for Apple Silicon
- **rfdetr** - Object detection models

View File

@@ -199,7 +199,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
if "language" in self.options:
kwargs["language_id"] = self.options["language"]
if self.AudioPath is not None:
if request.voice and os.path.isfile(request.voice):
kwargs["audio_prompt_path"] = request.voice
elif self.AudioPath is not None:
kwargs["audio_prompt_path"] = self.AudioPath
# add options to kwargs
@@ -211,6 +213,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# strings on the wire and are coerced to float/int/bool.
if hasattr(request, "params") and request.params:
for key, value in request.params.items():
if key == "ref_text":
continue
kwargs[key] = coerce_param_value(value)
# Check if text exceeds 250 characters

View File

@@ -11,6 +11,8 @@ import os
import grpc
from parent_watch import start_parent_death_watcher
class _AbortHandler(grpc.RpcMethodHandler):
"""A method handler that immediately aborts with UNAUTHENTICATED."""
@@ -70,6 +72,13 @@ def get_auth_interceptors(*, aio: bool = False):
Returns an empty list when LOCALAI_GRPC_AUTH_TOKEN is not set.
"""
# Arm the best-effort parent-death backstop here: this is the single helper
# every LocalAI Python backend invokes exactly once while building its gRPC
# server (mirroring how the Go watcher arms in pkg/grpc's shared serve path).
# start_parent_death_watcher() is idempotent and a no-op when disabled or on
# unsupported platforms — see parent_watch.py.
start_parent_death_watcher()
token = os.environ.get("LOCALAI_GRPC_AUTH_TOKEN", "")
if not token:
return []

View File

@@ -397,10 +397,29 @@ function ensureVenv() {
function runProtogen() {
ensureVenv
# Match grpcio-tools to the grpcio already installed by the backend's
# requirements. grpcio and grpcio-tools are released in lockstep, and the
# protoc that grpcio-tools bundles stamps a Protobuf "gencode" version into
# backend_pb2.py. Left unpinned, `uv pip install grpcio-tools` pulls the
# newest release, whose newer gencode (e.g. 7.35.0) trips Protobuf's
# runtime >= gencode guarantee at import time when a backend caps the
# protobuf runtime lower (vLLM pins it to 6.33.6), crashing the backend with
# "grpc service not ready" before it ever loads a model. Pinning
# grpcio-tools to the installed grpcio version keeps the gencode in step with
# the runtime. Falls back to unpinned when grpcio isn't installed yet.
# See mudler/LocalAI#10718.
local grpcio_tools_spec="grpcio-tools"
local grpcio_version
grpcio_version="$(python -c 'import importlib.metadata as m; print(m.version("grpcio"))' 2>/dev/null || true)"
if [ -n "${grpcio_version}" ]; then
grpcio_tools_spec="grpcio-tools==${grpcio_version}"
fi
if [ "x${USE_PIP}" == "xtrue" ]; then
pip install grpcio-tools
pip install "${grpcio_tools_spec}"
else
uv pip install grpcio-tools
uv pip install "${grpcio_tools_spec}"
fi
pushd "${EDIR}" >/dev/null
# use the venv python (ensures correct interpreter & sys.path)

View File

@@ -20,7 +20,15 @@ def split_reasoning(text, think_start, think_end):
Returns ``(reasoning_content, remaining_text)``. When ``think_start`` is
empty or not found, returns ``("", text)`` unchanged.
"""
if not think_start or not text or think_start not in text:
if not think_start or not text:
return "", text
if think_start not in text:
# Models like Qwen3.5 open assistant turns already INSIDE thinking, so
# the generated text carries only the closing tag. Everything before it
# is reasoning that would otherwise leak into the content.
if think_end and think_end in text:
head, _, tail = text.partition(think_end)
return head.strip(), tail.strip()
return "", text
pattern = re.compile(
re.escape(think_start) + r"(.*?)" + re.escape(think_end or ""),

View File

@@ -0,0 +1,75 @@
"""Unit tests for the mlx/mlx-vlm shared helpers (mlx_utils.py).
Run standalone (Python standard library only, no backend venv needed):
python3 -m unittest mlx_utils_test
These mirror the server-less helper tests in backend/python/mlx/test.py
(TestSharedHelpers), but live here so they run on any platform: the mlx
test module imports grpc/backend_pb2 at import time and needs the MLX venv,
whereas mlx_utils only needs the standard library.
"""
import types
import unittest
from mlx_utils import parse_tool_calls, split_reasoning
class TestSplitReasoning(unittest.TestCase):
def test_both_tags(self):
r, c = split_reasoning(
"<think>step 1\nstep 2</think>The answer is 42.", "<think>", "</think>"
)
self.assertEqual(r, "step 1\nstep 2")
self.assertEqual(c, "The answer is 42.")
def test_implicit_opener_only_closing_tag(self):
# Qwen3.5 opens the assistant turn already inside thinking, so the
# output carries only the closing tag; everything before it is reasoning.
r, c = split_reasoning(
"The user is asking about the weather.\n</think>\n\nThe weather in Rome is sunny.",
"<think>",
"</think>",
)
self.assertEqual(r, "The user is asking about the weather.")
self.assertEqual(c, "The weather in Rome is sunny.")
def test_no_tags_at_all(self):
r, c = split_reasoning("just text", "<think>", "</think>")
self.assertEqual(r, "")
self.assertEqual(c, "just text")
def test_empty_think_end_and_no_opener_match(self):
# No think_end to anchor on, and the opener is absent → return unchanged.
r, c = split_reasoning("no opener here", "<think>", "")
self.assertEqual(r, "")
self.assertEqual(c, "no opener here")
def test_empty_text(self):
r, c = split_reasoning("", "<think>", "</think>")
self.assertEqual(r, "")
self.assertEqual(c, "")
class TestParseToolCalls(unittest.TestCase):
def test_with_shim(self):
tm = types.SimpleNamespace(
tool_call_start="<tool_call>",
tool_call_end="</tool_call>",
parse_tool_call=lambda body, tools: {
"name": "get_weather",
"arguments": {"location": body.strip()},
},
)
calls, remaining = parse_tool_calls(
"Sure: <tool_call>Paris</tool_call>", tm, tools=None
)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["name"], "get_weather")
self.assertEqual(calls[0]["arguments"], '{"location": "Paris"}')
self.assertEqual(calls[0]["index"], 0)
self.assertNotIn("<tool_call>", remaining)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,149 @@
"""Parent-death watcher (best-effort backstop) for LocalAI Python backends.
LocalAI spawns each backend as a child process and, on a clean shutdown, tears
it down itself (SIGTERM -> grace -> SIGKILL). That graceful path only runs when
LocalAI receives a catchable signal and lives long enough to run its handlers.
If LocalAI is SIGKILLed (e.g. a supervising process's grace period elapses
first), that teardown never runs and this backend would be reparented to init
and linger, holding GPU/VRAM and its listen port.
The watcher here is a best-effort backstop for exactly that case: it does NOT
replace the graceful teardown, it only covers the "parent vanished without
cleaning up" path. It detects reparenting: when the process that spawned this
backend dies, the kernel reparents us to the nearest sub-reaper or to init
(PID 1), so os.getppid() stops matching the value captured at startup. This
getppid() approach is portable across Linux/macOS (unlike the Linux-only
PR_SET_PDEATHSIG), which is why it is used here, mirroring the Go backends'
pkg/grpc/parentwatch.go and the C++ backends' parent_watch.h. It is disabled on
Windows, which has no equivalent orphan-reparenting semantics.
Env vars (shared verbatim across the Go, C++ and Python backends):
LOCALAI_BACKEND_PARENT_WATCH enabled by default; a falsey value
("false"/"0"/"no"/"off", case-insensitive)
disables it.
LOCALAI_BACKEND_PARENT_WATCH_INTERVAL poll interval as a Go-style duration
string ("500ms", "2s", "1m") or a bare
number of seconds. Defaults to 2s.
"""
import os
import sys
import threading
ENV_PARENT_WATCH = "LOCALAI_BACKEND_PARENT_WATCH"
ENV_PARENT_WATCH_INTERVAL = "LOCALAI_BACKEND_PARENT_WATCH_INTERVAL"
_DEFAULT_INTERVAL_SECONDS = 2.0
# Guard so repeated calls (e.g. get_auth_interceptors invoked more than once)
# only ever arm a single watcher thread per process.
_started = False
_started_lock = threading.Lock()
def _enabled():
"""Report whether the watcher should run in this process."""
# Windows does not reparent orphans to a well-known init PID, so the
# getppid() heuristic used here doesn't apply there.
if os.name == "nt" or sys.platform.startswith("win"):
return False
val = os.environ.get(ENV_PARENT_WATCH, "").strip().lower()
if val in ("false", "0", "no", "off"):
return False
return True
def _interval_seconds():
"""Return the configured poll interval in seconds, or the default.
Accepts Go-style duration strings ("500ms", "2s", "1m") for cross-language
parity, or a bare number interpreted as seconds.
"""
raw = os.environ.get(ENV_PARENT_WATCH_INTERVAL, "").strip()
if not raw:
return _DEFAULT_INTERVAL_SECONDS
# Split numeric prefix from unit suffix.
i = 0
while i < len(raw) and (raw[i].isdigit() or raw[i] == "." or (i == 0 and raw[i] in "+-")):
i += 1
if i == 0:
return _DEFAULT_INTERVAL_SECONDS
try:
num = float(raw[:i])
except ValueError:
return _DEFAULT_INTERVAL_SECONDS
unit = raw[i:].lower()
if unit == "ms":
seconds = num / 1000.0
elif unit in ("s", ""):
seconds = num
elif unit == "m":
seconds = num * 60.0
else:
return _DEFAULT_INTERVAL_SECONDS
return seconds if seconds > 0 else _DEFAULT_INTERVAL_SECONDS
def _parent_died(orig_ppid):
"""Report whether this process has been reparented away from orig_ppid.
Reparenting is the standard POSIX signal that the original parent (here, the
LocalAI process that spawned this backend) has exited: the orphan is handed
to the nearest sub-reaper or to init (PID 1), so os.getppid() no longer
matches the value captured at startup.
"""
ppid = os.getppid()
return ppid != orig_ppid or ppid == 1
def _watch(orig_ppid, interval, on_death):
"""Poll until _parent_died reports the original parent is gone, then call
on_death. Blocks, so run it on its own (daemon) thread."""
import time
while True:
time.sleep(interval)
if _parent_died(orig_ppid):
on_death()
return
def start_parent_death_watcher():
"""Install the best-effort safety net described in this module's docstring.
No-op when disabled, on Windows, when already orphaned at startup
(os.getppid() <= 1), or if already started. This is a backstop alongside —
never a replacement for — LocalAI's graceful teardown.
"""
global _started
if not _enabled():
return
with _started_lock:
if _started:
return
orig_ppid = os.getppid()
# A parent of 1 (or less) at startup means we were already orphaned (or
# launched directly under init) — there is no original parent to watch.
if orig_ppid <= 1:
return
interval = _interval_seconds()
def on_death():
print(
"backend parent process (pid {}) exited without stopping this "
"backend; self-terminating to avoid orphaning".format(orig_ppid),
file=sys.stderr,
flush=True,
)
# Immediate, non-cleanup exit: this is a shutdown safety net and the
# normal graceful path is already gone.
os._exit(1)
thread = threading.Thread(
target=_watch,
args=(orig_ppid, interval, on_death),
name="parent-death-watcher",
daemon=True,
)
thread.start()
_started = True

View File

@@ -0,0 +1,150 @@
"""Unit tests for the parent-death watcher (parent_watch.py).
Run standalone (Python standard library only, no backend venv needed):
python3 -m unittest parent_watch_test
The core test (test_detects_reparent) builds a genuine two-level process tree
(test -> middle -> grandchild) with os.fork, lets the middle process die, and
asserts the grandchild's parent_watch._watch detects the reparenting and
self-terminates — mirroring the Go test in pkg/grpc/parentwatch_test.go and the
C++ test in backend/cpp/llama-cpp/parent_watch_test.cpp.
"""
import os
import sys
import tempfile
import threading
import time
import unittest
import parent_watch
class TestParentWatchEnvParsing(unittest.TestCase):
def setUp(self):
self._saved = {
k: os.environ.get(k)
for k in (parent_watch.ENV_PARENT_WATCH, parent_watch.ENV_PARENT_WATCH_INTERVAL)
}
for k in self._saved:
os.environ.pop(k, None)
def tearDown(self):
for k, v in self._saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def test_interval_default(self):
self.assertEqual(parent_watch._interval_seconds(), 2.0)
def test_interval_units(self):
cases = {"500ms": 0.5, "2s": 2.0, "1m": 60.0, "3": 3.0, "0.5s": 0.5}
for raw, expected in cases.items():
os.environ[parent_watch.ENV_PARENT_WATCH_INTERVAL] = raw
self.assertAlmostEqual(parent_watch._interval_seconds(), expected, msg=raw)
def test_interval_garbage_falls_back(self):
os.environ[parent_watch.ENV_PARENT_WATCH_INTERVAL] = "garbage"
self.assertEqual(parent_watch._interval_seconds(), 2.0)
@unittest.skipIf(os.name == "nt" or sys.platform.startswith("win"), "POSIX only")
def test_enabled_default(self):
self.assertTrue(parent_watch._enabled())
@unittest.skipIf(os.name == "nt" or sys.platform.startswith("win"), "POSIX only")
def test_disabled_by_falsey(self):
for val in ("false", "0", "no", "off", "OFF", " False "):
os.environ[parent_watch.ENV_PARENT_WATCH] = val
self.assertFalse(parent_watch._enabled(), msg=val)
@unittest.skipIf(os.name == "nt" or sys.platform.startswith("win"), "POSIX only")
def test_enabled_by_truthy(self):
for val in ("true", "1", "yes", "on"):
os.environ[parent_watch.ENV_PARENT_WATCH] = val
self.assertTrue(parent_watch._enabled(), msg=val)
@unittest.skipIf(os.name == "nt" or sys.platform.startswith("win"), "fork/reparent is POSIX only")
class TestParentWatchReparent(unittest.TestCase):
def _wait_for_file(self, path, timeout=10.0):
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(path):
return True
time.sleep(0.02)
return False
def test_detects_reparent(self):
tmpdir = tempfile.mkdtemp(prefix="parentwatch_test_")
ready_file = os.path.join(tmpdir, "ready")
exited_file = os.path.join(tmpdir, "exited")
middle = os.fork()
if middle == 0:
# ---- middle process ----
grandchild = os.fork()
if grandchild == 0:
# ---- grandchild process: arm the REAL watcher against middle ----
orig_ppid = os.getppid()
def on_death():
with open(exited_file, "w") as f:
f.write("1")
os._exit(7)
threading.Thread(
target=parent_watch._watch,
args=(orig_ppid, 0.05, on_death),
daemon=True,
).start()
# Safety valve: never linger if something goes wrong.
def bail():
time.sleep(30)
os._exit(2)
threading.Thread(target=bail, daemon=True).start()
# Signal readiness only after the watcher captured orig_ppid.
with open(ready_file, "w") as f:
f.write(str(os.getpid()))
while True:
time.sleep(1)
else:
# middle: wait until grandchild is ready, then exit to orphan it.
if not self._wait_for_file(ready_file):
os._exit(5)
os._exit(0)
# ---- test (top) process ----
os.waitpid(middle, 0) # reap middle only; grandchild is orphaned
self.assertTrue(os.path.exists(ready_file), "grandchild never signaled readiness")
self.assertTrue(
self._wait_for_file(exited_file),
"watcher did not detect parent death within timeout",
)
# Best-effort cleanup: kill the grandchild if it somehow survived.
try:
with open(ready_file) as f:
pid = int(f.read().strip())
if pid > 1:
os.kill(pid, 9)
except (OSError, ValueError):
pass
for p in (ready_file, exited_file):
try:
os.remove(p)
except OSError:
pass
try:
os.rmdir(tmpdir)
except OSError:
pass
if __name__ == "__main__":
unittest.main()

View File

@@ -58,7 +58,18 @@ def messages_to_dicts(proto_messages):
d["reasoning_content"] = msg.reasoning_content
if msg.tool_calls:
try:
d["tool_calls"] = json.loads(msg.tool_calls)
tool_calls = json.loads(msg.tool_calls)
# Chat templates (e.g. Qwen) iterate function.arguments as a
# mapping, but the OpenAI wire format carries it as a JSON
# string — decode it back so the template's .items() works.
for tc in tool_calls:
fn = tc.get("function") if isinstance(tc, dict) else None
if isinstance(fn, dict) and isinstance(fn.get("arguments"), str):
try:
fn["arguments"] = json.loads(fn["arguments"])
except json.JSONDecodeError:
pass
d["tool_calls"] = tool_calls
except json.JSONDecodeError:
pass
result.append(d)

View File

@@ -0,0 +1,122 @@
"""Unit tests for the shared python backend helpers (python_utils.py).
Run standalone (Python standard library only, no backend venv needed):
python3 -m unittest python_utils_test
These mirror the server-less helper tests in backend/python/mlx/test.py
(TestSharedHelpers), but live here so they run on any platform: the mlx
test module imports grpc/backend_pb2 at import time and needs the MLX venv,
whereas python_utils has no third-party dependency. Proto Message objects
are faked with types.SimpleNamespace (real proto fields default to "").
"""
import json
import types
import unittest
from python_utils import messages_to_dicts, parse_options
def _msg(**fields):
"""Fake a proto Message: every unset field is the empty string, as protobuf."""
defaults = {
"role": "",
"content": "",
"name": "",
"tool_call_id": "",
"reasoning_content": "",
"tool_calls": "",
}
defaults.update(fields)
return types.SimpleNamespace(**defaults)
class TestParseOptions(unittest.TestCase):
def test_type_inference(self):
opts = parse_options(
["temperature:0.7", "max_tokens:128", "trust:true", "name:hello", "no_colon_skipped"]
)
self.assertEqual(opts["temperature"], 0.7)
self.assertEqual(opts["max_tokens"], 128)
self.assertIs(opts["trust"], True)
self.assertEqual(opts["name"], "hello")
self.assertNotIn("no_colon_skipped", opts)
class TestMessagesToDicts(unittest.TestCase):
def test_basic_fields(self):
out = messages_to_dicts(
[
_msg(role="user", content="hi"),
_msg(role="tool", content="42", tool_call_id="call_1", name="f"),
]
)
self.assertEqual(out[0], {"role": "user", "content": "hi"})
self.assertEqual(out[1]["tool_call_id"], "call_1")
self.assertEqual(out[1]["name"], "f")
def test_tool_call_arguments_string_decoded_to_mapping(self):
# OpenAI wire format ships function.arguments as a JSON *string*; chat
# templates iterate it as a mapping, so it must come back as a dict.
out = messages_to_dicts(
[
_msg(
role="assistant",
tool_calls=json.dumps(
[
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "Rome"}',
},
}
]
),
)
]
)
args = out[0]["tool_calls"][0]["function"]["arguments"]
self.assertEqual(args, {"location": "Rome"})
self.assertEqual(dict(args.items()), {"location": "Rome"})
def test_tool_call_arguments_already_mapping_is_idempotent(self):
out = messages_to_dicts(
[
_msg(
role="assistant",
tool_calls=json.dumps(
[{"function": {"name": "f", "arguments": {"a": 1}}}]
),
)
]
)
self.assertEqual(out[0]["tool_calls"][0]["function"]["arguments"], {"a": 1})
def test_tool_call_arguments_invalid_json_left_as_string(self):
out = messages_to_dicts(
[
_msg(
role="assistant",
tool_calls=json.dumps(
[{"function": {"name": "f", "arguments": "not-json"}}]
),
)
]
)
self.assertEqual(out[0]["tool_calls"][0]["function"]["arguments"], "not-json")
def test_tool_call_without_function_key(self):
out = messages_to_dicts(
[_msg(role="assistant", tool_calls=json.dumps([{"id": "call_1"}]))]
)
self.assertEqual(out[0]["tool_calls"], [{"id": "call_1"}])
def test_tool_calls_invalid_json_dropped(self):
out = messages_to_dicts([_msg(role="assistant", tool_calls="{not json")])
self.assertNotIn("tool_calls", out[0])
if __name__ == "__main__":
unittest.main()

View File

@@ -79,14 +79,19 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
if self.tts.is_multi_lingual and lang is None:
return backend_pb2.Result(success=False, message=f"Model is multi-lingual, but no language was provided")
# if model is multi-speaker, use speaker_wav or the speaker_id from request.voice
if self.tts.is_multi_speaker and self.AudioPath is None and request.voice is None:
# A path-shaped per-request voice is a cloning reference; otherwise
# preserve Coqui's named-speaker behavior.
request_voice = request.voice if request.voice else ""
speaker_wav = request_voice if os.path.isfile(request_voice) else self.AudioPath
if self.tts.is_multi_speaker and speaker_wav is None and not request_voice:
return backend_pb2.Result(success=False, message=f"Model is multi-speaker, but no speaker was provided")
if self.tts.is_multi_speaker and request.voice is not None:
self.tts.tts_to_file(text=request.text, speaker=request.voice, language=lang, file_path=request.dst)
if speaker_wav is not None:
self.tts.tts_to_file(text=request.text, speaker_wav=speaker_wav, language=lang, file_path=request.dst)
elif self.tts.is_multi_speaker and request_voice:
self.tts.tts_to_file(text=request.text, speaker=request_voice, language=lang, file_path=request.dst)
else:
self.tts.tts_to_file(text=request.text, speaker_wav=self.AudioPath, language=lang, file_path=request.dst)
self.tts.tts_to_file(text=request.text, language=lang, file_path=request.dst)
except Exception as err:
return backend_pb2.Result(success=False, message=f"Unexpected {err=}, {type(err)=}")
return backend_pb2.Result(success=True)

View File

@@ -864,8 +864,13 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
**kwargs
).images[0]
# save the result
image.save(request.dst)
# save the result. Save as PNG explicitly instead of letting Pillow
# infer the encoder from the extension: the core passes an absolute
# staging path ending in .tmp (e.g. /staging/localai-output-*.tmp),
# which Pillow can't map to a format and would raise
# "unknown file extension: .tmp". LocalAI serves generated images as
# PNG regardless of the temp path.
image.save(request.dst, format="PNG")
return backend_pb2.Result(message="Media generated", success=True)

View File

@@ -83,20 +83,23 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
return backend_pb2.Result(message="Model loaded successfully", success=True)
def _get_ref_audio_path(self, request):
if not self.audio_path:
# A per-request voice path is the canonical LocalAI voice-profile
# contract. Keep AudioPath as the backwards-compatible YAML fallback.
audio_path = request.voice if hasattr(request, "voice") and request.voice else self.audio_path
if not audio_path:
return None
if os.path.isabs(self.audio_path):
return self.audio_path
if os.path.isabs(audio_path):
return audio_path
if self.model_file:
model_file_base = os.path.dirname(self.model_file)
ref_path = os.path.join(model_file_base, self.audio_path)
ref_path = os.path.join(model_file_base, audio_path)
if os.path.exists(ref_path):
return ref_path
if self.model_path:
ref_path = os.path.join(self.model_path, self.audio_path)
ref_path = os.path.join(self.model_path, audio_path)
if os.path.exists(ref_path):
return ref_path
return self.audio_path
return audio_path
def TTS(self, request, context):
try:
@@ -122,13 +125,13 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
success=False,
message="AudioPath is required for voice clone (set in LoadModel)"
)
ref_text = self.options.get("ref_text")
if not ref_text and hasattr(request, 'ref_text') and request.ref_text:
ref_text = request.ref_text
ref_text = request.params.get("ref_text") if hasattr(request, "params") else None
if not ref_text:
ref_text = self.options.get("ref_text")
if not ref_text:
return backend_pb2.Result(
success=False,
message="ref_text is required for voice clone (set via LoadModel Options, e.g. ref_text:Your reference transcript)"
message="ref_text is required for voice clone (set in request.params or LoadModel options)"
)
chunk_size = self.options.get("chunk_size")

View File

@@ -267,6 +267,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
def _get_ref_audio_path(self, voice_name=None):
"""Get reference audio path from voices dict or stored AudioPath."""
if voice_name and os.path.isfile(voice_name):
return voice_name
if voice_name and voice_name in self.voices:
audio_path = self.voices[voice_name]["audio"]
@@ -332,7 +334,19 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
references = []
voice_name = request.voice if request.voice else None
if voice_name and voice_name in self.voices:
if 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()
ref_text = request.params.get("ref_text", "") if hasattr(request, "params") else ""
references.append(
ServeReferenceAudio(audio=audio_bytes, text=ref_text)
)
print(
f"[INFO] Using per-request reference audio: {ref_audio_path}",
file=sys.stderr,
)
elif voice_name and voice_name in self.voices:
ref_audio_path = self._get_ref_audio_path(voice_name)
if ref_audio_path and os.path.exists(ref_audio_path):
with open(ref_audio_path, "rb") as f:
@@ -350,7 +364,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
if ref_audio_path and os.path.exists(ref_audio_path):
with open(ref_audio_path, "rb") as f:
audio_bytes = f.read()
ref_text = self.options.get("ref_text", "")
ref_text = request.params.get("ref_text", "") if hasattr(request, "params") else ""
if not ref_text:
ref_text = self.options.get("ref_text", "")
references.append(
ServeReferenceAudio(audio=audio_bytes, text=ref_text)
)

View File

@@ -13,6 +13,17 @@ fi
# fish-speech uses pyrootutils which requires a .project-root marker
touch "${backend_dir}/.project-root"
# On darwin arm64 the transitive `tokenizers` dep compiles its Rust extension
# from source (Linux uses prebuilt manylinux wheels, so it never compiles
# there). The pinned tokenizers crate that fish-speech's stack resolves to
# contains a `&T` -> `&mut T` cast that trips the now-deny-by-default
# `invalid_reference_casting` lint in the macOS runner's newer Rust toolchain,
# breaking the build (seen in the v4.5.5 release CI fish-speech darwin/metal
# job). Allow that lint so the unchanged third-party crate compiles as before.
# Append rather than clobber any pre-existing RUSTFLAGS; harmless on Linux
# where no Rust compile happens.
export RUSTFLAGS="${RUSTFLAGS:-} -A invalid_reference_casting"
installRequirements
# Clone fish-speech source (the pip package doesn't include inference modules)

View File

@@ -3,4 +3,5 @@ protobuf
certifi
packaging==24.1
pip
chardet
chardet
click

View File

@@ -0,0 +1,6 @@
backend_pb2.py
backend_pb2_grpc.py
lib/
python/
sources/
venv/

View File

@@ -0,0 +1,36 @@
# SPDX-License-Identifier: MIT
LONGCAT_VIDEO_VERSION?=6b3f4b8582a8bc3f20f795735f5383716c4ba794
LONGCAT_VIDEO_REPO?=https://github.com/meituan-longcat/LongCat-Video
LONGCAT_SOURCE_STAMP=sources/LongCat-Video/.localai-$(LONGCAT_VIDEO_VERSION)
.PHONY: all
all: $(LONGCAT_SOURCE_STAMP)
bash install.sh
$(LONGCAT_SOURCE_STAMP): patches/0001-sdpa-attention-fallback.patch
rm -rf sources/LongCat-Video
mkdir -p sources/LongCat-Video
cd sources/LongCat-Video && git init -q && \
git remote add origin $(LONGCAT_VIDEO_REPO) && \
git fetch --depth 1 origin $(LONGCAT_VIDEO_VERSION) && \
git checkout --detach FETCH_HEAD && \
git apply ../../patches/0001-sdpa-attention-fallback.patch && \
rm -rf .git && \
touch .localai-$(LONGCAT_VIDEO_VERSION)
.PHONY: run
run: all
bash run.sh
.PHONY: test
test: all
bash test.sh
.PHONY: protogen-clean
protogen-clean:
$(RM) backend_pb2.py backend_pb2_grpc.py
.PHONY: clean
clean: protogen-clean
rm -rf __pycache__ lib python sources venv

View File

@@ -0,0 +1,44 @@
# LongCat Video backend
This backend serves Meituan's `LongCat-Video` and
`LongCat-Video-Avatar-1.5` checkpoints through LocalAI's `GenerateVideo`
RPC. It supports:
- text-to-video and image-to-video with `LongCat-Video`;
- audio + text-to-avatar and portrait + audio-to-avatar with Avatar 1.5;
- multi-segment avatar continuation for speech longer than one segment;
- PyTorch SDPA when FlashAttention is unavailable, including CUDA 13 ARM64
systems such as NVIDIA DGX Spark.
Install the `longcat-video` or `longcat-video-avatar-1.5` recipe from the
LocalAI Model Gallery. See the [LongCat user guide](../../../docs/content/features/longcat-video.md)
for Studio and API examples, hardware requirements, and manual configuration.
The upstream source is pinned in `Makefile` and patched at build time. The
patch adds only the missing SDPA attention branches; model and source licenses
remain MIT.
## Model options
| Option | Default | Description |
| --- | --- | --- |
| `attention_backend` | `sdpa` | `sdpa`, `auto`, `flash2`, `flash3`, or `xformers`. The packaged backend guarantees only `sdpa`. |
| `use_distill` | `true` for Avatar, `false` for base | Loads the checkpoint's fast distillation LoRA. |
| `use_int8` | `false` | Loads Avatar 1.5's INT8 DiT. BF16 has a lower load-time peak on unified-memory systems. |
| `base_model` | `meituan-longcat/LongCat-Video` | Base components used by Avatar 1.5. |
| `max_segments` | `8` | Maximum avatar continuation segments accepted per request. |
| `resolution` | `480p` | Image-conditioned generation resolution (`480p` or `720p`). |
Per-request `params` may set `num_segments`, `audio_guidance_scale`,
`offload_kv_cache`, `ref_img_index`, `mask_frame_range`, and `resolution`.
Gallery and imported configs declare `known_input_modalities` and
`known_output_modalities`. Keep those declarations in manual configs as well;
they let model discovery distinguish base image-conditioned video from Avatar
audio conditioning without inspecting the backend or checkpoint name.
LongCat is CUDA-only and very large. Avatar 1.5 also loads tokenizer,
text-encoder, and VAE components from the base checkpoint. Keep ample unified
memory and storage available; no CPU or macOS backend image is published. The
initial backend supports one GPU per process; tensor parallel sizes above one
are rejected explicitly.

View File

@@ -0,0 +1,916 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
import argparse
import datetime
import gc
import math
import os
import signal
import subprocess
import sys
import tempfile
import traceback
from concurrent import futures
import grpc
import backend_pb2
import backend_pb2_grpc
from longcat_utils import (
BASE_MODEL_ID,
MODEL_KIND_AVATAR,
MODEL_KIND_BASE,
attention_overrides,
avatar_segments_for_duration,
avatar_segments_for_frames,
classify_model,
normalize_model_source,
normalize_num_frames,
parse_options,
require_bool,
require_float,
require_int,
select_known_options,
validate_dimensions,
)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "common"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "sources", "LongCat-Video"))
from grpc_auth import get_auth_interceptors
MAX_WORKERS = int(os.environ.get("PYTHON_GRPC_MAX_WORKERS", "1"))
DEFAULT_NEGATIVE_PROMPT = (
"Close-up, bright tones, overexposed, static, blurred details, subtitles, "
"paintings, low quality, JPEG compression residue, ugly, incomplete, extra "
"fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, "
"misshapen limbs, fused fingers, still picture, messy background, three legs, "
"many people in the background, walking backwards"
)
LOAD_OPTIONS = {
"attention_backend",
"base_model",
"max_segments",
"resolution",
"use_distill",
"use_int8",
}
REQUEST_PARAMS = {
"audio_guidance_scale",
"mask_frame_range",
"num_segments",
"offload_kv_cache",
"ref_img_index",
"resolution",
}
BASE_CHECKPOINT_PATTERNS = [
"config.json",
"model_index.json",
"dit/**",
"lora/cfg_step_lora.safetensors",
"scheduler/**",
"text_encoder/**",
"tokenizer/**",
"vae/**",
]
AVATAR_BASE_PATTERNS = [
"config.json",
"model_index.json",
"text_encoder/**",
"tokenizer/**",
"vae/**",
]
AVATAR_COMMON_PATTERNS = [
"config.json",
"lora/dmd_lora.safetensors",
"model_index.json",
"scheduler/**",
"whisper-large-v3/config.json",
"whisper-large-v3/model.safetensors",
"whisper-large-v3/preprocessor_config.json",
]
class BackendServicer(backend_pb2_grpc.BackendServicer):
def __init__(self):
self.model_kind = None
self.pipeline = None
self.options = {}
self.device_index = 0
self.cp_split_hw = None
self._dist_store_dir = None
def Health(self, request, context):
return backend_pb2.Reply(message=b"OK")
def LoadModel(self, request, context):
model = request.Model
if request.ModelFile and os.path.isdir(request.ModelFile):
model = request.ModelFile
model_kind = classify_model(model)
if model_kind is None:
return self._fail(
context,
grpc.StatusCode.INVALID_ARGUMENT,
"longcat-video only accepts LongCat-Video or LongCat-Video-Avatar-1.5 checkpoints",
)
try:
options, ignored = select_known_options(
parse_options(request.Options), LOAD_OPTIONS
)
if ignored:
# The server injects llama.cpp serving defaults (cache_reuse,
# parallel) onto every config; ignore what we don't understand
# rather than fail to load.
print(
f"longcat-video ignoring unknown model option(s): {', '.join(ignored)}",
file=sys.stderr,
)
self._import_torch()
if not self.torch.cuda.is_available():
return self._fail(
context,
grpc.StatusCode.FAILED_PRECONDITION,
"longcat-video requires an NVIDIA CUDA GPU",
)
if request.TensorParallelSize > 1:
return self._fail(
context,
grpc.StatusCode.UNIMPLEMENTED,
"longcat-video currently supports one GPU per backend process",
)
self._import_runtime()
attention_name = str(options.get("attention_backend", "sdpa")).lower()
attention_overrides(attention_name)
resolution = str(options.get("resolution", "480p")).lower()
if resolution not in {"480p", "720p"}:
raise ValueError("resolution must be 480p or 720p")
use_distill_default = model_kind == MODEL_KIND_AVATAR
use_distill = require_bool(
options.get("use_distill", use_distill_default),
"use_distill",
)
use_int8 = require_bool(options.get("use_int8", False), "use_int8")
if model_kind == MODEL_KIND_BASE and use_int8:
raise ValueError(
"use_int8 is supported only by LongCat-Video-Avatar-1.5"
)
self.options = {
**options,
"attention_backend": attention_name,
"resolution": resolution,
"use_distill": use_distill,
"use_int8": use_int8,
"max_segments": require_int(
options.get("max_segments", 8),
"max_segments",
minimum=1,
maximum=64,
),
}
self._release_model()
self._ensure_distributed()
if model_kind == MODEL_KIND_BASE:
self._load_base_model(model)
else:
self._load_avatar_model(model)
self.model_kind = model_kind
print(
f"Loaded {normalize_model_source(model)} as {model_kind} "
f"with attention_backend={attention_name}",
file=sys.stderr,
)
return backend_pb2.Result(message="Model loaded successfully", success=True)
except ValueError as err:
self._release_model()
return self._fail(context, grpc.StatusCode.INVALID_ARGUMENT, str(err))
except Exception as err:
self._release_model()
print(f"Error loading LongCat model: {err}", file=sys.stderr)
traceback.print_exc()
return self._fail(
context,
grpc.StatusCode.INTERNAL,
f"failed to load LongCat model: {err}",
)
def Free(self, request, context):
self._release_model()
return backend_pb2.Result(message="Model released", success=True)
def GenerateVideo(self, request, context):
if self.pipeline is None or self.model_kind is None:
return self._fail(
context,
grpc.StatusCode.FAILED_PRECONDITION,
"model is not loaded",
)
if not request.prompt.strip():
return self._fail(
context,
grpc.StatusCode.INVALID_ARGUMENT,
"prompt is required",
)
if not request.dst:
return self._fail(
context,
grpc.StatusCode.INVALID_ARGUMENT,
"output destination is required",
)
if request.end_image:
return self._fail(
context,
grpc.StatusCode.INVALID_ARGUMENT,
"longcat-video does not support end_image conditioning",
)
request_state = {"finished": False}
def interrupt_if_cancelled():
if not request_state["finished"] and self.pipeline is not None:
self.pipeline._interrupt = True
try:
params, ignored_params = select_known_options(
dict(request.params), REQUEST_PARAMS
)
if ignored_params:
print(
f"longcat-video ignoring unknown request param(s): {', '.join(ignored_params)}",
file=sys.stderr,
)
os.makedirs(os.path.dirname(request.dst) or ".", mode=0o750, exist_ok=True)
if hasattr(context, "add_callback"):
context.add_callback(interrupt_if_cancelled)
if request.start_image and not os.path.isfile(request.start_image):
raise ValueError("start_image is not a readable staged file")
if request.num_frames < 0:
raise ValueError("num_frames must not be negative")
if self.model_kind == MODEL_KIND_BASE:
if request.audio:
raise ValueError(
"audio input requires a LongCat-Video-Avatar-1.5 model"
)
self._generate_base(request, params)
else:
self._generate_avatar(request, params, context)
return backend_pb2.Result(
message="Video generated successfully", success=True
)
except ValueError as err:
return self._fail(context, grpc.StatusCode.INVALID_ARGUMENT, str(err))
except Exception as err:
print(f"Error generating LongCat video: {err}", file=sys.stderr)
traceback.print_exc()
return self._fail(
context,
grpc.StatusCode.INTERNAL,
f"LongCat video generation failed: {err}",
)
finally:
request_state["finished"] = True
if self.pipeline is not None:
self.pipeline._interrupt = False
def _import_torch(self):
if hasattr(self, "torch"):
return
import torch
self.torch = torch
def _import_runtime(self):
if hasattr(self, "LongCatVideoPipeline"):
return
import imageio.v2 as imageio
import imageio_ffmpeg
import librosa
import numpy as np
import torch.distributed as dist
from diffusers.utils import load_image
from huggingface_hub import snapshot_download
from PIL import Image
from transformers import AutoTokenizer, UMT5EncoderModel
from longcat_video.audio_process import (
get_audio_encoder,
get_audio_feature_extractor,
)
from longcat_video.context_parallel import context_parallel_util
from longcat_video.modules.autoencoder_kl_wan import AutoencoderKLWan
from longcat_video.modules.avatar.longcat_video_dit_avatar import (
LongCatVideoAvatarTransformer3DModel,
)
from longcat_video.modules.longcat_video_dit import (
LongCatVideoTransformer3DModel,
)
from longcat_video.modules.quantization import load_quantized_dit
from longcat_video.modules.scheduling_flow_match_euler_discrete import (
FlowMatchEulerDiscreteScheduler,
)
from longcat_video.pipeline_longcat_video import LongCatVideoPipeline
from longcat_video.pipeline_longcat_video_avatar import (
LongCatVideoAvatarPipeline,
)
self.imageio = imageio
self.imageio_ffmpeg = imageio_ffmpeg
self.librosa = librosa
self.np = np
self.dist = dist
self.load_image = load_image
self.snapshot_download = snapshot_download
self.Image = Image
self.AutoTokenizer = AutoTokenizer
self.UMT5EncoderModel = UMT5EncoderModel
self.get_audio_encoder = get_audio_encoder
self.get_audio_feature_extractor = get_audio_feature_extractor
self.context_parallel_util = context_parallel_util
self.AutoencoderKLWan = AutoencoderKLWan
self.LongCatVideoAvatarTransformer3DModel = LongCatVideoAvatarTransformer3DModel
self.LongCatVideoTransformer3DModel = LongCatVideoTransformer3DModel
self.load_quantized_dit = load_quantized_dit
self.FlowMatchEulerDiscreteScheduler = FlowMatchEulerDiscreteScheduler
self.LongCatVideoPipeline = LongCatVideoPipeline
self.LongCatVideoAvatarPipeline = LongCatVideoAvatarPipeline
def _ensure_distributed(self):
self.torch.cuda.set_device(self.device_index)
if not self.dist.is_initialized():
self._dist_store_dir = tempfile.mkdtemp(prefix="localai-longcat-dist-")
init_file = os.path.join(self._dist_store_dir, "store")
self.dist.init_process_group(
backend="nccl",
init_method=f"file://{init_file}",
rank=0,
world_size=1,
timeout=datetime.timedelta(hours=24),
)
self.context_parallel_util.init_context_parallel(
context_parallel_size=1,
global_rank=0,
world_size=1,
)
self.cp_split_hw = self.context_parallel_util.get_optimal_split(1)
def _resolve_checkpoint(self, model, patterns):
source = normalize_model_source(model)
if os.path.isdir(source):
return source
print(f"Downloading required files for {source}", file=sys.stderr)
return self.snapshot_download(repo_id=source, allow_patterns=patterns)
def _load_base_model(self, model):
checkpoint = self._resolve_checkpoint(model, BASE_CHECKPOINT_PATTERNS)
dtype = self.torch.bfloat16
overrides = attention_overrides(self.options["attention_backend"])
tokenizer = self.AutoTokenizer.from_pretrained(
checkpoint,
subfolder="tokenizer",
)
text_encoder = self.UMT5EncoderModel.from_pretrained(
checkpoint,
subfolder="text_encoder",
torch_dtype=dtype,
low_cpu_mem_usage=True,
)
vae = self.AutoencoderKLWan.from_pretrained(
checkpoint,
subfolder="vae",
torch_dtype=dtype,
low_cpu_mem_usage=True,
)
scheduler = self.FlowMatchEulerDiscreteScheduler.from_pretrained(
checkpoint,
subfolder="scheduler",
)
dit = self.LongCatVideoTransformer3DModel.from_pretrained(
checkpoint,
subfolder="dit",
cp_split_hw=self.cp_split_hw,
torch_dtype=dtype,
low_cpu_mem_usage=True,
**overrides,
)
if self.options["use_distill"]:
dit.load_lora(
os.path.join(checkpoint, "lora", "cfg_step_lora.safetensors"),
"cfg_step_lora",
)
dit.enable_loras(["cfg_step_lora"])
self.pipeline = self.LongCatVideoPipeline(
tokenizer=tokenizer,
text_encoder=text_encoder,
vae=vae,
scheduler=scheduler,
dit=dit,
)
self.pipeline.to(self.device_index)
def _load_avatar_model(self, model):
avatar_patterns = list(AVATAR_COMMON_PATTERNS)
model_subfolder = (
"base_model_int8" if self.options["use_int8"] else "base_model"
)
avatar_patterns.append(f"{model_subfolder}/**")
checkpoint = self._resolve_checkpoint(model, avatar_patterns)
base_model = self.options.get("base_model")
if not base_model and os.path.isdir(normalize_model_source(model)):
sibling = os.path.join(
os.path.dirname(normalize_model_source(model)), "LongCat-Video"
)
if os.path.isdir(sibling):
base_model = sibling
base_model = base_model or BASE_MODEL_ID
if classify_model(str(base_model)) != MODEL_KIND_BASE:
raise ValueError("base_model must point to a LongCat-Video checkpoint")
base_checkpoint = self._resolve_checkpoint(base_model, AVATAR_BASE_PATTERNS)
dtype = self.torch.bfloat16
overrides = attention_overrides(self.options["attention_backend"])
tokenizer = self.AutoTokenizer.from_pretrained(
base_checkpoint,
subfolder="tokenizer",
)
text_encoder = self.UMT5EncoderModel.from_pretrained(
base_checkpoint,
subfolder="text_encoder",
torch_dtype=dtype,
low_cpu_mem_usage=True,
)
vae = self.AutoencoderKLWan.from_pretrained(
base_checkpoint,
subfolder="vae",
torch_dtype=dtype,
low_cpu_mem_usage=True,
)
scheduler = self.FlowMatchEulerDiscreteScheduler.from_pretrained(
checkpoint,
subfolder="scheduler",
)
if self.options["use_int8"]:
previous_dtype = self.torch.get_default_dtype()
self.torch.set_default_dtype(dtype)
try:
dit = self.load_quantized_dit(
checkpoint,
subfolder="base_model_int8",
cp_split_hw=self.cp_split_hw,
**overrides,
)
finally:
self.torch.set_default_dtype(previous_dtype)
else:
dit = self.LongCatVideoAvatarTransformer3DModel.from_pretrained(
checkpoint,
subfolder="base_model",
cp_split_hw=self.cp_split_hw,
torch_dtype=dtype,
low_cpu_mem_usage=True,
**overrides,
)
if self.options["use_distill"]:
dit.load_lora(
os.path.join(checkpoint, "lora", "dmd_lora.safetensors"),
"dmd",
multiplier=1.0,
lora_network_dim=128,
lora_network_alpha=64,
)
dit.enable_loras(["dmd"])
audio_checkpoint = os.path.join(checkpoint, "whisper-large-v3")
audio_encoder = self.get_audio_encoder(
audio_checkpoint,
MODEL_KIND_AVATAR + "-v1.5",
).to(self.device_index)
audio_feature_extractor = self.get_audio_feature_extractor(
audio_checkpoint,
MODEL_KIND_AVATAR + "-v1.5",
)
self.pipeline = self.LongCatVideoAvatarPipeline(
tokenizer=tokenizer,
text_encoder=text_encoder,
vae=vae,
scheduler=scheduler,
dit=dit,
audio_encoder=audio_encoder,
audio_feature_extractor=audio_feature_extractor,
model_type="avatar-v1.5",
)
self.pipeline.to(self.device_index)
def _generate_base(self, request, params):
use_distill = self.options["use_distill"]
frames = normalize_num_frames(request.num_frames)
steps = (
16
if use_distill
else require_int(
request.step or 50,
"step",
minimum=1,
maximum=200,
)
)
guidance_scale = (
1.0
if use_distill
else require_float(
request.cfg_scale or 4.0,
"cfg_scale",
minimum=0.0,
maximum=30.0,
)
)
fps = require_int(request.fps or 15, "fps", minimum=1, maximum=60)
seed = request.seed if request.seed > 0 else 42
negative_prompt = request.negative_prompt or DEFAULT_NEGATIVE_PROMPT
generator = self.torch.Generator(device=self.device_index).manual_seed(seed)
if request.start_image:
resolution = self._resolution(params)
image = self.load_image(request.start_image)
output = self.pipeline.generate_i2v(
image=image,
prompt=request.prompt,
negative_prompt=negative_prompt,
resolution=resolution,
num_frames=frames,
num_inference_steps=steps,
use_distill=use_distill,
guidance_scale=guidance_scale,
generator=generator,
)[0]
else:
width, height = validate_dimensions(request.width, request.height)
output = self.pipeline.generate_t2v(
prompt=request.prompt,
negative_prompt=negative_prompt,
height=height,
width=width,
num_frames=frames,
num_inference_steps=steps,
use_distill=use_distill,
guidance_scale=guidance_scale,
generator=generator,
)[0]
self._save_video(output, request.dst, fps)
def _generate_avatar(self, request, params, context):
if not request.audio:
raise ValueError("audio is required for LongCat-Video-Avatar-1.5")
if not os.path.isfile(request.audio):
raise ValueError("audio input is not a readable staged file")
use_distill = self.options["use_distill"]
steps = (
8
if use_distill
else require_int(
request.step or 50,
"step",
minimum=1,
maximum=200,
)
)
text_guidance = (
1.0
if use_distill
else require_float(
request.cfg_scale or 4.0,
"cfg_scale",
minimum=0.0,
maximum=30.0,
)
)
audio_guidance = (
1.0
if use_distill
else require_float(
params.get("audio_guidance_scale", 4.0),
"audio_guidance_scale",
minimum=0.0,
maximum=20.0,
)
)
seed = request.seed if request.seed > 0 else 42
generator = self.torch.Generator(device=self.device_index).manual_seed(seed)
negative_prompt = request.negative_prompt or DEFAULT_NEGATIVE_PROMPT
resolution = self._resolution(params)
speech, sample_rate = self.librosa.load(request.audio, sr=16000, mono=True)
if speech.size == 0:
raise ValueError("audio contains no samples")
audio_duration = len(speech) / sample_rate
segments = self._avatar_segments(request, params, audio_duration)
segment_frames = 93
conditioning_frames = 13
avatar_fps = 25
generated_duration = (
segment_frames + (segments - 1) * (segment_frames - conditioning_frames)
) / avatar_fps
pad_samples = max(
0, math.ceil((generated_duration - audio_duration) * sample_rate)
)
if pad_samples:
speech = self.np.pad(speech, (0, pad_samples))
full_audio_embedding = self.pipeline.get_audio_embedding(
speech,
fps=avatar_fps,
device=self.device_index,
sample_rate=sample_rate,
model_type="avatar-v1.5",
)
if not self.torch.isfinite(full_audio_embedding).all():
raise ValueError("audio encoder returned non-finite values")
indices = self.torch.arange(5) - 2
def audio_window(start_index):
centers = self.torch.arange(
start_index,
start_index + segment_frames,
).unsqueeze(1) + indices.unsqueeze(0)
centers = self.torch.clamp(
centers,
min=0,
max=full_audio_embedding.shape[0] - 1,
)
return full_audio_embedding[centers][None, ...].to(self.device_index)
audio_start = 0
common = {
"prompt": request.prompt,
"negative_prompt": negative_prompt,
"num_frames": segment_frames,
"num_inference_steps": steps,
"text_guidance_scale": text_guidance,
"audio_guidance_scale": audio_guidance,
"output_type": "both",
"generator": generator,
"audio_emb": audio_window(audio_start),
"use_distill": use_distill,
}
if request.start_image:
output, latent = self.pipeline.generate_ai2v(
image=self.load_image(request.start_image),
resolution=resolution,
**common,
)
else:
width, height = validate_dimensions(request.width, request.height)
output, latent = self.pipeline.generate_at2v(
height=height,
width=width,
**common,
)
video = self._frames_to_pil(output[0])
width, height = video[0].size
current_video = video
reference_latent = latent[:, :, :1].clone()
all_frames = list(video)
for segment in range(1, segments):
if hasattr(context, "is_active") and not context.is_active():
raise RuntimeError("request was cancelled")
print(
f"Generating avatar segment {segment + 1}/{segments}", file=sys.stderr
)
audio_start += segment_frames - conditioning_frames
output, latent = self.pipeline.generate_avc(
video=current_video,
video_latent=latent,
prompt=request.prompt,
negative_prompt=negative_prompt,
height=height,
width=width,
num_frames=segment_frames,
num_cond_frames=conditioning_frames,
num_inference_steps=steps,
text_guidance_scale=text_guidance,
audio_guidance_scale=audio_guidance,
generator=generator,
output_type="both",
use_kv_cache=True,
offload_kv_cache=require_bool(
params.get("offload_kv_cache", False),
"offload_kv_cache",
),
enhance_hf=not use_distill,
audio_emb=audio_window(audio_start),
ref_latent=reference_latent,
ref_img_index=require_int(
params.get("ref_img_index", 10),
"ref_img_index",
minimum=-30,
maximum=30,
),
mask_frame_range=require_int(
params.get("mask_frame_range", 3),
"mask_frame_range",
minimum=0,
maximum=32,
),
use_distill=use_distill,
)
current_video = self._frames_to_pil(output[0])
all_frames.extend(current_video[conditioning_frames:])
self._save_avatar_video(all_frames, request.audio, request.dst, avatar_fps)
def _avatar_segments(self, request, params, audio_duration):
if "num_segments" in params:
segments = require_int(
params["num_segments"],
"num_segments",
minimum=1,
)
elif request.num_frames > 0:
segments = avatar_segments_for_frames(request.num_frames)
else:
segments = avatar_segments_for_duration(audio_duration)
max_segments = self.options["max_segments"]
if segments > max_segments:
raise ValueError(
f"request needs {segments} avatar segments, but max_segments is {max_segments}; "
"trim the audio or raise the model's max_segments option"
)
return segments
def _resolution(self, params):
resolution = str(params.get("resolution", self.options["resolution"])).lower()
if resolution not in {"480p", "720p"}:
raise ValueError("resolution must be 480p or 720p")
return resolution
def _frames_to_pil(self, frames):
images = []
for frame in frames:
array = self.np.asarray(frame)
if self.np.issubdtype(array.dtype, self.np.floating):
array = self.np.clip(array, 0.0, 1.0) * 255
images.append(self.Image.fromarray(array.astype(self.np.uint8)))
return images
def _save_video(self, frames, path, fps):
writer = self.imageio.get_writer(
path,
format="FFMPEG",
mode="I",
fps=fps,
codec="libx264",
macro_block_size=1,
ffmpeg_params=[
"-crf",
"18",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-f",
"mp4",
],
)
try:
for frame in frames:
array = self.np.asarray(frame)
if self.np.issubdtype(array.dtype, self.np.floating):
array = self.np.clip(array, 0.0, 1.0) * 255
writer.append_data(array.astype(self.np.uint8))
finally:
writer.close()
def _save_avatar_video(self, frames, audio_path, dst, fps):
output_dir = os.path.dirname(dst) or "."
handle, silent_path = tempfile.mkstemp(
prefix="longcat-silent-",
suffix=".mp4",
dir=output_dir,
)
os.close(handle)
try:
self._save_video(frames, silent_path, fps)
command = [
self.imageio_ffmpeg.get_ffmpeg_exe(),
"-y",
"-i",
silent_path,
"-i",
audio_path,
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-movflags",
"+faststart",
"-f",
"mp4",
dst,
]
subprocess.run(
command,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
except subprocess.CalledProcessError as err:
details = (err.stderr or "ffmpeg failed")[-2000:]
raise RuntimeError(f"failed to mux avatar audio: {details}") from err
finally:
try:
os.remove(silent_path)
except FileNotFoundError:
pass
def _release_model(self):
self.pipeline = None
self.model_kind = None
gc.collect()
if hasattr(self, "torch") and self.torch.cuda.is_available():
self.torch.cuda.empty_cache()
self.torch.cuda.ipc_collect()
@staticmethod
def _fail(context, code, message):
if context is not None:
context.set_code(code)
context.set_details(message)
return backend_pb2.Result(message=message, success=False)
def serve(address):
server = grpc.server(
futures.ThreadPoolExecutor(max_workers=MAX_WORKERS),
options=[
("grpc.max_message_length", 64 * 1024 * 1024),
("grpc.max_send_message_length", 64 * 1024 * 1024),
("grpc.max_receive_message_length", 64 * 1024 * 1024),
],
interceptors=get_auth_interceptors(),
)
backend_pb2_grpc.add_BackendServicer_to_server(BackendServicer(), server)
server.add_insecure_port(address)
server.start()
print(f"LongCat Video backend listening on {address}", file=sys.stderr)
def stop_server(signum, frame):
del signum, frame
server.stop(0)
signal.signal(signal.SIGINT, stop_server)
signal.signal(signal.SIGTERM, stop_server)
server.wait_for_termination()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run the LongCat Video gRPC backend")
parser.add_argument(
"--addr",
default="localhost:50051",
help="address on which to serve the backend",
)
arguments = parser.parse_args()
serve(arguments.addr)

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -euo pipefail
PYTHON_VERSION="3.12"
PYTHON_PATCH="12"
PY_STANDALONE_TAG="20251120"
backend_dir=$(dirname "$0")
if [ -d "${backend_dir}/common" ]; then
source "${backend_dir}/common/libbackend.sh"
else
source "${backend_dir}/../common/libbackend.sh"
fi
installRequirements

View File

@@ -0,0 +1,199 @@
# SPDX-License-Identifier: MIT
import json
import math
import os
from urllib.parse import urlparse
BASE_MODEL_ID = "meituan-longcat/LongCat-Video"
AVATAR_MODEL_ID = "meituan-longcat/LongCat-Video-Avatar-1.5"
MODEL_KIND_BASE = "base"
MODEL_KIND_AVATAR = "avatar"
ATTENTION_OVERRIDES = {
"auto": {},
"sdpa": {
"enable_flashattn2": False,
"enable_flashattn3": False,
"enable_xformers": False,
},
"flash2": {
"enable_flashattn2": True,
"enable_flashattn3": False,
"enable_xformers": False,
},
"flash3": {
"enable_flashattn2": False,
"enable_flashattn3": True,
"enable_xformers": False,
},
"xformers": {
"enable_flashattn2": False,
"enable_flashattn3": False,
"enable_xformers": True,
},
}
def parse_options(values):
options = {}
for raw in values:
if ":" not in raw:
options[raw.strip()] = True
continue
key, value = raw.split(":", 1)
key = key.strip()
value = value.strip()
if not key:
continue
lower = value.lower()
if lower in {"true", "false"}:
options[key] = lower == "true"
continue
try:
options[key] = int(value)
continue
except ValueError:
pass
try:
options[key] = float(value)
continue
except ValueError:
pass
options[key] = value
return options
def select_known_options(options, known):
"""Split parsed options into the subset this backend understands and the
unknown keys to ignore.
LocalAI injects serving defaults (e.g. the llama.cpp cache_reuse / parallel
options) onto every model config regardless of backend. A backend should
tolerate options it does not understand rather than refuse to load, matching
the other LocalAI Python backends; the caller logs the ignored keys.
Returns (kept, ignored) where kept preserves the known entries and ignored is
the sorted list of dropped keys.
"""
ignored = sorted(key for key in options if key not in known)
kept = {key: value for key, value in options.items() if key in known}
return kept, ignored
def require_bool(value, name):
if isinstance(value, bool):
return value
if isinstance(value, str) and value.lower() in {"true", "false"}:
return value.lower() == "true"
raise ValueError(f"{name} must be true or false")
def require_int(value, name, minimum=None, maximum=None):
try:
parsed = int(value)
except (TypeError, ValueError) as err:
raise ValueError(f"{name} must be an integer") from err
if minimum is not None and parsed < minimum:
raise ValueError(f"{name} must be at least {minimum}")
if maximum is not None and parsed > maximum:
raise ValueError(f"{name} must be at most {maximum}")
return parsed
def require_float(value, name, minimum=None, maximum=None):
try:
parsed = float(value)
except (TypeError, ValueError) as err:
raise ValueError(f"{name} must be a number") from err
if minimum is not None and parsed < minimum:
raise ValueError(f"{name} must be at least {minimum}")
if maximum is not None and parsed > maximum:
raise ValueError(f"{name} must be at most {maximum}")
return parsed
def attention_overrides(name):
try:
return dict(ATTENTION_OVERRIDES[name])
except KeyError as err:
choices = ", ".join(ATTENTION_OVERRIDES)
raise ValueError(f"attention_backend must be one of: {choices}") from err
def _model_name_from_directory(path):
for filename in ("model_index.json", "config.json"):
config_path = os.path.join(path, filename)
try:
with open(config_path, "r", encoding="utf-8") as config_file:
model_name = json.load(config_file).get("model_name", "")
except (FileNotFoundError, OSError, ValueError, TypeError):
continue
if model_name:
return model_name
return ""
def normalize_model_source(model):
value = model.rstrip("/")
for prefix in ("huggingface://", "hf://"):
if value.startswith(prefix):
return value[len(prefix) :]
parsed = urlparse(value)
if parsed.scheme in {"http", "https"} and parsed.netloc.lower() == "huggingface.co":
parts = [part for part in parsed.path.split("/") if part]
if len(parts) >= 2:
return "/".join(parts[:2])
return value
def classify_model(model):
if not model:
return None
normalized = normalize_model_source(model)
if os.path.isdir(normalized):
name = _model_name_from_directory(normalized).lower()
if name == "longcat-video":
return MODEL_KIND_BASE
if name == "longcat-video-avatar-1.5":
return MODEL_KIND_AVATAR
return None
normalized = normalized.lower()
if normalized == BASE_MODEL_ID.lower():
return MODEL_KIND_BASE
if normalized == AVATAR_MODEL_ID.lower():
return MODEL_KIND_AVATAR
return None
def normalize_num_frames(value, default=93):
frames = default if not value or value < 1 else value
return max(1, ((frames - 1) // 4) * 4 + 1)
def avatar_segments_for_frames(frames):
if not frames or frames <= 93:
return 1
return 1 + math.ceil((frames - 93) / 80)
def avatar_segments_for_duration(duration_seconds, fps=25):
if duration_seconds <= 0:
return 1
return avatar_segments_for_frames(math.ceil(duration_seconds * fps))
def validate_dimensions(width, height):
width = width or 832
height = height or 480
if width < 256 or height < 256:
raise ValueError("width and height must each be at least 256")
if width > 1280 or height > 768:
raise ValueError("width and height must not exceed 1280x768")
if width % 16 != 0 or height % 16 != 0:
raise ValueError("width and height must be divisible by 16")
if width * height > 1280 * 768:
raise ValueError("requested video dimensions exceed the 1280x768 pixel limit")
return width, height

View File

@@ -0,0 +1,75 @@
diff --git a/longcat_video/modules/attention.py b/longcat_video/modules/attention.py
index bb5630f..9b9f3cc 100644
--- a/longcat_video/modules/attention.py
+++ b/longcat_video/modules/attention.py
@@ -2,6 +2,7 @@ from typing import List, Optional
import torch
import torch.nn as nn
+import torch.nn.functional as F
from einops import rearrange
@@ -100,7 +101,8 @@ class Attention(nn.Module):
x = xformers.ops.memory_efficient_attention(q, k, v, attn_bias=None, op=None,)
x = rearrange(x, "B M H K -> B H M K")
else:
- raise RuntimeError("Unsupported attention operations.")
+ # Keep a dependency-free path for systems without optional kernels.
+ x = F.scaled_dot_product_attention(q, k, v, scale=self.scale)
return x
@@ -245,8 +247,22 @@ class MultiHeadCrossAttention(nn.Module):
attn_bias = xformers.ops.fmha.attn_bias.BlockDiagonalMask.from_seqlens([N] * B, kv_seqlen)
x = xformers.ops.memory_efficient_attention(q, k, v, attn_bias=attn_bias)
else:
- raise RuntimeError("Unsupported attention operations.")
-
+ # Preserve the variable-length block boundaries without materializing
+ # a dense attention mask.
+ blocks = []
+ offset = 0
+ for batch_index, key_count in enumerate(kv_seqlen):
+ query = q[0][batch_index * N:(batch_index + 1) * N]
+ key = k[0][offset:offset + key_count]
+ value = v[0][offset:offset + key_count]
+ output = F.scaled_dot_product_attention(
+ query.transpose(0, 1),
+ key.transpose(0, 1),
+ value.transpose(0, 1),
+ )
+ blocks.append(output.transpose(0, 1))
+ offset += key_count
+ x = torch.cat(blocks, dim=0)
x = x.view(B, -1, C)
x = self.proj(x)
diff --git a/longcat_video/modules/avatar/attention.py b/longcat_video/modules/avatar/attention.py
index a169a7a..df9a469 100644
--- a/longcat_video/modules/avatar/attention.py
+++ b/longcat_video/modules/avatar/attention.py
@@ -2,6 +2,7 @@ from typing import List, Optional
import torch
import torch.nn as nn
+import torch.nn.functional as F
from einops import rearrange
@@ -111,7 +112,8 @@ class Attention(nn.Module):
x = xformers.ops.memory_efficient_attention(q, k, v, attn_bias=None, op=None,)
x = rearrange(x, "B M H K -> B H M K")
else:
- raise RuntimeError("Unsupported attention operations.")
+ # Keep a dependency-free path for systems without optional kernels.
+ x = F.scaled_dot_product_attention(q, k, v, scale=self.scale)
return x
@@ -429,2 +431,5 @@ class SingleStreamAttention(nn.Module):
+ else:
+ # This branch uses the native PyTorch kernel when optional kernels are off.
+ x = F.scaled_dot_product_attention(q, encoder_k, encoder_v, scale=self.scale)
# linear transform

Some files were not shown because too many files have changed in this diff Show More