Compare commits

...

81 Commits

Author SHA1 Message Date
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
220 changed files with 12066 additions and 745 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

@@ -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

@@ -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

@@ -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.'

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
@@ -565,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
@@ -594,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
@@ -645,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`.
@@ -672,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
@@ -1012,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 \
@@ -1044,6 +1050,18 @@ test-extra-backend-parakeet-cpp-transcription: docker-build-parakeet-cpp
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
## LocalVQE audio transform (joint AEC + noise suppression + dereverb).
## Exercises the audio_transform capability end-to-end: batch transform
## of a real WAV fixture and bidi streaming of synthetic silent frames.
@@ -1212,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
@@ -1237,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
@@ -1305,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)))
@@ -1321,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)))
@@ -1357,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

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

@@ -577,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 {
@@ -1256,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?=bbc7de475178dd0535c16ad85f204a2529806c9d
IK_LLAMA_VERSION?=6198a356a85ed71534c02a9c1026203389f341e5
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=

View File

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

View File

@@ -613,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);
@@ -744,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

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?=09df654e304947f7521e1f52992ceacccf03c300
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

@@ -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

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# omnivoice.cpp version
OMNIVOICE_REPO?=https://github.com/ServeurpersoCom/omnivoice.cpp
OMNIVOICE_VERSION?=daedb763fd442e0916eb130a479fdd74947291c0
OMNIVOICE_VERSION?=bbdf4be03aa5bc3c188b5db778b6f3fd63ceff6c
SO_TARGET?=libgomnivoicecpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

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

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# qwentts.cpp version
QWEN3TTS_REPO?=https://github.com/ServeurpersoCom/qwentts.cpp
QWEN3TTS_CPP_VERSION?=73fe0c67bbf0898ba2999535e0680a02a7f8537d
QWEN3TTS_CPP_VERSION?=d17c33d4ee2f56d15f9ca8a1bb82f7389305f838
SO_TARGET?=libgoqwen3ttscpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

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?=2574f5936571645f784b77623e1f09bad97d948a
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) {

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

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

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# whisper.cpp version
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
WHISPER_CPP_VERSION?=6fc7c33b4c3a2cec83e4b65abd5e96a890480375
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

@@ -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

@@ -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

@@ -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,904 @@
#!/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,
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 = parse_options(request.Options)
unknown = sorted(set(options) - LOAD_OPTIONS)
if unknown:
raise ValueError(f"unknown model option(s): {', '.join(unknown)}")
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 = dict(request.params)
unknown = sorted(set(params) - REQUEST_PARAMS)
if unknown:
raise ValueError(f"unknown request param(s): {', '.join(unknown)}")
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,182 @@
# 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 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

View File

@@ -0,0 +1 @@
accelerate

View File

@@ -0,0 +1,3 @@
--index-url https://download.pytorch.org/whl/cpu
torch==2.12.1
torchvision==0.27.1

View File

@@ -0,0 +1,3 @@
--index-url https://download.pytorch.org/whl/cu126
torch==2.12.1
torchvision==0.27.1

View File

@@ -0,0 +1,3 @@
--index-url https://download.pytorch.org/whl/cu130
torch==2.12.1
torchvision==0.27.1

View File

@@ -0,0 +1,3 @@
--index-url https://download.pytorch.org/whl/cu130
torch==2.12.1
torchvision==0.27.1

View File

@@ -0,0 +1,23 @@
certifi
diffusers==0.35.1
einops==0.8.0
ftfy==6.2.0
grpcio==1.76.0
huggingface-hub>=0.23,<1.0
imageio==2.37.0
imageio-ffmpeg==0.6.0
librosa==0.11.0
loguru==0.7.2
numpy==1.26.4
packaging
pillow
protobuf
pyloudnorm==0.1.1
regex
safetensors
scipy==1.15.3
sentencepiece
soundfile==0.13.1
soxr==0.5.0.post1
tqdm
transformers==4.41.0

View File

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

View File

@@ -0,0 +1,218 @@
# SPDX-License-Identifier: MIT
import importlib.util
import json
import os
import sys
import tempfile
import unittest
BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BACKEND_DIR)
# longcat-video is a backend directory, not an importable Python package name.
from longcat_utils import ( # noqa: E402
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,
validate_dimensions,
)
SOURCE_DIR = os.path.join(BACKEND_DIR, "sources", "LongCat-Video")
try:
import torch
sys.path.insert(0, SOURCE_DIR)
ATTENTION_TESTS_AVAILABLE = (
os.path.isdir(SOURCE_DIR) and importlib.util.find_spec("triton") is not None
)
except ImportError:
torch = None
ATTENTION_TESTS_AVAILABLE = False
AVATAR_ATTENTION_TESTS_AVAILABLE = ATTENTION_TESTS_AVAILABLE and all(
importlib.util.find_spec(module) is not None
for module in ("pyloudnorm", "scipy", "torchvision")
)
class LongCatUtilsTest(unittest.TestCase):
def test_parse_options_preserves_colons_and_coerces_scalars(self):
options = parse_options(
[
"use_distill:true",
"max_segments:4",
"audio_guidance_scale:3.5",
"source:https://example.com/model",
"flag",
]
)
self.assertEqual(options["use_distill"], True)
self.assertEqual(options["max_segments"], 4)
self.assertEqual(options["audio_guidance_scale"], 3.5)
self.assertEqual(options["source"], "https://example.com/model")
self.assertEqual(options["flag"], True)
def test_classify_model_accepts_only_supported_longcat_models(self):
cases = {
"meituan-longcat/LongCat-Video": MODEL_KIND_BASE,
"https://huggingface.co/meituan-longcat/LongCat-Video": MODEL_KIND_BASE,
"hf://meituan-longcat/LongCat-Video-Avatar-1.5": MODEL_KIND_AVATAR,
"other-org/LongCat-Video": None,
"meituan-longcat/LongCat-Video-Avatar": None,
"some-org/unrelated-model": None,
}
for model, expected in cases.items():
with self.subTest(model=model):
self.assertEqual(classify_model(model), expected)
def test_classify_model_reads_local_checkpoint_metadata(self):
with tempfile.TemporaryDirectory() as directory:
with open(
os.path.join(directory, "model_index.json"),
"w",
encoding="utf-8",
) as config_file:
json.dump({"model_name": "LongCat-Video-Avatar-1.5"}, config_file)
self.assertEqual(classify_model(directory), MODEL_KIND_AVATAR)
def test_normalize_model_source_handles_huggingface_uri_forms(self):
self.assertEqual(
normalize_model_source(
"https://huggingface.co/meituan-longcat/LongCat-Video/tree/main"
),
"meituan-longcat/LongCat-Video",
)
self.assertEqual(
normalize_model_source("huggingface://meituan-longcat/LongCat-Video"),
"meituan-longcat/LongCat-Video",
)
def test_frame_and_segment_rounding_matches_longcat_temporal_shape(self):
self.assertEqual(normalize_num_frames(94), 93)
self.assertEqual(normalize_num_frames(0), 93)
self.assertEqual(avatar_segments_for_frames(93), 1)
self.assertEqual(avatar_segments_for_frames(94), 2)
self.assertEqual(avatar_segments_for_frames(173), 2)
self.assertEqual(avatar_segments_for_frames(174), 3)
self.assertEqual(avatar_segments_for_duration(10.0), 3)
def test_dimensions_are_bounded_and_aligned(self):
self.assertEqual(validate_dimensions(0, 0), (832, 480))
self.assertEqual(validate_dimensions(512, 512), (512, 512))
with self.assertRaisesRegex(ValueError, "divisible by 16"):
validate_dimensions(513, 512)
with self.assertRaisesRegex(ValueError, "must not exceed"):
validate_dimensions(1920, 1080)
def test_attention_backend_validation(self):
self.assertEqual(
attention_overrides("sdpa"),
{
"enable_flashattn2": False,
"enable_flashattn3": False,
"enable_xformers": False,
},
)
with self.assertRaisesRegex(ValueError, "attention_backend"):
attention_overrides("unknown")
@unittest.skipUnless(
ATTENTION_TESTS_AVAILABLE,
"patched LongCat source and torch are required for attention tests",
)
class SDPAFallbackTest(unittest.TestCase):
def test_base_self_attention_matches_reference(self):
from longcat_video.modules.attention import Attention
dim, heads, sequence = 64, 4, 32
attention = Attention(
dim,
heads,
enable_flashattn2=False,
enable_flashattn3=False,
enable_xformers=False,
enable_bsa=False,
).float()
query = torch.randn(2, heads, sequence, dim // heads)
key = torch.randn_like(query)
value = torch.randn_like(query)
output = attention._process_attn(query, key, value, shape=(1, 1, sequence))
reference = (
torch.softmax(
(query @ key.transpose(-1, -2)) * attention.scale,
dim=-1,
)
@ value
)
self.assertLess((output - reference).abs().max().item(), 1e-4)
@unittest.skipUnless(
AVATAR_ATTENTION_TESTS_AVAILABLE,
"avatar audio dependencies are required for the avatar attention test",
)
def test_avatar_self_attention_matches_reference(self):
from longcat_video.modules.avatar.attention import Attention
dim, heads, sequence = 64, 4, 16
attention = Attention(
dim,
heads,
enable_flashattn2=False,
enable_flashattn3=False,
enable_xformers=False,
).float()
query = torch.randn(1, heads, sequence, dim // heads)
key = torch.randn_like(query)
value = torch.randn_like(query)
output = attention._process_attn(query, key, value, shape=(1, 1, sequence))
reference = (
torch.softmax(
(query @ key.transpose(-1, -2)) * attention.scale,
dim=-1,
)
@ value
)
self.assertLess((output - reference).abs().max().item(), 1e-4)
def test_base_cross_attention_remains_block_diagonal(self):
from longcat_video.modules.attention import MultiHeadCrossAttention
dim, heads = 64, 4
attention = MultiHeadCrossAttention(
dim,
heads,
enable_flashattn2=False,
enable_flashattn3=False,
enable_xformers=False,
).float()
query = torch.randn(2, 8, dim)
key_lengths = [5, 7]
condition = torch.randn(1, sum(key_lengths), dim)
first = attention._process_cross_attn(query, condition, key_lengths)
changed = condition.clone()
changed[:, key_lengths[0] :] = torch.randn_like(changed[:, key_lengths[0] :])
second = attention._process_cross_attn(query, changed, key_lengths)
self.assertLess((first[0] - second[0]).abs().max().item(), 1e-5)
self.assertGreater((first[1] - second[1]).abs().max().item(), 1e-3)
if __name__ == "__main__":
unittest.main()

View File

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

View File

@@ -119,9 +119,18 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# add options to kwargs
kwargs.update(self.options)
ref_codes = self.model.encode_reference(self.AudioPath)
ref_audio = request.voice if request.voice else self.AudioPath
if not ref_audio:
return backend_pb2.Result(success=False, message="reference audio is required")
ref_text = request.params.get("ref_text") if hasattr(request, "params") else None
if not ref_text:
ref_text = self.ref_text
if not ref_text:
return backend_pb2.Result(success=False, message="ref_text is required")
wav = self.model.infer(request.text, ref_codes, self.ref_text)
ref_codes = self.model.encode_reference(ref_audio)
wav = self.model.infer(request.text, ref_codes, ref_text)
sf.write(request.dst, wav, 24000)
except Exception as err:

View File

@@ -362,7 +362,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# model_type explicitly set
if self.model_type == "CustomVoice":
return "CustomVoice"
if self.model_type == "VoiceClone":
if self.model_type in ("VoiceClone", "Base"):
return "VoiceClone"
if self.model_type == "VoiceDesign":
return "VoiceDesign"
@@ -380,6 +380,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
def _get_ref_audio_path(self, request, voice_name=None):
"""Get reference audio path from stored AudioPath or from voices dict."""
if hasattr(request, "voice") and request.voice and os.path.isfile(request.voice):
return request.voice
# If voice_name is provided and exists in voices dict, use that
if voice_name and voice_name in self.voices:
audio_path = self.voices[voice_name]["audio"]
@@ -735,6 +737,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# model receives the types it expects. These override YAML-derived kwargs.
if hasattr(request, "params") and request.params:
for key, value in request.params.items():
if key == "ref_text":
continue
generation_kwargs[key] = coerce_param_value(value)
# Generate audio based on mode
@@ -743,7 +747,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# Check if multi-voice mode is active (voices dict is populated)
voice_name = None
if self.voices:
request_voice_path = request.voice if request.voice and os.path.isfile(request.voice) else None
if self.voices and request_voice_path is None:
# Get voice from request (priority) or options
voice_name = request.voice if request.voice else None
if not voice_name:
@@ -775,11 +780,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
if voice_name and voice_name in self.voices:
ref_text_source = self.voices[voice_name]["ref_text"]
else:
ref_text_source = self.options.get("ref_text", None)
ref_text_source = request.params.get("ref_text") if hasattr(request, "params") else None
if not ref_text_source:
# Try to get from request if available
if hasattr(request, "ref_text") and request.ref_text:
ref_text_source = request.ref_text
ref_text_source = self.options.get("ref_text", None)
if not ref_text_source:
# x_vector_only_mode doesn't require ref_text

View File

@@ -670,7 +670,10 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
arr = img_tensor.numpy()
image = Image.fromarray(arr)
dst = request.dst or os.path.join(tempfile.gettempdir(), "tinygrad_image.png")
image.save(dst)
# Force PNG rather than letting Pillow guess from the extension: the
# core passes a staging path ending in .tmp, which Pillow can't map
# to a format ("unknown file extension: .tmp").
image.save(dst, format="PNG")
return backend_pb2.Result(success=True, message=dst)
except Exception as exc:
import traceback

View File

@@ -337,9 +337,11 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
if not images or len(images) == 0:
return backend_pb2.Result(success=False, message="Empty images list")
# Save image
# Save image. Force PNG rather than letting Pillow guess from the
# extension: the core passes a staging path ending in .tmp, which
# Pillow can't map to a format ("unknown file extension: .tmp").
output_image = images[0]
output_image.save(request.dst)
output_image.save(request.dst, format="PNG")
return backend_pb2.Result(message="Image generated successfully", success=True)
except Exception as err:
@@ -698,10 +700,17 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
inputs["additional_information"]["non_streaming_mode"] = [True]
elif task_type == "Base":
# Voice cloning requires ref_audio and ref_text
if "ref_audio" in self.options:
inputs["additional_information"]["ref_audio"] = [self.options["ref_audio"]]
if "ref_text" in self.options:
inputs["additional_information"]["ref_text"] = [self.options["ref_text"]]
ref_audio = voice if voice and os.path.isfile(voice) else self.options.get("ref_audio")
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_audio or not ref_text:
return backend_pb2.Result(
success=False,
message="Base TTS voice cloning requires request.voice/ref_audio and params.ref_text",
)
inputs["additional_information"]["ref_audio"] = [ref_audio]
inputs["additional_information"]["ref_text"] = [ref_text]
if "x_vector_only_mode" in self.options:
inputs["additional_information"]["x_vector_only_mode"] = [self.options["x_vector_only_mode"]]

View File

@@ -119,7 +119,7 @@ if [ "$(uname -s)" = "Darwin" ]; then
# can rewrite it. Darwin therefore follows vllm-metal and can lag the Linux
# vllm pin (requirements-cublas13-after.txt, bumped independently against
# vllm/vllm) until vllm-metal supports a newer vLLM.
VLLM_METAL_VERSION="v0.3.0.dev20260704102955"
VLLM_METAL_VERSION="v0.3.0.dev20260713103604"
# The coupled vLLM source version is whatever this vllm-metal release builds
# against -- it declares it in its own installer as `vllm_v=`. Derive it from

View File

@@ -3,8 +3,8 @@
# on a cu130 host. Pull the cu130-flavoured wheel from vLLM's per-tag index
# instead — the cublas13 case in install.sh adds --index-strategy=unsafe-best-match
# so uv consults this index alongside PyPI.
--extra-index-url https://wheels.vllm.ai/0.24.0/cu130
--extra-index-url https://wheels.vllm.ai/0.25.0/cu130
# VERSION COUPLING: darwin/Apple-Silicon builds use vllm-metal (see install.sh),
# which pins this exact vLLM version. Bumping vllm here means coordinating with a
# vllm-metal release that supports the new version, or macOS/Metal builds break.
vllm==0.24.0
vllm==0.25.0

View File

@@ -1,4 +1,12 @@
# vLLM 0.20+ ships an aarch64 manylinux wheel on PyPI whose Requires-Dist pins
# torch==2.11.0 / torchvision==0.26.0 / torchaudio==2.11.0, locking an ABI-
# consistent set with the cu130 torch wheel installed above.
vllm
#
# Pinned to match the cublas13 build (requirements-cublas13-after.txt) and to
# stay deterministic on GB10 / DGX Spark: 0.24.0 carries vllm-project/vllm#45179
# ("release cached device memory under pressure on UMA GPUs during weight
# loading"), without which cold model loads on the Grace Blackwell unified-
# memory architecture crash deterministically with an empty "Engine core init
# failed" set (mudler/LocalAI#10722). Leaving this unpinned let the L4T image
# drift onto whatever wheel was latest at build time.
vllm==0.25.0

View File

@@ -1,7 +1,7 @@
grpcio==1.81.1
grpcio==1.82.1
protobuf
certifi
setuptools
pillow
charset-normalizer>=3.4.7
charset-normalizer>=3.4.9
chardet

View File

@@ -144,7 +144,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
if os.path.exists(potential_path):
prompt_wav_path = potential_path
if hasattr(request, 'AudioPath') and request.AudioPath:
if prompt_wav_path is None and hasattr(request, 'AudioPath') and request.AudioPath:
if os.path.isabs(request.AudioPath):
prompt_wav_path = request.AudioPath
elif hasattr(request, 'ModelFile') and request.ModelFile:
@@ -155,8 +155,10 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
else:
prompt_wav_path = request.AudioPath
# Get prompt_text from options if available
if "prompt_text" in self.options:
# Per-request profile transcript takes precedence over YAML.
if hasattr(request, "params") and request.params.get("ref_text"):
prompt_text = request.params["ref_text"]
elif "prompt_text" in self.options:
prompt_text = self.options["prompt_text"]
# Prepare text
@@ -241,7 +243,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
if os.path.exists(potential_path):
prompt_wav_path = potential_path
if hasattr(request, 'AudioPath') and request.AudioPath:
if prompt_wav_path is None and hasattr(request, 'AudioPath') and request.AudioPath:
if os.path.isabs(request.AudioPath):
prompt_wav_path = request.AudioPath
elif hasattr(request, 'ModelFile') and request.ModelFile:
@@ -252,8 +254,10 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
else:
prompt_wav_path = request.AudioPath
# Get prompt_text from options if available
if "prompt_text" in self.options:
# Per-request profile transcript takes precedence over YAML.
if hasattr(request, "params") and request.params.get("ref_text"):
prompt_text = request.params["ref_text"]
elif "prompt_text" in self.options:
prompt_text = self.options["prompt_text"]
# Prepare text

View File

@@ -22,6 +22,7 @@ import (
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/services/routing/piidetector"
"github.com/mudler/LocalAI/core/services/routing/router"
"github.com/mudler/LocalAI/core/services/voiceprofile"
"github.com/mudler/LocalAI/core/services/voicerecognition"
"github.com/mudler/LocalAI/core/templates"
pkggrpc "github.com/mudler/LocalAI/pkg/grpc"
@@ -58,6 +59,7 @@ type Application struct {
agentPoolService atomic.Pointer[agentpool.AgentPoolService]
faceRegistry facerecognition.Registry
voiceRegistry voicerecognition.Registry
voiceProfileStore *voiceprofile.Store
authDB *gorm.DB
metricsService *monitoring.LocalAIMetricsService
statsRecorder *billing.Recorder
@@ -98,6 +100,11 @@ type Application struct {
func newApplication(appConfig *config.ApplicationConfig) *Application {
ml := model.NewModelLoader(appConfig.SystemState)
// Apply the per-model load-failure cooldown (0 disables). Set here rather
// than in the watchdog block so it takes effect regardless of whether the
// watchdog/LRU limiter is enabled.
ml.SetLoadFailureCooldown(appConfig.ModelLoadFailureCooldown, 0)
// Close MCP sessions when a model is unloaded (watchdog eviction, manual shutdown, etc.)
ml.OnModelUnload(func(modelName string) {
mcpTools.CloseMCPSessions(modelName)
@@ -113,6 +120,7 @@ func newApplication(appConfig *config.ApplicationConfig) *Application {
modelLoader: ml,
applicationConfig: appConfig,
templatesEvaluator: templates.NewEvaluator(appConfig.SystemState.Model.ModelsPath),
voiceProfileStore: voiceprofile.NewStore(appConfig.DataPath),
}
// Face-recognition registry backed by LocalAI's built-in vector store.
@@ -211,6 +219,13 @@ func (a *Application) VoiceRegistry() voicerecognition.Registry {
return a.voiceRegistry
}
// VoiceProfileStore returns the persistent library of reusable voice-cloning
// references. It is distinct from VoiceRegistry, which stores speaker
// recognition embeddings rather than synthesis reference audio.
func (a *Application) VoiceProfileStore() *voiceprofile.Store {
return a.voiceProfileStore
}
// AuthDB returns the auth database connection, or nil if auth is not enabled.
func (a *Application) AuthDB() *gorm.DB {
return a.authDB
@@ -456,6 +471,11 @@ func (a *Application) Shutdown() error {
if a.modelLoader != nil {
err = a.modelLoader.StopAllGRPC()
}
if a.voiceProfileStore != nil {
if closeErr := a.voiceProfileStore.Close(); err == nil {
err = closeErr
}
}
})
return err
}
@@ -520,6 +540,7 @@ func (a *Application) start() error {
// "unavailable" error if startup ran with --disable-stats.
assistantClient.StatsRecorder = a.statsRecorder
assistantClient.FallbackUser = a.fallbackUser
assistantClient.VoiceProfiles = a.voiceProfileStore
// PII filter — same nil-or-real wiring.
assistantClient.PIIRedactor = a.piiRedactor
assistantClient.PIIEvents = a.piiEvents

View File

@@ -261,4 +261,35 @@ var _ = Describe("loadRuntimeSettingsFromFile", func() {
Expect(cfg.AgentPool.AgentHubURL).To(Equal("https://hub.acme.io"))
})
})
// Backend logging capture. Worker/distributed mode force-enables it
// (core/services/worker.SetBackendLoggingEnabled(true)); single mode used
// to leave it off by default with no CLI flag, so the UI "Backend Logs"
// page was silently empty unless the operator found the Settings toggle.
// It now defaults on in single mode too. Because the default is on, the
// loader must let a persisted enable_backend_logging=false (the UI
// toggle-off) win over the default - the sticky "only flip false->true"
// merge used for env-backed flags would otherwise ignore it and revert
// the toggle on every restart.
Describe("backend logging capture", func() {
It("captures backend logs by default in single mode", func() {
cfg := config.NewApplicationConfig()
Expect(cfg.EnableBackendLogging).To(BeTrue(),
"single mode should capture backend logs out of the box, matching worker mode")
})
It("honors a persisted enable_backend_logging=false across restart (toggle-off wins over default-on)", func() {
cfg := config.NewApplicationConfig() // default-on boot state
cfg.DynamicConfigsDir = seedSettings(`{"enable_backend_logging": false}`)
loadRuntimeSettingsFromFile(cfg)
Expect(cfg.EnableBackendLogging).To(BeFalse(),
"a UI toggle-off persisted to runtime_settings.json must survive a restart")
})
It("loads a persisted enable_backend_logging=true", func() {
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"enable_backend_logging": true}`)}
loadRuntimeSettingsFromFile(cfg)
Expect(cfg.EnableBackendLogging).To(BeTrue())
})
})
})

View File

@@ -698,10 +698,12 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
}
}
// Backend logging defaults on in single mode (NewApplicationConfig), so
// the 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 it, so an explicit persisted value is authoritative here.
if settings.EnableBackendLogging != nil {
if !options.EnableBackendLogging {
options.EnableBackendLogging = *settings.EnableBackendLogging
}
options.EnableBackendLogging = *settings.EnableBackendLogging
}
// Tracing settings

View File

@@ -215,9 +215,12 @@ const (
)
// EffectiveContextSize is the context window the backend will run with: the
// configured value, or DefaultContextSize when unset.
// configured value, or DefaultContextSize when unset. A negative value (the
// context_size: -1 auto-max sentinel) that survived config resolution, e.g. on
// a backend that never ran the GGUF resolver, is clamped here so a negative
// n_ctx never reaches a backend.
func EffectiveContextSize(c config.ModelConfig) int {
if c.ContextSize != nil {
if c.ContextSize != nil && *c.ContextSize > 0 {
return *c.ContextSize
}
return DefaultContextSize

View File

@@ -293,3 +293,24 @@ var _ = Describe("gRPCPredictOpts chat_template_kwargs metadata", func() {
Expect(opts.Metadata).ToNot(HaveKey("chat_template_kwargs"))
})
})
var _ = Describe("EffectiveContextSize", func() {
Context("EffectiveContextSize", func() {
It("clamps a negative (auto-max sentinel) context size to the default", func() {
neg := -1
cfg := config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &neg}}
Expect(EffectiveContextSize(cfg)).To(Equal(DefaultContextSize))
})
It("returns an explicit positive context size unchanged", func() {
ctx := 8192
cfg := config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx}}
Expect(EffectiveContextSize(cfg)).To(Equal(8192))
})
It("falls back to the default when context size is unset", func() {
cfg := config.ModelConfig{}
Expect(EffectiveContextSize(cfg)).To(Equal(DefaultContextSize))
})
})
})

View File

@@ -133,10 +133,16 @@ func StoreBackend(sl *model.ModelLoader, appConfig *config.ApplicationConfig, st
// Try to add key with length N when existing length is M
// Use the store namespace as modelID so each namespace gets its own
// process instance and its own in-memory Store{}.
//
// The model name sent over gRPC carries store.NamespacePrefix so the
// backend can tell a genuine store load from the greedy autoload
// probing it with LLM model names; local-store refuses names without
// the prefix (core and backend ship from the same release, so the
// convention upgrades in lockstep).
sc := []model.Option{
model.WithBackendString(backend),
model.WithModelID(storeName),
model.WithModel(storeName),
model.WithModel(store.NamespacePrefix + storeName),
}
return sl.Load(sc...)

View File

@@ -1,21 +1,37 @@
package backend
import (
"maps"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/grpc/proto"
model "github.com/mudler/LocalAI/pkg/model"
)
func VideoGeneration(height, width int32, prompt, negativePrompt, startImage, endImage, dst string, numFrames, fps, seed int32, cfgScale float32, step int32, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func() error, error) {
// VideoGenerationOptions is the backend-neutral request passed to video generators.
// Media fields contain staged local paths by the time they reach this layer.
type VideoGenerationOptions struct {
Height int32
Width int32
Prompt string
NegativePrompt string
StartImage string
EndImage string
Audio string
Destination string
NumFrames int32
FPS int32
Seed int32
CFGScale float32
Step int32
Params map[string]string
}
func VideoGeneration(options VideoGenerationOptions, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func() error, error) {
opts := ModelOptions(modelConfig, appConfig)
inferenceModel, err := loader.Load(
opts...,
)
inferenceModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
@@ -25,19 +41,22 @@ func VideoGeneration(height, width int32, prompt, negativePrompt, startImage, en
_, err := inferenceModel.GenerateVideo(
appConfig.Context,
&proto.GenerateVideoRequest{
Height: height,
Width: width,
Prompt: prompt,
NegativePrompt: negativePrompt,
StartImage: startImage,
EndImage: endImage,
NumFrames: numFrames,
Fps: fps,
Seed: seed,
CfgScale: cfgScale,
Step: step,
Dst: dst,
})
Height: options.Height,
Width: options.Width,
Prompt: options.Prompt,
NegativePrompt: options.NegativePrompt,
StartImage: options.StartImage,
EndImage: options.EndImage,
Audio: options.Audio,
NumFrames: options.NumFrames,
Fps: options.FPS,
Seed: options.Seed,
CfgScale: options.CFGScale,
Step: options.Step,
Dst: options.Destination,
Params: maps.Clone(options.Params),
},
)
return err
}
@@ -45,15 +64,18 @@ func VideoGeneration(height, width int32, prompt, negativePrompt, startImage, en
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
traceData := map[string]any{
"prompt": prompt,
"negative_prompt": negativePrompt,
"height": height,
"width": width,
"num_frames": numFrames,
"fps": fps,
"seed": seed,
"cfg_scale": cfgScale,
"step": step,
"prompt": options.Prompt,
"negative_prompt": options.NegativePrompt,
"height": options.Height,
"width": options.Width,
"num_frames": options.NumFrames,
"fps": options.FPS,
"seed": options.Seed,
"cfg_scale": options.CFGScale,
"step": options.Step,
"has_start_image": options.StartImage != "",
"has_end_image": options.EndImage != "",
"has_audio": options.Audio != "",
}
startTime := time.Now()
@@ -73,7 +95,7 @@ func VideoGeneration(height, width int32, prompt, negativePrompt, startImage, en
Type: trace.BackendTraceVideoGeneration,
ModelName: modelConfig.Name,
Backend: modelConfig.Backend,
Summary: trace.TruncateString(prompt, 200),
Summary: trace.TruncateString(options.Prompt, 200),
Error: errStr,
Data: traceData,
})

View File

@@ -96,6 +96,7 @@ type RunCMD struct {
SizeAwareEviction bool `env:"LOCALAI_SIZE_AWARE_EVICTION,SIZE_AWARE_EVICTION" default:"false" help:"Evict the largest loaded model first rather than the least-recently-used one, keeping small utility models resident and maximizing freed memory per eviction" group:"backends"`
LRUEvictionMaxRetries int `env:"LOCALAI_LRU_EVICTION_MAX_RETRIES,LRU_EVICTION_MAX_RETRIES" default:"30" help:"Maximum number of retries when waiting for busy models to become idle before eviction (default: 30)" group:"backends"`
LRUEvictionRetryInterval string `env:"LOCALAI_LRU_EVICTION_RETRY_INTERVAL,LRU_EVICTION_RETRY_INTERVAL" default:"1s" help:"Interval between retries when waiting for busy models to become idle (e.g., 1s, 2s) (default: 1s)" group:"backends"`
ModelLoadFailureCooldown string `env:"LOCALAI_MODEL_LOAD_FAILURE_COOLDOWN,MODEL_LOAD_FAILURE_COOLDOWN" default:"10s" help:"After a model load fails, refuse new load attempts for that model for this long (returned as HTTP 503 + Retry-After) so a client polling a broken model doesn't respawn a crashing backend every request. Doubles per consecutive failure up to 5m; reset on success. Set to 0 to disable (e.g., 10s, 30s)" group:"backends"`
Federated bool `env:"LOCALAI_FEDERATED,FEDERATED" help:"Enable federated instance" group:"federated"`
DisableGalleryEndpoint bool `env:"LOCALAI_DISABLE_GALLERY_ENDPOINT,DISABLE_GALLERY_ENDPOINT" help:"Disable the gallery endpoints" group:"api"`
DisableMCP bool `env:"LOCALAI_DISABLE_MCP,DISABLE_MCP" help:"Disable MCP (Model Context Protocol) support" group:"api" default:"false"`
@@ -614,6 +615,13 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
}
opts = append(opts, config.WithLRUEvictionRetryInterval(dur))
}
if r.ModelLoadFailureCooldown != "" {
dur, err := time.ParseDuration(r.ModelLoadFailureCooldown)
if err != nil {
return fmt.Errorf("invalid model load failure cooldown: %w", err)
}
opts = append(opts, config.WithModelLoadFailureCooldown(dur))
}
// Handle Open Responses store TTL
if r.OpenResponsesStoreTTL != "" && r.OpenResponsesStoreTTL != "0" {

View File

@@ -131,6 +131,13 @@ type ApplicationConfig struct {
LRUEvictionMaxRetries int // Maximum number of retries when waiting for busy models to become idle (default: 30)
LRUEvictionRetryInterval time.Duration // Interval between retries when waiting for busy models (default: 1s)
// ModelLoadFailureCooldown is the base cooldown applied after a model load
// fails: new load attempts for that model are refused (HTTP 503 + Retry-After)
// until it elapses, doubling per consecutive failure up to a 5m cap and reset
// on success. Prevents a client polling a broken model from respawning a
// crashing backend on every request. 0 disables it. Default: 10s.
ModelLoadFailureCooldown time.Duration
ModelsURL []string
WatchDogBusyTimeout, WatchDogIdleTimeout time.Duration
@@ -239,12 +246,20 @@ type AppOption func(*ApplicationConfig)
func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
opt := &ApplicationConfig{
Context: context.Background(),
UploadLimitMB: 15,
Debug: true,
Context: context.Background(),
UploadLimitMB: 15,
Debug: true,
// Capture backend process stdout/stderr into the per-model
// BackendLogStore by default so the UI "Backend Logs" page works out
// of the box in single mode, matching worker/distributed mode (which
// force-enables it). It's a small in-memory ring buffer; the Settings
// toggle can still turn it off (a persisted false wins - see
// loadRuntimeSettingsFromFile).
EnableBackendLogging: true,
AgentJobRetentionDays: 30, // Default: 30 days
LRUEvictionMaxRetries: 30, // Default: 30 retries
LRUEvictionRetryInterval: 1 * time.Second, // Default: 1 second
ModelLoadFailureCooldown: 10 * time.Second, // Default: 10s base cooldown after a failed load
// WatchDogInterval is intentionally left at the zero value here.
// The startup loader applies a persisted runtime_settings.json value
// only when the interval is still 0 (its "not set by env var"
@@ -531,6 +546,17 @@ func WithLRUEvictionRetryInterval(interval time.Duration) AppOption {
}
}
// WithModelLoadFailureCooldown sets the base cooldown applied after a failed
// model load. 0 disables the cooldown, so unlike most options it accepts any
// non-negative value.
func WithModelLoadFailureCooldown(cooldown time.Duration) AppOption {
return func(o *ApplicationConfig) {
if cooldown >= 0 {
o.ModelLoadFailureCooldown = cooldown
}
}
}
var EnableGalleriesAutoload = func(o *ApplicationConfig) {
o.AutoloadGalleries = true
}

View File

@@ -120,7 +120,7 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
UsecaseVideo: {
Flag: FLAG_VIDEO,
GRPCMethod: MethodGenerateVideo,
Description: "Video generation via the GenerateVideo RPC.",
Description: "Video generation via the GenerateVideo RPC, with optional image or audio conditioning when supported by the backend.",
},
UsecaseTranscript: {
Flag: FLAG_TRANSCRIPT,
@@ -209,10 +209,29 @@ type BackendCapability struct {
AcceptsVideos bool
// AcceptsAudios indicates multimodal audio input in Predict.
AcceptsAudios bool
// VoiceCloning describes the backend's per-request reference-audio
// contract. Model variants that share a backend may narrow this further;
// use VoiceCloningForModel for UI/API decisions.
VoiceCloning *VoiceCloningCapability
// Description is a human-readable summary of the backend.
Description string
}
// VoiceCloningCapability is the model-facing contract for reusable reference
// voices. The first release intentionally accepts only browser-normalizable
// PCM WAV so every advertised backend sees the same input shape.
type VoiceCloningCapability struct {
ReferenceTranscriptRequired bool `json:"reference_transcript_required"`
AcceptedAudioFormats []string `json:"accepted_audio_formats"`
}
func referenceVoiceCloning() *VoiceCloningCapability {
return &VoiceCloningCapability{
ReferenceTranscriptRequired: true,
AcceptedAudioFormats: []string{"audio/wav"},
}
}
// BackendCapabilities maps each backend name (as used in model configs and gallery
// entries) to its verified capabilities. This is the single source of truth for
// what each backend supports.
@@ -261,6 +280,7 @@ var BackendCapabilities = map[string]BackendCapability{
AcceptsImages: true,
AcceptsVideos: true,
AcceptsAudios: true,
VoiceCloning: referenceVoiceCloning(),
Description: "vLLM omni-modal — supports text, image, video generation and TTS",
},
"transformers": {
@@ -303,6 +323,14 @@ var BackendCapabilities = map[string]BackendCapability{
DefaultUsecases: []string{UsecaseImage},
Description: "HuggingFace diffusers — Stable Diffusion, Flux, video generation",
},
"longcat-video": {
GRPCMethods: []GRPCMethod{MethodGenerateVideo},
PossibleUsecases: []string{UsecaseVideo},
DefaultUsecases: []string{UsecaseVideo},
AcceptsImages: true,
AcceptsAudios: true,
Description: "LongCat-Video — text, image, and audio-conditioned avatar video generation on NVIDIA CUDA",
},
"stablediffusion": {
GRPCMethods: []GRPCMethod{MethodGenerateImage},
PossibleUsecases: []string{UsecaseImage},
@@ -375,6 +403,7 @@ var BackendCapabilities = map[string]BackendCapability{
GRPCMethods: []GRPCMethod{MethodAudioTranscription, MethodTTS, MethodTTSStream},
PossibleUsecases: []string{UsecaseTranscript, UsecaseTTS},
DefaultUsecases: []string{UsecaseTranscript, UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "VibeVoice C++ — bidirectional speech, C++ backend with streaming TTS",
},
"sherpa-onnx": {
@@ -401,6 +430,7 @@ var BackendCapabilities = map[string]BackendCapability{
GRPCMethods: []GRPCMethod{MethodTTS},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "Coqui TTS — multi-speaker neural synthesis",
},
"kitten-tts": {
@@ -419,50 +449,72 @@ var BackendCapabilities = map[string]BackendCapability{
GRPCMethods: []GRPCMethod{MethodTTS},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "Pocket TTS — lightweight text-to-speech",
},
"qwen-tts": {
GRPCMethods: []GRPCMethod{MethodTTS},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "Qwen TTS",
},
"qwen3-tts-cpp": {
GRPCMethods: []GRPCMethod{MethodTTS, MethodTTSStream},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "Qwen3 TTS C++ - text-to-speech with streaming, named speakers, voice design and cloning (qwentts.cpp / GGML)",
},
"faster-qwen3-tts": {
GRPCMethods: []GRPCMethod{MethodTTS},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "Faster Qwen3 TTS — accelerated Qwen TTS",
},
"fish-speech": {
GRPCMethods: []GRPCMethod{MethodTTS},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "Fish Speech TTS",
},
"neutts": {
GRPCMethods: []GRPCMethod{MethodTTS},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "NeuTTS — neural text-to-speech",
},
"chatterbox": {
GRPCMethods: []GRPCMethod{MethodTTS},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "Chatterbox TTS",
},
"voxcpm": {
GRPCMethods: []GRPCMethod{MethodTTS, MethodTTSStream},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "VoxCPM TTS with streaming support",
},
"omnivoice-cpp": {
GRPCMethods: []GRPCMethod{MethodTTS, MethodTTSStream},
PossibleUsecases: []string{UsecaseTTS},
DefaultUsecases: []string{UsecaseTTS},
VoiceCloning: referenceVoiceCloning(),
Description: "OmniVoice C++ — multilingual TTS with streaming voice cloning and voice design",
},
"crispasr": {
GRPCMethods: []GRPCMethod{MethodAudioTranscription, MethodTTS, MethodTTSStream, MethodVAD},
PossibleUsecases: []string{UsecaseTranscript, UsecaseTTS, UsecaseVAD},
DefaultUsecases: []string{UsecaseTranscript},
VoiceCloning: referenceVoiceCloning(),
Description: "CrispASR GGUF runtime — speech recognition, VAD, and model-dependent TTS",
},
// --- Sound generation backends ---
"ace-step": {
@@ -605,6 +657,82 @@ func GetBackendCapability(backend string) *BackendCapability {
return nil
}
// VoiceCloningForModel returns the reference-audio contract only when the
// installed model variant can honor it. Several backends serve both Base
// (voice cloning) and CustomVoice/VoiceDesign models, so backend name alone is
// deliberately insufficient. Operators with custom filenames can opt in or
// out explicitly with tts.voice_cloning; the model option spelling remains a
// compatibility fallback for configurations created before the typed field.
func VoiceCloningForModel(cfg *ModelConfig) *VoiceCloningCapability {
if cfg == nil {
return nil
}
backend := NormalizeBackendName(cfg.Backend)
capability := GetBackendCapability(backend)
if capability == nil || capability.VoiceCloning == nil {
return nil
}
if cfg.VoiceCloning != nil {
if !*cfg.VoiceCloning {
return nil
}
return cloneVoiceCloningCapability(capability.VoiceCloning)
}
if enabled, explicit := voiceCloningOverride(cfg.Options); explicit {
if !enabled {
return nil
}
return cloneVoiceCloningCapability(capability.VoiceCloning)
}
identity := strings.ToLower(strings.Join([]string{cfg.Name, cfg.Model, strings.Join(cfg.Options, " ")}, " "))
supported := false
switch backend {
case "qwen3-tts-cpp", "qwen-tts", "vllm-omni":
supported = strings.Contains(identity, "base") || strings.Contains(identity, "voiceclone") || strings.Contains(identity, "voice_clone")
case "vibevoice-cpp":
// Realtime 0.5B consumes a precomputed .gguf voice prompt; the 1.5B
// path consumes raw WAV references per request.
supported = strings.Contains(identity, "1.5b")
case "coqui":
supported = strings.Contains(identity, "xtts") || strings.Contains(identity, "your_tts")
case "crispasr":
supported = strings.Contains(identity, "f5-tts") || strings.Contains(identity, "f5_tts")
default:
supported = true
}
if !supported {
return nil
}
return cloneVoiceCloningCapability(capability.VoiceCloning)
}
func voiceCloningOverride(options []string) (enabled, explicit bool) {
for _, option := range options {
parts := strings.FieldsFunc(option, func(r rune) bool { return r == ':' || r == '=' })
if len(parts) != 2 || !strings.EqualFold(strings.TrimSpace(parts[0]), "voice_cloning") {
continue
}
switch strings.ToLower(strings.TrimSpace(parts[1])) {
case "true", "1", "yes", "on":
return true, true
case "false", "0", "no", "off":
return false, true
}
}
return false, false
}
func cloneVoiceCloningCapability(capability *VoiceCloningCapability) *VoiceCloningCapability {
if capability == nil {
return nil
}
clone := *capability
clone.AcceptedAudioFormats = slices.Clone(capability.AcceptedAudioFormats)
return &clone
}
// PossibleUsecasesForBackend returns all usecases a backend can support.
// Returns nil if the backend is unknown.
func PossibleUsecasesForBackend(backend string) []string {

View File

@@ -72,6 +72,29 @@ var _ = Describe("GetBackendCapability", func() {
})
})
var _ = Describe("VoiceCloningForModel", func() {
voiceCloningSetting := func(enabled bool) *bool { return &enabled }
DescribeTable("advertises only compatible model variants",
func(cfg ModelConfig, expected bool) {
Expect(VoiceCloningForModel(&cfg) != nil).To(Equal(expected))
},
Entry("Qwen C++ Base", ModelConfig{Name: "qwen3-tts-cpp-0.6b-base", Backend: "qwen3-tts-cpp"}, true),
Entry("Qwen C++ CustomVoice", ModelConfig{Name: "qwen3-tts-cpp-customvoice", Backend: "qwen3-tts-cpp"}, false),
Entry("VibeVoice realtime 0.5B", ModelConfig{Name: "vibevoice-cpp-0.5b", Backend: "vibevoice-cpp"}, false),
Entry("VibeVoice 1.5B", ModelConfig{Name: "vibevoice-1.5b", Backend: "vibevoice-cpp"}, true),
Entry("F5 through CrispASR", ModelConfig{Name: "f5-tts-crispasr", Backend: "crispasr"}, true),
Entry("ASR through CrispASR", ModelConfig{Name: "parakeet-crispasr", Backend: "crispasr"}, false),
Entry("VoxCPM", ModelConfig{Name: "voxcpm-1.5", Backend: "voxcpm"}, true),
Entry("unsupported Piper", ModelConfig{Name: "piper", Backend: "piper"}, false),
Entry("typed custom opt-in", ModelConfig{Name: "private-build", Backend: "qwen3-tts-cpp", TTSConfig: TTSConfig{VoiceCloning: voiceCloningSetting(true)}}, true),
Entry("typed opt-out", ModelConfig{Name: "voxcpm-1.5", Backend: "voxcpm", TTSConfig: TTSConfig{VoiceCloning: voiceCloningSetting(false)}}, false),
Entry("typed setting wins over compatibility option", ModelConfig{Name: "private-build", Backend: "qwen3-tts-cpp", TTSConfig: TTSConfig{VoiceCloning: voiceCloningSetting(true)}, Options: []string{"voice_cloning:false"}}, true),
Entry("legacy option custom opt-in", ModelConfig{Name: "private-build", Backend: "qwen3-tts-cpp", Options: []string{"voice_cloning:true"}}, true),
Entry("legacy option opt-out", ModelConfig{Name: "voxcpm-1.5", Backend: "voxcpm", Options: []string{"voice_cloning=false"}}, false),
)
})
var _ = Describe("IsValidUsecaseForBackend", func() {
It("accepts a backend's declared usecases", func() {
Expect(IsValidUsecaseForBackend("piper", "tts")).To(BeTrue())

View File

@@ -27,6 +27,24 @@ func reservedNonChatModel(cfg *ModelConfig) bool {
}
func guessGGUFFromFile(cfg *ModelConfig, f *gguf.GGUFFile, defaultCtx int) {
// Explicit opt-in: a negative context_size (canonically -1) means "use the
// model's full trained context (n_ctx_train) from GGUF metadata". Unlike the
// silent unset path below, this overrides an already-present value and warns
// when the resolved window will not fit detected VRAM.
if cfg.ContextSize != nil && *cfg.ContextSize < 0 {
if maxCtx := int(f.Architecture().MaximumContextLength); maxCtx > 0 {
cfg.ContextSize = &maxCtx
warnIfContextExceedsVRAM(f, maxCtx, f.Metadata().Name)
} else {
// No usable trained max in metadata: degrade to the safe default
// rather than leak a negative n_ctx downstream.
d := DefaultContextSize
cfg.ContextSize = &d
xlog.Warn("[gguf] context_size=-1 requested but GGUF exposes no trained max; using default",
"default", d, "model", f.Metadata().Name)
}
}
if defaultCtx == 0 && cfg.ContextSize == nil {
// trainedMax is the model's full trained context window (n_ctx_train).
// Defaulting a model to it unbounded is what OOMs long-context models at
@@ -225,3 +243,35 @@ func applyDetectedThinkingConfig(cfg *ModelConfig, metadata *pb.ModelMetadataRes
xlog.Debug("[gguf] DetectThinkingSupportFromBackend: preserving explicit reasoning config", "supports_thinking", metadata.SupportsThinking, "disable_reasoning", *cfg.ReasoningConfig.DisableReasoning, "disable_reasoning_tag_prefill", *cfg.ReasoningConfig.DisableReasoningTagPrefill)
}
// warnIfContextExceedsVRAM logs a best-effort warning when running the model at
// the given context would not fit detected VRAM. It never blocks load: any
// detection or estimation gap (no GPU, unknown VRAM, estimate failure) silently
// skips the warning. Used by the context_size=-1 auto-max path, where the raw
// trained max can be far larger than a consumer card holds.
func warnIfContextExceedsVRAM(f *gguf.GGUFFile, ctx int, name string) {
defer func() { _ = recover() }() // the run estimate can panic on unusual headers
if !xsysinfo.HasGPU("nvidia") && !xsysinfo.HasGPU("amd") {
return // no VRAM to compare against
}
vram, err := xsysinfo.TotalAvailableVRAM()
if err != nil || vram == 0 {
return
}
sum := f.EstimateLLaMACppRun(gguf.WithLLaMACppContextSize(int32(ctx))).Summarize(true, 0, 0)
if len(sum.Items) == 0 {
return
}
var used uint64
for _, v := range sum.Items[0].VRAMs {
used += uint64(v.NonUMA)
}
if used == 0 || used <= vram {
return
}
xlog.Warn("[gguf] context_size=-1 resolved to the model's trained max; estimated VRAM may exceed available - expect OOM, or set an explicit context_size",
"model", name, "context", ctx,
"estimated_vram_gib", used>>30, "available_vram_gib", vram>>30)
}

View File

@@ -31,9 +31,11 @@ func llamaCppDefaults(cfg *ModelConfig, modelPath string) {
}
}()
// Default context size if not set, regardless of whether GGUF parsing succeeds
// Default context size if not set, or if a context_size=-1 auto-max was
// requested but the GGUF could not be parsed, so guessGGUFFromFile never ran
// to resolve it. A negative value must never reach the backend.
defer func() {
if cfg.ContextSize == nil {
if cfg.ContextSize == nil || *cfg.ContextSize < 0 {
ctx := DefaultContextSize
cfg.ContextSize = &ctx
}

View File

@@ -26,7 +26,7 @@ const (
// array (tokenizer.ggml.tokens). The big array is exactly what SkipLargeMetadata
// + UseMMap are expected to avoid reading element-by-element, so it must survive a
// round-trip through the real hook without corrupting the guessed defaults.
func writeTestGGUF(path, chatTemplate string, vocab int) error {
func writeTestGGUF(path, chatTemplate string, vocab int, ctxTrain uint32) error {
wStr := func(b *bytes.Buffer, s string) {
binary.Write(b, binary.LittleEndian, uint64(len(s)))
b.WriteString(s)
@@ -45,7 +45,7 @@ func writeTestGGUF(path, chatTemplate string, vocab int) error {
var meta bytes.Buffer
kvStr(&meta, "general.architecture", "llama")
kvStr(&meta, "general.name", "ReproModel")
kvU32(&meta, "llama.context_length", 4096)
kvU32(&meta, "llama.context_length", ctxTrain)
kvU32(&meta, "llama.attention.head_count", 32)
kvU32(&meta, "llama.feed_forward_length", 11008)
kvU32(&meta, "llama.block_count", 32)
@@ -211,7 +211,7 @@ var _ = Describe("Backend hooks and parser defaults", func() {
It("guesses defaults from a GGUF whose large vocab is skipped", func() {
dir := GinkgoT().TempDir()
modelFile := "repro.gguf"
Expect(writeTestGGUF(filepath.Join(dir, modelFile), chatTemplate, 50000)).To(Succeed())
Expect(writeTestGGUF(filepath.Join(dir, modelFile), chatTemplate, 50000, 4096)).To(Succeed())
// A pre-set context size short-circuits the GGUF run-estimate, which
// needs full tensor info this header-only fixture deliberately omits;
@@ -236,6 +236,26 @@ var _ = Describe("Backend hooks and parser defaults", func() {
Expect(cfg.KnownUsecaseStrings).To(ContainElement("FLAG_CHAT"))
})
It("resolves context_size=-1 to the model's trained maximum context", func() {
dir := GinkgoT().TempDir()
modelFile := "automax.gguf"
// A distinctive trained max proves we read metadata, not the 4096 default.
Expect(writeTestGGUF(filepath.Join(dir, modelFile), chatTemplate, 100, 131072)).To(Succeed())
neg := -1
cfg := &ModelConfig{
Backend: "llama-cpp",
LLMConfig: LLMConfig{ContextSize: &neg},
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: modelFile},
},
}
cfg.SetDefaults(ModelPath(dir))
Expect(cfg.ContextSize).NotTo(BeNil())
Expect(*cfg.ContextSize).To(Equal(131072))
})
It("falls back to the default context size when the GGUF is unreadable", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(dir, "bad.gguf"), []byte("not a gguf"), 0o644)).To(Succeed())
@@ -254,6 +274,24 @@ var _ = Describe("Backend hooks and parser defaults", func() {
Expect(cfg.ContextSize).NotTo(BeNil())
Expect(*cfg.ContextSize).To(Equal(DefaultContextSize))
})
It("falls back to the default when context_size=-1 but the GGUF is unreadable", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(dir, "bad.gguf"), []byte("not a gguf"), 0o644)).To(Succeed())
neg := -1
cfg := &ModelConfig{
Backend: "llama-cpp",
LLMConfig: LLMConfig{ContextSize: &neg},
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: "bad.gguf"},
},
}
cfg.SetDefaults(ModelPath(dir))
Expect(cfg.ContextSize).NotTo(BeNil())
Expect(*cfg.ContextSize).To(Equal(DefaultContextSize))
})
})
Context("PromptCacheAll default", func() {

View File

@@ -34,6 +34,7 @@
"ministral": {"min_p":0.01,"repeat_penalty":1,"temperature":0.15,"top_k":-1,"top_p":0.95},
"devstral": {"min_p":0.01,"repeat_penalty":1,"temperature":0.7,"top_k":-1,"top_p":0.95},
"pixtral": {"min_p":0.1,"repeat_penalty":1,"temperature":1.5,"top_k":-1,"top_p":0.95},
"deepseek-v4": {"min_p":0,"repeat_penalty":1,"temperature":1,"top_k":-1,"top_p":1},
"deepseek-r1": {"min_p":0.01,"repeat_penalty":1,"temperature":0.6,"top_k":-1,"top_p":0.95},
"deepseek-v3": {"min_p":0.01,"repeat_penalty":1,"temperature":0.6,"top_k":-1,"top_p":0.95},
"deepseek-ocr": {"min_p":0.01,"repeat_penalty":1,"temperature":0,"top_k":-1,"top_p":0.95},
@@ -56,5 +57,5 @@
"grok": {"min_p":0.01,"repeat_penalty":1,"temperature":1,"top_k":-1,"top_p":0.95},
"mimo": {"min_p":0.01,"repeat_penalty":1,"temperature":0.7,"top_k":-1,"top_p":0.95}
},
"patterns": ["qwen3.6","qwen3.5","qwen3-coder","qwen3-next","qwen3-vl","qwen3","qwen2.5-coder","qwen2.5-vl","qwen2.5-omni","qwen2.5-math","qwen2.5","qwen2-vl","qwen2","qwq","gemma-4","gemma-3n","gemma-3","medgemma","gemma-2","llama-4","llama-3.3","llama-3.2","llama-3.1","llama-3","phi-4","phi-3","mistral-nemo","mistral-small","mistral-large","magistral","ministral","devstral","pixtral","deepseek-r1","deepseek-v3","deepseek-ocr","glm-5","glm-4","nemotron","minimax-m2.7","minimax-m2.5","minimax","gpt-oss","granite-4","kimi-k2","kimi","lfm2","smollm","olmo","falcon","ernie","seed","grok","mimo"]
"patterns": ["qwen3.6","qwen3.5","qwen3-coder","qwen3-next","qwen3-vl","qwen3","qwen2.5-coder","qwen2.5-vl","qwen2.5-omni","qwen2.5-math","qwen2.5","qwen2-vl","qwen2","qwq","gemma-4","gemma-3n","gemma-3","medgemma","gemma-2","llama-4","llama-3.3","llama-3.2","llama-3.1","llama-3","phi-4","phi-3","mistral-nemo","mistral-small","mistral-large","magistral","ministral","devstral","pixtral","deepseek-v4","deepseek-r1","deepseek-v3","deepseek-ocr","glm-5","glm-4","nemotron","minimax-m2.7","minimax-m2.5","minimax","gpt-oss","granite-4","kimi-k2","kimi","lfm2","smollm","olmo","falcon","ernie","seed","grok","mimo"]
}

View File

@@ -79,6 +79,14 @@ var UsecaseOptions = []FieldOption{
{Value: "video", Label: "Video"},
}
// ModalityOptions enumerates the values accepted by known modality fields.
var ModalityOptions = []FieldOption{
{Value: "text", Label: "Text"},
{Value: "image", Label: "Image"},
{Value: "audio", Label: "Audio"},
{Value: "video", Label: "Video"},
}
var DiffusersSchedulerOptions = []FieldOption{
{Value: "ddim", Label: "DDIM"},
{Value: "ddpm", Label: "DDPM"},

View File

@@ -66,6 +66,22 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Options: UsecaseOptions,
Order: 6,
},
"known_input_modalities": {
Section: "general",
Label: "Known Input Modalities",
Description: "Explicit input types this model accepts when use cases alone are not specific enough",
Component: "string-list",
Options: ModalityOptions,
Order: 7,
},
"known_output_modalities": {
Section: "general",
Label: "Known Output Modalities",
Description: "Explicit output types this model produces when use cases alone are not specific enough",
Component: "string-list",
Options: ModalityOptions,
Order: 8,
},
// --- LLM ---
"context_size": {
@@ -629,6 +645,14 @@ func DefaultRegistry() map[string]FieldMetaOverride {
},
// --- TTS ---
"tts.voice_cloning": {
Section: "tts",
Label: "Voice Cloning",
Description: "Override automatic Voice Library profile support detection for this model. Leave unset to infer support from the backend and model variant.",
Component: "toggle",
Advanced: true,
Order: 89,
},
"tts.voice": {
Section: "tts",
Label: "Voice",
@@ -636,6 +660,13 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Component: "input",
Order: 90,
},
"tts.audio_path": {
Section: "tts",
Label: "Reference Audio Path",
Description: "Default reference audio for voice cloning. A per-request voice or saved Voice Library profile takes precedence.",
Component: "input",
Order: 91,
},
// --- Diffusers ---
"diffusers.pipeline_type": {

View File

@@ -249,7 +249,6 @@ var grandfatheredUnregistered = []string{
"trimspace",
"trimsuffix",
"trust_remote_code",
"tts.audio_path",
"type",
"yarn_attn_factor",
"yarn_beta_fast",

View File

@@ -1,5 +1,7 @@
package config
import "slices"
// This file is the single source of truth for deriving a model's user-facing
// capabilities and input/output modalities from its ModelConfig. Both the
// OpenAI-compatible /v1/models/capabilities endpoint and the Ollama-compatible
@@ -7,14 +9,47 @@ package config
// across clients. Keep the detection heuristics here rather than duplicating
// them per endpoint.
// Canonical model modality values used by config declarations and discovery APIs.
const (
ModalityText = "text"
ModalityImage = "image"
ModalityAudio = "audio"
ModalityVideo = "video"
)
var modalityOrder = []string{ModalityText, ModalityImage, ModalityAudio, ModalityVideo}
func declaredModalities(modalities []string) map[string]bool {
declared := make(map[string]bool, len(modalities))
for _, modality := range modalities {
if slices.Contains(modalityOrder, modality) {
declared[modality] = true
}
}
return declared
}
func orderedModalities(modalities map[string]bool) []string {
result := make([]string, 0, len(modalityOrder))
for _, modality := range modalityOrder {
if modalities[modality] {
result = append(result, modality)
}
}
return result
}
// VisionSupported reports whether the model can accept image inputs.
//
// We deliberately avoid HasUsecases(FLAG_VISION): GuessUsecases has no
// FLAG_VISION branch and reports true for any chat model, so it would paint
// vision onto text-only models. Instead we look for explicit signals: the
// declared KnownUsecases bit, a multimodal projector, or a template/backend
// multimodal marker.
// declared input modality or KnownUsecases bit, a multimodal projector, or a
// template/backend multimodal marker.
func (c *ModelConfig) VisionSupported() bool {
if slices.Contains(c.KnownInputModalities, ModalityImage) {
return true
}
if c.KnownUsecases != nil && (*c.KnownUsecases&FLAG_VISION) == FLAG_VISION {
return true
}
@@ -68,20 +103,21 @@ func (c *ModelConfig) ThinkingSupported() bool {
}
// AudioInputSupported reports whether a chat/generation model accepts audio as
// input (e.g. vLLM omni models). The signal is the vLLM per-prompt audio limit;
// there is no FLAG_* for "chat model that hears audio", which is exactly why a
// plain usecase list can't express it. Transcription models are handled
// separately in InputModalities via FLAG_TRANSCRIPT.
// input. Model configs can declare this directly; vLLM-family configs can also
// signal it through the per-prompt audio limit. Transcription models are
// handled separately in InputModalities via FLAG_TRANSCRIPT.
func (c *ModelConfig) AudioInputSupported() bool {
return c.LimitMMPerPrompt.LimitAudioPerPrompt > 0
return slices.Contains(c.KnownInputModalities, ModalityAudio) ||
c.LimitMMPerPrompt.LimitAudioPerPrompt > 0
}
// VideoInputSupported reports whether a chat/generation model accepts video as
// input. The signal is the vLLM per-prompt video limit. Note this is distinct
// from FLAG_VIDEO, which denotes video *generation* (diffusers) — an output
// modality, not an input one.
// input. Model configs can declare this directly; vLLM-family configs can also
// signal it through the per-prompt video limit. This is distinct from
// FLAG_VIDEO, which denotes video generation — an output modality.
func (c *ModelConfig) VideoInputSupported() bool {
return c.LimitMMPerPrompt.LimitVideoPerPrompt > 0
return slices.Contains(c.KnownInputModalities, ModalityVideo) ||
c.LimitMMPerPrompt.LimitVideoPerPrompt > 0
}
// Capabilities returns the ordered list of capability strings the model
@@ -135,6 +171,7 @@ func (c *ModelConfig) Capabilities() []string {
// attachment router consults to decide whether an image/audio/video file can be
// handed to the active model directly.
func (c *ModelConfig) InputModalities() []string {
modalities := declaredModalities(c.KnownInputModalities)
imageGen := c.HasUsecases(FLAG_IMAGE)
videoGen := c.HasUsecases(FLAG_VIDEO)
chatish := c.HasUsecases(FLAG_CHAT) || c.HasUsecases(FLAG_COMPLETION)
@@ -154,25 +191,17 @@ func (c *ModelConfig) InputModalities() []string {
videoIn := c.VideoInputSupported()
var mods []string
if textIn {
mods = append(mods, "text")
}
if imageIn {
mods = append(mods, "image")
}
if audioIn {
mods = append(mods, "audio")
}
if videoIn {
mods = append(mods, "video")
}
return mods
modalities[ModalityText] = modalities[ModalityText] || textIn
modalities[ModalityImage] = modalities[ModalityImage] || imageIn
modalities[ModalityAudio] = modalities[ModalityAudio] || audioIn
modalities[ModalityVideo] = modalities[ModalityVideo] || videoIn
return orderedModalities(modalities)
}
// OutputModalities returns the set of modalities (text, image, audio, video)
// the model produces, ordered text→image→audio→video.
func (c *ModelConfig) OutputModalities() []string {
modalities := declaredModalities(c.KnownOutputModalities)
textOut := c.HasUsecases(FLAG_CHAT) || c.HasUsecases(FLAG_COMPLETION) || c.HasUsecases(FLAG_EDIT) ||
c.HasUsecases(FLAG_TRANSCRIPT)
imageOut := c.HasUsecases(FLAG_IMAGE)
@@ -180,18 +209,9 @@ func (c *ModelConfig) OutputModalities() []string {
c.HasUsecases(FLAG_AUDIO_TRANSFORM) || c.HasUsecases(FLAG_REALTIME_AUDIO)
videoOut := c.HasUsecases(FLAG_VIDEO)
var mods []string
if textOut {
mods = append(mods, "text")
}
if imageOut {
mods = append(mods, "image")
}
if audioOut {
mods = append(mods, "audio")
}
if videoOut {
mods = append(mods, "video")
}
return mods
modalities[ModalityText] = modalities[ModalityText] || textOut
modalities[ModalityImage] = modalities[ModalityImage] || imageOut
modalities[ModalityAudio] = modalities[ModalityAudio] || audioOut
modalities[ModalityVideo] = modalities[ModalityVideo] || videoOut
return orderedModalities(modalities)
}

View File

@@ -21,6 +21,14 @@ var _ = Describe("Model capabilities derivation", func() {
Expect(cfg.VisionSupported()).To(BeTrue())
})
It("is true when image input is declared explicitly", func() {
cfg := &ModelConfig{
KnownUsecases: usecaseBits(FLAG_CHAT),
KnownInputModalities: []string{ModalityText, ModalityImage},
}
Expect(cfg.VisionSupported()).To(BeTrue())
})
It("is true when an mmproj projector is set", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT), Backend: "llama.cpp"}
cfg.MMProj = "mmproj.gguf" // promoted field from the embedded options struct
@@ -37,6 +45,14 @@ var _ = Describe("Model capabilities derivation", func() {
})
Describe("AudioInputSupported / VideoInputSupported", func() {
It("honors explicit model modality declarations", func() {
cfg := &ModelConfig{
KnownInputModalities: []string{ModalityAudio, ModalityVideo},
}
Expect(cfg.AudioInputSupported()).To(BeTrue())
Expect(cfg.VideoInputSupported()).To(BeTrue())
})
It("detects vLLM omni audio input via limit_mm_per_prompt", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT), Backend: "vllm"}
cfg.LimitMMPerPrompt.LimitAudioPerPrompt = 1
@@ -93,6 +109,17 @@ var _ = Describe("Model capabilities derivation", func() {
Expect(cfg.OutputModalities()).To(Equal([]string{"image"}))
})
It("conditioned video uses declared modalities without backend-specific inference", func() {
cfg := &ModelConfig{
KnownUsecases: usecaseBits(FLAG_VIDEO),
KnownInputModalities: []string{ModalityAudio, ModalityImage, ModalityText, ModalityAudio, "unknown"},
KnownOutputModalities: []string{ModalityVideo},
}
Expect(cfg.Capabilities()).To(Equal([]string{UsecaseVideo}))
Expect(cfg.InputModalities()).To(Equal([]string{ModalityText, ModalityImage, ModalityAudio}))
Expect(cfg.OutputModalities()).To(Equal([]string{ModalityVideo}))
})
It("a TTS model reads text and writes audio", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_TTS), Backend: "piper"}
Expect(cfg.Capabilities()).To(ContainElement(UsecaseTTS))

View File

@@ -28,6 +28,11 @@ type TTSConfig struct {
Voice string `yaml:"voice,omitempty" json:"voice,omitempty"`
AudioPath string `yaml:"audio_path,omitempty" json:"audio_path,omitempty"`
// VoiceCloning overrides saved-profile capability detection for this model.
// A pointer preserves the distinction between an explicit false and the
// default automatic behavior.
VoiceCloning *bool `yaml:"voice_cloning,omitempty" json:"voice_cloning,omitempty"`
}
// @Description ModelConfig represents a model configuration
@@ -52,7 +57,11 @@ type ModelConfig struct {
TemplateConfig TemplateConfig `yaml:"template,omitempty" json:"template,omitempty"`
KnownUsecaseStrings []string `yaml:"known_usecases,omitempty" json:"known_usecases,omitempty"`
KnownUsecases *ModelConfigUsecase `yaml:"-" json:"-"`
Pipeline Pipeline `yaml:"pipeline,omitempty" json:"pipeline,omitempty"`
// KnownInputModalities and KnownOutputModalities describe model-specific I/O
// that usecases alone cannot express, such as image- or audio-conditioned video.
KnownInputModalities []string `yaml:"known_input_modalities,omitempty" json:"known_input_modalities,omitempty"`
KnownOutputModalities []string `yaml:"known_output_modalities,omitempty" json:"known_output_modalities,omitempty"`
Pipeline Pipeline `yaml:"pipeline,omitempty" json:"pipeline,omitempty"`
PromptStrings, InputStrings []string `yaml:"-" json:"-"`
InputToken [][]int `yaml:"-" json:"-"`

View File

@@ -629,6 +629,25 @@ concurrency_groups:
})
})
var _ = Describe("TTS capability configuration", func() {
It("preserves an explicit voice-cloning opt-out from YAML", func() {
var cfg ModelConfig
Expect(yaml.Unmarshal([]byte("name: private-voice\ntts:\n voice_cloning: false\n"), &cfg)).To(Succeed())
Expect(cfg.VoiceCloning).NotTo(BeNil())
Expect(*cfg.VoiceCloning).To(BeFalse())
raw, err := yaml.Marshal(cfg)
Expect(err).NotTo(HaveOccurred())
Expect(string(raw)).To(ContainSubstring("voice_cloning: false"))
})
It("leaves voice-cloning detection automatic when the field is omitted", func() {
var cfg ModelConfig
Expect(yaml.Unmarshal([]byte("name: automatic-voice\ntts:\n audio_path: voices/default.wav\n"), &cfg)).To(Succeed())
Expect(cfg.VoiceCloning).To(BeNil())
})
})
var _ = Describe("PII config accessors", func() {
It("PIIDetectors returns a fresh copy of the consumer's detector list", func() {
cfg := &ModelConfig{PII: PIIConfig{Detectors: []string{"a", "b"}}}

View File

@@ -3,10 +3,12 @@ package gallery
import (
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/xsync"
"github.com/mudler/xlog"
@@ -26,6 +28,101 @@ func (e modelConfigCacheEntry) hasExpired() bool {
// modelConfigCache caches parsed model config maps keyed by URL.
var modelConfigCache = xsync.NewSyncedMap[string, modelConfigCacheEntry]()
// VoiceCloningCapability returns the same variant-aware reference-audio
// contract used for installed models, but derived from a gallery entry before
// it is installed. This keeps gallery recommendations tied to server-owned
// capability metadata instead of a second allow-list in the frontend.
func (m *GalleryModel) VoiceCloningCapability(basePath string) *config.VoiceCloningCapability {
if m == nil {
return nil
}
baseConfig := m.ConfigFile
if len(baseConfig) == 0 && m.URL != "" {
baseConfig = fetchModelConfigMap(m.URL, basePath)
}
modelReference := nestedString(m.Overrides, "parameters", "model")
if modelReference == "" {
modelReference = nestedString(baseConfig, "parameters", "model")
}
options, overridden := stringSliceValue(m.Overrides, "options")
if !overridden {
options, _ = stringSliceValue(baseConfig, "options")
}
voiceCloning, overridden := nestedBool(m.Overrides, "tts", "voice_cloning")
if !overridden {
voiceCloning, _ = nestedBool(baseConfig, "tts", "voice_cloning")
}
modelConfig := &config.ModelConfig{
Name: m.Name,
Backend: m.Backend,
Options: options,
TTSConfig: config.TTSConfig{
VoiceCloning: voiceCloning,
},
}
modelConfig.Model = modelReference
return config.VoiceCloningForModel(modelConfig)
}
func nestedBool(values map[string]any, outer, inner string) (*bool, bool) {
if values == nil {
return nil, false
}
nested, ok := values[outer].(map[string]any)
if !ok {
return nil, false
}
value, exists := nested[inner]
if !exists {
return nil, false
}
enabled, ok := value.(bool)
if !ok {
return nil, true
}
return &enabled, true
}
func nestedString(values map[string]any, outer, inner string) string {
if values == nil {
return ""
}
nested, ok := values[outer].(map[string]any)
if !ok {
return ""
}
value, _ := nested[inner].(string)
return value
}
func stringSliceValue(values map[string]any, key string) ([]string, bool) {
if values == nil {
return nil, false
}
raw, exists := values[key]
if !exists {
return nil, false
}
switch items := raw.(type) {
case []string:
return slices.Clone(items), true
case []any:
result := make([]string, 0, len(items))
for _, item := range items {
if value, ok := item.(string); ok {
result = append(result, value)
}
}
return result, true
default:
return nil, true
}
}
// resolveBackend determines the backend for a GalleryModel by checking (in priority order):
// 1. Overrides["backend"] — highest priority, same as install-time merge
// 2. Inline ConfigFile["backend"] — for models with inline config maps

View File

@@ -298,10 +298,25 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
return fmt.Errorf("failed to remove stale backend artefact at %s: %w", backendPath, rmErr)
}
}
err = os.MkdirAll(backendPath, 0750)
if err != nil {
return fmt.Errorf("failed to create base path: %v", err)
// Stage the download into a temp directory and atomically swap it into
// place once it's complete and validated. This makes a reinstall a clean
// replace rather than an overlay: files from a previous version that are
// absent in the new artifact (a stale .so, an orphaned package dir) are
// dropped instead of lingering to shadow the new build at import time.
// Mirrors the atomic-swap UpgradeBackend already performs.
stagingPath := backendPath + ".install-tmp"
backupPath := backendPath + ".install-backup"
// Clean any stale staging/backup dirs from a prior interrupted attempt
// (best-effort; a missing dir is the common case).
_ = os.RemoveAll(stagingPath)
_ = os.RemoveAll(backupPath)
if err := os.MkdirAll(stagingPath, 0750); err != nil {
return fmt.Errorf("failed to create staging path: %v", err)
}
// Unless we successfully swap the staging dir into place below, never leave
// it behind. After a successful rename it no longer exists, so this is a
// no-op on the happy path.
defer func() { _ = os.RemoveAll(stagingPath) }()
// Build the download options once and reuse for every retry path —
// mirrors and tag fallbacks must verify against the same gallery
@@ -327,21 +342,21 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
uri := downloader.URI(primaryURI)
// Check if it is a directory
if uri.LooksLikeDir() {
// It is a directory, we just copy it over in the backend folder
if err := cp.Copy(string(uri), backendPath); err != nil {
// It is a directory, we just copy it over into the staging folder
if err := cp.Copy(string(uri), stagingPath); err != nil {
return fmt.Errorf("failed copying: %w", err)
}
} else {
xlog.Debug("Downloading backend", "uri", primaryURI, "backendPath", backendPath)
if err := uri.DownloadFileWithContext(ctx, backendPath, config.SHA256, 1, 1, downloadStatus, downloadOpts...); err != nil {
xlog.Debug("Backend download failed, trying fallback", "backendPath", backendPath, "error", err)
xlog.Debug("Downloading backend", "uri", primaryURI, "backendPath", stagingPath)
if err := uri.DownloadFileWithContext(ctx, stagingPath, config.SHA256, 1, 1, downloadStatus, downloadOpts...); err != nil {
xlog.Debug("Backend download failed, trying fallback", "backendPath", stagingPath, "error", err)
// resetBackendPath cleans up partial state from a failed OCI extraction
// resetStagingPath cleans up partial state from a failed OCI extraction
// so the next download attempt starts fresh. The directory is re-created
// because OCI image extractors need it to exist for writing files into.
resetBackendPath := func() {
os.RemoveAll(backendPath)
os.MkdirAll(backendPath, 0750)
resetStagingPath := func() {
_ = os.RemoveAll(stagingPath)
_ = os.MkdirAll(stagingPath, 0750)
}
success := false
@@ -354,10 +369,10 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
return ctx.Err()
default:
}
resetBackendPath()
if err := downloader.URI(mirror).DownloadFileWithContext(ctx, backendPath, config.SHA256, 1, 1, downloadStatus, downloadOpts...); err == nil {
resetStagingPath()
if err := downloader.URI(mirror).DownloadFileWithContext(ctx, stagingPath, config.SHA256, 1, 1, downloadStatus, downloadOpts...); err == nil {
success = true
xlog.Debug("Downloaded backend from mirror", "uri", config.URI, "backendPath", backendPath)
xlog.Debug("Downloaded backend from mirror", "uri", config.URI, "backendPath", stagingPath)
break
}
}
@@ -366,19 +381,19 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
// Try fallback: replace latestTag + "-" with masterTag + "-" in the URI
fallbackURI := strings.Replace(string(config.URI), latestTag+"-", masterTag+"-", 1)
if fallbackURI != string(config.URI) {
resetBackendPath()
resetStagingPath()
xlog.Info("Trying fallback URI", "original", config.URI, "fallback", fallbackURI)
if err := downloader.URI(fallbackURI).DownloadFileWithContext(ctx, backendPath, config.SHA256, 1, 1, downloadStatus, downloadOpts...); err == nil {
xlog.Info("Downloaded backend using fallback URI", "uri", fallbackURI, "backendPath", backendPath)
if err := downloader.URI(fallbackURI).DownloadFileWithContext(ctx, stagingPath, config.SHA256, 1, 1, downloadStatus, downloadOpts...); err == nil {
xlog.Info("Downloaded backend using fallback URI", "uri", fallbackURI, "backendPath", stagingPath)
success = true
} else {
xlog.Info("Fallback URI failed", "fallback", fallbackURI, "error", err)
if !strings.Contains(fallbackURI, "-"+devSuffix) {
resetBackendPath()
resetStagingPath()
devFallbackURI := fallbackURI + "-" + devSuffix
xlog.Info("Trying development fallback URI", "fallback", devFallbackURI)
if err := downloader.URI(devFallbackURI).DownloadFileWithContext(ctx, backendPath, config.SHA256, 1, 1, downloadStatus, downloadOpts...); err == nil {
xlog.Info("Downloaded backend using development fallback URI", "uri", devFallbackURI, "backendPath", backendPath)
if err := downloader.URI(devFallbackURI).DownloadFileWithContext(ctx, stagingPath, config.SHA256, 1, 1, downloadStatus, downloadOpts...); err == nil {
xlog.Info("Downloaded backend using development fallback URI", "uri", devFallbackURI, "backendPath", stagingPath)
success = true
} else {
xlog.Info("Development fallback URI failed", "fallback", devFallbackURI, "error", err)
@@ -389,26 +404,27 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
}
if !success {
// Clean up backend directory only when all download attempts have failed
if cleanupErr := os.RemoveAll(backendPath); cleanupErr != nil {
xlog.Warn("Failed to clean up backend directory", "backendPath", backendPath, "error", cleanupErr)
}
xlog.Error("Failed to download backend", "uri", config.URI, "backendPath", backendPath, "error", err)
// The deferred RemoveAll(stagingPath) cleans up the partial
// download; the previously installed backend (if any) is left
// untouched because we never touched backendPath.
xlog.Error("Failed to download backend", "uri", config.URI, "backendPath", stagingPath, "error", err)
return fmt.Errorf("failed to download backend %q: %v", config.URI, err)
}
} else {
xlog.Debug("Downloaded backend", "uri", config.URI, "backendPath", backendPath)
xlog.Debug("Downloaded backend", "uri", config.URI, "backendPath", stagingPath)
}
}
// sanity check - check if runfile is present
runFile := filepath.Join(backendPath, runFile)
if _, err := os.Stat(runFile); os.IsNotExist(err) {
xlog.Error("Run file not found", "runFile", runFile)
return fmt.Errorf("not a valid backend: run file not found %q", runFile)
// sanity check - check if runfile is present in the staged content. This
// doubles as the validation gate before we swap it into place.
stagedRunFile := filepath.Join(stagingPath, runFile)
if _, err := os.Stat(stagedRunFile); os.IsNotExist(err) {
xlog.Error("Run file not found", "runFile", stagedRunFile)
return fmt.Errorf("not a valid backend: run file not found %q", stagedRunFile)
}
// Create metadata for the backend
// Create metadata for the backend (written into the staged dir so the
// atomic swap brings a complete, self-describing backend into place).
metadata := &BackendMetadata{
Name: name,
GalleryURL: config.Gallery.URL,
@@ -431,10 +447,26 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
metadata.Alias = config.Alias
}
if err := writeBackendMetadata(backendPath, metadata); err != nil {
if err := writeBackendMetadata(stagingPath, metadata); err != nil {
return fmt.Errorf("failed to write metadata for backend %q: %v", name, err)
}
// Atomic swap: move any existing install aside, move the staged dir into
// place, and roll the old one back if the final rename fails.
if _, statErr := os.Stat(backendPath); statErr == nil {
if err := os.Rename(backendPath, backupPath); err != nil {
return fmt.Errorf("failed to move current backend to backup: %w", err)
}
}
if err := os.Rename(stagingPath, backendPath); err != nil {
xlog.Error("Failed to move new backend into place, restoring backup", "error", err)
if restoreErr := os.Rename(backupPath, backendPath); restoreErr != nil {
xlog.Error("Failed to restore backup", "error", restoreErr)
}
return fmt.Errorf("failed to move new backend into place: %w", err)
}
_ = os.RemoveAll(backupPath)
return RegisterBackends(systemState, modelLoader)
}

View File

@@ -134,6 +134,44 @@ var _ = Describe("Gallery Backends", func() {
Expect(err).ToNot(HaveOccurred())
Expect(filepath.Join(tempDir, "test-backend", "run.sh")).To(BeARegularFile())
})
It("removes files from a previous install that are absent in the new artifact", func() {
// A reinstall must fully replace the installed backend, not overlay
// the new artifact onto the old one: a stale library or package
// directory left behind from an earlier version can shadow the new
// one at import time (e.g. an old vllm .so lingering next to a fresh
// build). Mirror the atomic-swap behaviour UpgradeBackend already has.
makeSource := func(files map[string]string) string {
dir, err := os.MkdirTemp("", "backend-src-*")
Expect(err).NotTo(HaveOccurred())
for name, content := range files {
Expect(os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)).To(Succeed())
}
return dir
}
srcV1 := makeSource(map[string]string{
"run.sh": "#!/bin/sh\necho v1\n",
"stale-lib.so": "old",
})
defer func() { _ = os.RemoveAll(srcV1) }()
cfgV1 := &GalleryBackend{Metadata: Metadata{Name: "orphan-test"}, URI: srcV1}
Expect(InstallBackend(context.TODO(), systemState, ml, cfgV1, nil, false)).To(Succeed())
installed := filepath.Join(tempDir, "orphan-test")
Expect(filepath.Join(installed, "stale-lib.so")).To(BeARegularFile())
// Reinstall from an artifact that no longer ships stale-lib.so.
srcV2 := makeSource(map[string]string{
"run.sh": "#!/bin/sh\necho v2\n",
})
defer func() { _ = os.RemoveAll(srcV2) }()
cfgV2 := &GalleryBackend{Metadata: Metadata{Name: "orphan-test"}, URI: srcV2}
Expect(InstallBackend(context.TODO(), systemState, ml, cfgV2, nil, false)).To(Succeed())
Expect(filepath.Join(installed, "run.sh")).To(BeARegularFile())
Expect(filepath.Join(installed, "stale-lib.so")).NotTo(BeAnExistingFile())
})
})
Describe("Meta Backends", func() {

View File

@@ -619,4 +619,59 @@ var _ = Describe("Gallery", func() {
Expect(filtered[0].Name).To(Equal("sd-model"))
})
})
Describe("VoiceCloningCapability", func() {
It("derives support from an inline Base model configuration", func() {
m := &GalleryModel{
Metadata: Metadata{Name: "qwen-base", Backend: "qwen3-tts-cpp"},
ConfigFile: map[string]any{
"parameters": map[string]any{"model": "Qwen3-TTS-12Hz-0.6B-Base"},
},
}
capability := m.VoiceCloningCapability(tempDir)
Expect(capability).NotTo(BeNil())
Expect(capability.AcceptedAudioFormats).To(Equal([]string{"audio/wav"}))
})
It("uses gallery overrides when deciding between model variants", func() {
m := &GalleryModel{
Metadata: Metadata{Name: "qwen-custom", Backend: "qwen3-tts-cpp"},
ConfigFile: map[string]any{
"parameters": map[string]any{"model": "Qwen3-TTS-12Hz-0.6B-Base"},
},
Overrides: map[string]any{
"parameters": map[string]any{"model": "Qwen3-TTS-12Hz-0.6B-CustomVoice"},
},
}
Expect(m.VoiceCloningCapability(tempDir)).To(BeNil())
})
It("honors the typed capability override for custom gallery entries", func() {
m := &GalleryModel{
Metadata: Metadata{Name: "private-voice-model", Backend: "qwen-tts"},
Overrides: map[string]any{
"parameters": map[string]any{"model": "private-checkpoint"},
"tts": map[string]any{"voice_cloning": true},
},
}
Expect(m.VoiceCloningCapability(tempDir)).NotTo(BeNil())
})
It("honors an explicit gallery opt-out", func() {
m := &GalleryModel{
Metadata: Metadata{Name: "voxcpm-1.5", Backend: "voxcpm"},
ConfigFile: map[string]any{
"tts": map[string]any{"voice_cloning": true},
},
Overrides: map[string]any{
"tts": map[string]any{"voice_cloning": false},
},
}
Expect(m.VoiceCloningCapability(tempDir)).To(BeNil())
})
})
})

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