Compare commits

...
Author SHA1 Message Date
localai-org-maint-bot b08b7de719 fix(buun-llama-cpp): adapt shared grpc wrapper
Translate modern speculative fields to the pinned fork API, disable unsupported score and checkpoint features, and cover the compatibility transform with an idempotent regression test.

Assisted-by: Codex:gpt-5 [Codex]
2026-08-09 22:04:41 +00:00
localai-org-maint-bot c740c2952a fix(buun-llama-cpp): isolate fork patch series
The buun build copies the stock llama.cpp backend directory, including patches that target upstream. Remove that copied patch directory before invoking the shared build so only the explicit buun compatibility series is applied to the fork.

Assisted-by: Codex:gpt-5 [systematic-debugging]
2026-08-09 22:04:41 +00:00
localai-org-maint-bot 21f34ddf41 fix(buun-llama-cpp): match speculative draft split anchor
The shared gRPC wrapper stores p_split under the draft sub-structure. Match that exact source spelling so the fork-specific patch stage reaches the build on every architecture.

Assisted-by: Codex:gpt-5 [Codex]
2026-08-09 22:04:41 +00:00
Ettore Di Giacinto 8df5dd443a fix(buun-llama-cpp): shim cudaMemcpy{To,From}Symbol + WARP_SIZE on fwht128 shuffles
Two more hipblas-only build failures in buun's fattn.cu, fixed under the
same patches/ infrastructure:

1. cudaMemcpyToSymbol / cudaMemcpyFromSymbol — buun's Q² calibration +
   TCQ codebook upload paths call the symbol variants of cudaMemcpy.
   ggml/src/ggml-cuda/vendors/hip.h aliases every other cudaMemcpy*
   name (cudaMemcpy, cudaMemcpyAsync, cudaMemcpy2DAsync, …) but the
   symbol pair was never added. 15+ "use of undeclared identifier"
   errors across fattn.cu lines 40, 54, 74-76, 94, 100-101, 371, 883,
   905, 954, 976, 1449, 1463. Add the two missing aliases alongside
   the existing memcpy block.

2. __shfl_xor_sync fwht128 calls — same 3-arg omission pattern as the
   earlier argmax top-K fix. Lines 512 (ggml_cuda_fwht128 intra-warp
   butterfly) and 536 (fwht128_store_half neighbor fetch) drop the
   width argument that hip.h:33 requires. Add WARP_SIZE.

Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:04:41 +00:00
Ettore Di Giacinto 004708196c fix(buun-llama-cpp): pass WARP_SIZE to argmax __shfl_xor_sync calls
Two call sites in ggml/src/ggml-cuda/argmax.cu (the top-K intra-warp
merge added by buun) use the 3-arg CUDA form __shfl_xor_sync(mask, var,
laneMask), omitting the optional width parameter. The hipification shim
at ggml/src/ggml-cuda/vendors/hip.h:33 is a function-like macro that
requires all four arguments, so hipcc fails with:

    argmax.cu:265: too few arguments provided to function-like macro
      invocation
    note: macro '__shfl_xor_sync' defined here:
      #define __shfl_xor_sync(mask, var, laneMask, width) \
              __shfl_xor(var, laneMask, width)

Every other call in the same file already passes WARP_SIZE explicitly;
aligning these two with that convention fixes the hipblas build without
changing CUDA codegen (warpSize is the CUDA default).

Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:04:41 +00:00
Ettore Di Giacinto 945ec2dc96 fix(buun-llama-cpp): shim atomicAdd(double*,double) for pre-sm_60 CUDA
Buun's Q² calibration path in ggml/src/ggml-cuda/fattn.cu calls
atomicAdd with a double* destination. Native double atomicAdd is only
available on CUDA compute capability 6.0 and later — LocalAI's CUDA 12
Docker image builds for the full published arch range (which includes
sm_50/sm_52), so nvcc fails with:

    fattn.cu:812: error: no instance of overloaded function "atomicAdd"
    matches the argument list, argument types are: (double *, double)

Add the canonical CAS-loop shim from the CUDA C Programming Guide
(B.15 Atomic Functions) guarded on __CUDA_ARCH__ < 600. On sm_60+ the
guard is false and nvcc picks up the native intrinsic as before.

Patch file lives under backend/cpp/buun-llama-cpp/patches/ and is
applied to the cloned fork tree by apply-patches.sh (the infrastructure
already put in place for exactly this class of backport).

Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:04:41 +00:00
Ettore Di Giacinto 7c066a9940 ci(buun-llama-cpp): wire backend into test-extra + build matrix
Adds the buun-llama-cpp backend to the same CI pipelines that turboquant
and sherpa-onnx already use:

- scripts/changed-backends.js: path resolution for Dockerfile.buun-llama-cpp,
  plus fork-of-fork detection (changes under backend/cpp/llama-cpp/ also
  retrigger the buun pipeline, mirroring how turboquant is handled).
- .github/workflows/test-extra.yml: detect-changes output and a new
  tests-buun-llama-cpp-grpc job that runs make test-extra-backend-buun-llama-cpp
  (turbo3 V-cache, same rationale as tests-turboquant-grpc).
- .github/workflows/backend.yml: 9 matrix entries (CUDA 12/13, L4T CUDA
  13 ARM64, ROCm, SYCL f32/f16, CPU, L4T ARM64, Vulkan) paired with each
  existing turboquant entry so image builds have platform parity.

Also updates .agents/ai-coding-assistants.md to clarify that AI agents
operating under the human submitter's git identity SHOULD emit
Signed-off-by via `git commit -s` (never inventing or guessing another
identity) — documents the workflow this PR is using.

Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:04:41 +00:00
Ettore Di Giacinto 1454f28aa6 fix(buun-llama-cpp): drop logit_bias_eog arg from params_from_json_cmpl
Previous substitution kept the call as 5 args, but buun predates the
upstream refactor that also *added* the logit_bias_eog parameter to
params_from_json_cmpl — buun's signature is still the 4-arg form
  (const llama_vocab*, const common_params&, int, const json&)
and it still derives logit_bias_eog internally from the common_params.

Replace the substitution with a line-delete. Guard matches both the
original call (ctx_server.get_meta().logit_bias_eog) and the previously
substituted form (params_base.sampling.logit_bias_eog) so the script
stays safe across re-runs and whatever state the tree was left in.

Assisted-by: Claude:Opus-4.7 [Read] [Edit] [Bash]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:04:41 +00:00
Ettore Di Giacinto 0f168e4f5e fix(buun-llama-cpp): backport logit_bias_eog field to grpc-server copy
LocalAI's shared grpc-server.cpp reaches
ctx_server.get_meta().logit_bias_eog twice (the twin params_from_json_cmpl
callsites). That accessor was added to server_context_meta upstream after
buun's 2026-04-05 fork-point, so compiling against buun errors with
  'struct server_context_meta' has no member named 'logit_bias_eog'.

Rewrite the call sites — only in the buun grpc-server.cpp copy — to source
the vector from params_base.sampling.logit_bias_eog instead. That vector is
the underlying data the upstream meta accessor eventually returns (buun
still carries common_params_sampling::logit_bias_eog at common.h:280), so
the substitution yields identical behavior on both trees.

The sed is guarded by a grep for the call site, so this patch is
self-disabling once buun rebases past the upstream refactor.

Assisted-by: Claude:Opus-4.7 [Read] [Edit] [Bash] [WebFetch]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:04:41 +00:00
Ettore Di Giacinto f5e9ab591b test(gallery): extend importer specs to cover buun-llama-cpp
Two additions that pair with the new backend:
- An Import()-side case that asserts preference buun-llama-cpp produces
  backend: buun-llama-cpp in the emitted YAML (mirrors the existing
  ik-llama-cpp and turboquant cases).
- AdditionalBackends() spec now asserts all three drop-in replacements
  are advertised, and verifies buun-llama-cpp's Modality/Description
  alongside the other two.

Assisted-by: Claude:Opus-4.7 [Read] [Edit] [Bash]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:04:41 +00:00
Ettore Di Giacinto bf5c9341ea feat(backend): add buun-llama-cpp fork (DFlash + TCQ KV-cache)
spiritbuun/buun-llama-cpp is a fork of TheTom/llama-cpp-turboquant that adds
two independent features on top: DFlash block-diffusion speculative decoding
(via a dedicated DFlashDraftModel GGUF arch) and two extra TCQ KV-cache
variants (turbo2_tcq, turbo3_tcq) on top of TurboQuant's turbo2/turbo3/turbo4.

Follows the turboquant thin-wrapper pattern — reuses backend/cpp/llama-cpp
grpc-server sources verbatim, patches only the build copy to extend the KV
allow-list and wire up buun-exclusive tree_budget / draft_topk options.
DraftModel is already wired end-to-end (proto field 39 → params.speculative),
so DFlash activation only needs the existing options passthrough
(spec_type:dflash) plus the drafter path in draft_model.

CacheTypeOptions now surfaces the five turbo* values so the React UI dropdown
shows them — benefits turboquant too (previously users had to type them in
YAML manually).

Assisted-by: Claude:Opus-4.7 [Read] [Edit] [Bash] [WebFetch]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:04:41 +00:00
mudler's LocalAI [bot]andmudler e9cfc2d284 chore(model-gallery): ⬆️ update checksum (#11433)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-09 23:26:55 +02:00
mudler's LocalAI [bot]andmudler 1f5dbe8ffc chore: ⬆️ Update ikawrakow/ik_llama.cpp to a7c81affa48c6800d63111bdb33469a01d062daa (#11431)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-09 22:56:28 +02:00
mudler's LocalAI [bot]andmudler 1ffc68a153 chore: ⬆️ Update antirez/ds4 to 84cc882352757baf628a1776badf7cc54d584e28 (#11432)
⬆️ Update antirez/ds4

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-09 22:56:12 +02:00
localai-org-maint-botandlocalai-org-maint-bot 06ff56e674 feat(pii): restore request-scoped pseudonyms (#11272)
* feat(pii): restore request-scoped pseudonyms

Replace masked request values with unique per-request tokens when response restoration is enabled, then restore them across JSON and SSE write boundaries. Document the opt-in model setting and expose it in config metadata.\n\nAssisted-by: Codex:gpt-5

* fix(pii): wrap reversible redaction tokens

Use configurable token delimiters to avoid restoring ordinary model text that happens to match an internal identifier. Rename the option and document the confidentiality tradeoff.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-09 22:37:13 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto a0f50b2af2 feat(vllm-cpp): serve MiniMax-H3 video+audio generation (#11424)
* feat(vllm-cpp): serve MiniMax-H3 video+audio generation

vllm.cpp's C ABI grew a video slice (ABI v12): a second engine handle
loaded from the MiniMax-H3 checkpoint SET, one blocking generate, and a
composed ffmpeg argv the caller execs. This wires that into LocalAI's
existing /video endpoint, so `vllm-cpp` now serves both text and video
and a clip comes back as an MP4 with a real audio track rather than a
silent render.

The video engine is a separate handle rather than a mode of the text
one because H3 is not a model directory: the DiT, the text encoder and
two VAEs are separate artifacts, and vllm.cpp has the two loaders refuse
each other's checkpoints. `Load` takes the video branch when the config
declares any of the video options; `parameters.model` is the DiT and the
rest of the set is named in `options:`.

Three details are worth calling out because getting them wrong is
expensive:

- The partition is DECLARED, not detected. The community quantisations
  strip the release metadata and the FL2VA and Ref2VA DiTs are
  byte-structurally identical, so the engine refuses to generate until
  it is told which it has. Worse, a mismatch does not fail cleanly: a
  reference passed to an FL2VA DiT renders for hours and returns a
  coloured lattice over the frame. The backend refuses that combination
  up front instead.
- ffmpeg comes from the host. libvllm writes frames plus a WAV and
  composes the mux argv, then spawns nothing - that process boundary is
  upstream's decision. The backend execs it, the same arrangement
  vibevoice-cpp uses for transcoding, and ffmpeg also converts a
  start_image upload into the binary PPM at the exact output canvas the
  engine requires.
- It is slow. Roughly 176 s per denoise step at the default 1344x768
  canvas on a 20-SM device, so the 50-step default is a multi-hour job.
  Nothing on this path imposes a deadline.

The /video endpoint no longer forces 512x512 when the request omits the
geometry. Every video backend already supplies its own default for a
zero (512x512 for stablediffusion-ggml, 1280x720 for diffusers, 832x480
for longcat-video, 1344x768 for H3), so the hardcoded value only ever
overrode the model's trained canvas with one three of the four were
never trained at.

Moving the engine pin from ABI v10 to v16 also grows the text
vllm_model_params mirror by the v14 device field and the v16 KV-sizing
knobs. LocalAI sets none of them - 0 is the pre-v14 engine byte for byte
- but the struct SIZE is part of the layout contract, so leaving them
out would have vllm_engine_load read past the allocation.

Gallery: `minimax-h3-fl2va-q4` installs the Q4_K_M FL2VA set (~40 GB
across five weight files plus the two VAE configs that carry the latent
statistics).

Assisted-by: Claude:claude-opus-5 golangci-lint yamllint go-vet

* fix(vllm-cpp): unbreak the Darwin build at the new engine pin

src/capi/vllm_c.cpp opens one `extern "C" {` for the whole ABI surface,
so file-local helpers declared inside it inherit C linkage. The video
slice added one that returns std::string, which Apple Clang reports as
-Wreturn-type-c-linkage and vllm.cpp's target-local -Werror turns into a
build failure. GCC and upstream Clang do not diagnose it, so only the
metal-darwin-arm64 job saw it.

Suppress it the same way this Makefile already suppresses Apple Clang's
-Wgnu-folding-constant on the Metal build. The helper is never called
across the boundary so the warning describes no hazard here, but it is a
real upstream wart: the fix belongs in vllm.cpp, hoisting the helper
above the extern "C" block, and this flag should go when a pin carrying
that fix lands.

Assisted-by: Claude:claude-opus-5

* fix(vllm-cpp): patch the engine clone instead of the warning flag

The -Wno-return-type-c-linkage added in the previous commit does nothing.
vllm_cpp_set_warnings adds `-Wall -Wextra -Werror` as PRIVATE target
options, so they land after anything CMAKE_CXX_FLAGS contributes, and
-Wall re-enables the -Wreturn-type group that -Wreturn-type-c-linkage
belongs to. The darwin job failed again on the same line, which is the
evidence: a consumer cannot wave this off from outside the engine.

Position is the only fix, so carry it as a patch against the pinned SHA,
the way longcat-video patches its own upstream. It hoists the helper
above the `extern "C" {` that gives it C linkage; it is file-local and
never called across the boundary, so nothing else moves.

`git apply` is unguarded on purpose: a patch that stops applying must
fail the clone loudly, because the alternative is a pin that silently
ships without a fix it is documented to carry. The patch header names
what retires it - a pin carrying the fix upstream, where it belongs.

Verified by applying the patch with `git apply` to the exact blob at the
pinned SHA and diffing the result against the intended file.

Assisted-by: Claude:claude-opus-5

* chore(vllm-cpp): bump the engine pin to ABI v17 and drop the vendored OrEmpty patch

The OrEmpty linkage fix this backend carried as patches/0001-* landed upstream
(mudler/vllm.cpp#195, 7534da65), so the patch has done its job. It is deleted
rather than left in place: the Makefile applies patches/*.patch unguarded and
documents that "a patch that no longer applies must FAIL the clone", so keeping
it against fixed source would break the build the moment the pin moved. Bumping
the pin and deleting the patch therefore have to be the SAME change.

Pin f921062b -> 776c56f1 (current vllm.cpp main).

That range also carries the engine's ABI v17 (vllm_server_main: the OpenAI server
published on the public surface). registerLib compares the library's
vllm_abi_version against `abiVersion` for EXACT equality, so the constant moves
16 -> 17 in the same commit or every load fails with an ABI mismatch.

The bump is safe for the layout assertions in video_test.go: diffing include/vllm.h
across the two pins shows zero struct-field changes -- v17 adds one function
declaration, the version macro and a doc comment, nothing else -- so every
unsafe.Offsetof in the video params test still holds.

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

* chore(vllm-cpp): re-pin to pick up the VLLM_CPP_SERVER=OFF link fix

The previous pin carried vllm.cpp's ABI v17 (vllm_server_main) but not the guard
that makes it link when the server is compiled out. This backend builds libvllm
with VLLM_CPP_SERVER off, so the darwin lane failed at the dylib link with
vllm::entrypoints::openai::VllmServerMain undefined.

Fixed upstream in mudler/vllm.cpp#202: the C entry point is now guarded, so the
symbol is still exported (ABI v17 stays resolvable for dlopen) while the
no-server arm reports the missing capability instead of dragging in a translation
unit that was never compiled.

Verified upstream in BOTH arms before re-pinning: SERVER=ON builds and runs, and
SERVER=OFF configures, links, produces libvllm.so, and `nm -D` shows
vllm_server_main exported next to vllm_video_generate and vllm_transcribe.

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

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-09 22:34:51 +02:00
Matheus C. França f31c3bbf1b feat(i18n): add pt-BR translation (#11427)
Adds a complete Brazilian Portuguese (pt-BR) translation for the
LocalAI WebUI across 14 namespaces with full key parity against the
English locale, including modelEditor.json. Registers pt-BR in
SUPPORTED_LANGUAGES with the code 'pt-BR', name 'Português (Brasil)'
and flag 'BR'. Brand/model/product names and technical identifiers are
kept untranslated, matching the existing locale conventions.

Assisted-by: opencode:deepseek-v4-flash-free python3

Signed-off-by: Matheus C. França <matheus-catarino@hotmail.com>
2026-08-09 22:34:19 +02:00
mudler's LocalAI [bot]andmudler 1f30ecc398 chore(model-gallery): ⬆️ update checksum (#11423)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 23:04:23 +02:00
mudler's LocalAI [bot]andmudler 6c4acece2a chore: ⬆️ Update ikawrakow/ik_llama.cpp to f2328aa0c19954d0ab31a3de60fbf50e47c2429f (#11421)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 23:04:08 +02:00
mudler's LocalAI [bot]andmudler ea9f4f5bc5 chore: ⬆️ Update CrispStrobe/CrispASR to 17a6cc99422bfafadf7161e96dd7294c89da9c36 (#11404)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 08:23:40 +02:00
mudler's LocalAI [bot]andmudler 53637e5397 docs: ⬆️ update docs version mudler/LocalAI (#11415)
⬆️ 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-08-08 08:23:24 +02:00
Copilotandmudler c5645795ba fix(kokoros): add missing upscale_image stub to Backend trait impl (#11414)
* Initial plan

* fix(kokoros): add missing upscale_image stub to Backend impl

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 08:23:05 +02:00
mudler's LocalAI [bot]andmudler 7047ae7210 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 40dffce6857b4fe051f096379dc464764c718458 (#11403)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 08:22:34 +02:00
mudler's LocalAI [bot]andmudler 40343ebeed chore(model-gallery): ⬆️ update checksum (#11418)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 08:22:16 +02:00
mudler's LocalAI [bot]andmudler 18041b615e chore: ⬆️ Update ggml-org/whisper.cpp to 592feef04a1802b18cbeffd0fd0eb5d02570c2ec (#11416)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-08 08:21:59 +02:00
Adiraandlocalai-org-maint-bot ab52813342 feat(modelartifacts): support bounded parallel Hugging Face file downloads (#11162)
* feat(modelartifacts): support bounded parallel Hugging Face file downloads

Closes #11114.

Snapshot materialization fetched every file through the sequential
executor in DownloadFilesWithContext, so a repository split into many
shards spent most of its wall clock in per-file request latency rather
than moving bytes.

Add DownloadFilesWithConcurrency, an errgroup with SetLimit, and keep
DownloadFilesWithContext as a wrapper that passes a limit of 1. That
leaves the two non-artifact callers (core/gallery and the model config
loader) on exactly the path they had: tasks still run in slice order,
and the first failure still returns before any later task starts.

Only whole files run in parallel. A single file is never split, so the
.partial resume machinery and the per-file SHA check in
downloadTaskWithRetry are untouched.

Two details the parallel path forced:

- completedBytes becomes an atomic.Int64. Several AfterDownload hooks
  add to it while other files' progress callbacks read it; without this
  the race detector reports three races on the new specs.
- The caller's status callback is serialized. The sequential path gave
  it an implicit guarantee of never being entered twice at once, and it
  belongs to the caller, so the executor keeps that promise rather than
  pushing locking onto every caller. AfterDownload is deliberately not
  serialized -- it does the verify-and-promote work that parallelism
  exists to overlap.

Manifest order needed no work: each hook already writes its own
manifest.Files slot by snapshot index, so entries stay in snapshot
order whatever the completion order. A spec now pins that.

The default is 1, unchanged behaviour. A shared models volume is often
the bottleneck rather than the link, so raising it is a deployment
decision; --artifact-download-concurrency and
LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY expose it on both `run` and
`models install`.

Not done here, per the issue: no chunk-level parallelism within a single
file, and no throughput measurements across concurrency 1/2/4/8 -- that
needs a representative sharded repo and a real link.

Assisted-by: Claude:claude-opus-5 go-test gofmt
Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>

* feat(modelartifacts): expose download concurrency in settings

Follow-up to review feedback on #11162:

- The CLI flag and docs no longer describe the limit as Hugging Face
  specific. It applies to any artifact source, as @mudler pointed out.
- artifact_download_concurrency is now a persisted runtime setting and
  is editable from the WebUI, so it can be changed without a restart.

The manager's limit becomes an atomic.Int64 behind
SetDownloadConcurrency, because a live runtime setting can be updated
while a materialization is already in flight. Injected materializers
stay compatible through an optional setter interface, so a manager that
does not implement it is simply left alone.

Verified before taking this on: go build, go vet and go test -race all
pass for pkg/modelartifacts, pkg/downloader and core/config. The React
UI builds with vite, artifact_download_concurrency is present in the
built Settings chunk, and eslint reports the same 8 pre-existing
warnings on Settings.jsx as it does without the change.

Implementation contributed by localai-org-maint-bot on the review
thread; reviewed, verified and signed off by me.

Assisted-by: Codex:gpt-5
Assisted-by: Claude:claude-opus-5 go-test vite eslint
Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>

---------

Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-08-07 18:00:45 +02:00
78 changed files with 5438 additions and 96 deletions

No files matched your search

+29 -9
View File
@@ -35,19 +35,33 @@ All contributions must comply with LocalAI's licensing requirements:
## Signed-off-by and Developer Certificate of Origin
**AI agents MUST NOT add `Signed-off-by` tags.** Only humans can legally
certify the Developer Certificate of Origin (DCO). The human submitter
is responsible for:
Only humans can certify the Developer Certificate of Origin (DCO). AI
agents MUST NOT invent or guess a human identity for `Signed-off-by`
doing so forges the DCO certification.
- Reviewing all AI-generated code
However, when a human operator explicitly directs the AI to commit on
their behalf, the AI is acting as a typing tool — no different from an
editor macro or `git commit -s`. In that case the AI SHOULD add
`Signed-off-by:` using the **configured `user.name` / `user.email`** of
the current git repository (i.e. the operator's own identity). The
resulting trailer is the operator's signature; they take responsibility
for it by reviewing and pushing the commit. The AI MUST NOT use any
other identity and MUST NOT add its own name to the sign-off.
When running `git commit`, prefer `git commit --signoff` (or `-s`) so
the trailer is emitted by git itself from the configured identity,
rather than hand-writing it in a heredoc — this guarantees the sign-off
matches whatever identity the operator is currently using.
The human submitter remains responsible for:
- Reviewing all AI-generated code before it's pushed or merged
- Ensuring compliance with licensing requirements
- Adding their own `Signed-off-by` tag (when the project requires DCO)
to certify the contribution
- Taking full responsibility for the contribution
AI agents MUST NOT add `Co-Authored-By` trailers for themselves either.
A human reviewer owns the contribution; the AI's involvement is recorded
via `Assisted-by` (see below).
AI agents MUST NOT add `Co-Authored-By` trailers for themselves. A human
reviewer owns the contribution; the AI's involvement is recorded via
`Assisted-by` (see below).
## Attribution
@@ -84,6 +98,12 @@ Assisted-by: Claude:claude-opus-4-7 golangci-lint
Signed-off-by: Jane Developer <jane@example.com>
```
The `Signed-off-by` line uses Jane's own identity because Jane is the
submitter operating the AI. If Jane asks Claude to create the commit via
`git commit -s`, git emits that exact trailer from Jane's configured
identity — no separate human step is needed beyond Jane reviewing the
diff before pushing.
## Scope and Responsibility
Using an AI assistant does not reduce the contributor's responsibility.
+149
View File
@@ -480,6 +480,22 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
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-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-12-amd64'
# bigger-runner: same rationale as -gpu-nvidia-cuda-12-llama-cpp above
# (observed 6h5m wall-clock on v4.2.1, just past the 6h job timeout).
runs-on: 'bigger-runner'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1178,6 +1194,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
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-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-13-amd64'
# bigger-runner: observed 6h5m wall-clock on v4.2.1 — at the GHA timeout.
runs-on: 'bigger-runner'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1221,6 +1252,20 @@ include:
backend: "turboquant"
dockerfile: "./backend/Dockerfile.turboquant"
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-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-13-arm64'
base-image: "ubuntu:24.04"
runs-on: 'ubuntu-24.04-arm'
ubuntu-version: '2404'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -2521,6 +2566,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
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-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-intel-amd64'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2563,6 +2622,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
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-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-intel-amd64'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
@@ -3029,6 +3102,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -3059,6 +3147,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-arm64'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -3320,6 +3423,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2204'
- 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-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-l4t-cuda-12-arm64'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2204'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
@@ -3380,6 +3497,22 @@ include:
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
@@ -3412,6 +3545,22 @@ include:
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-arm64'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
+25
View File
@@ -33,6 +33,7 @@ jobs:
llama-cpp: ${{ steps.detect.outputs.llama-cpp }}
ik-llama-cpp: ${{ steps.detect.outputs.ik-llama-cpp }}
turboquant: ${{ steps.detect.outputs.turboquant }}
buun-llama-cpp: ${{ steps.detect.outputs['buun-llama-cpp'] }}
vllm: ${{ steps.detect.outputs.vllm }}
sglang: ${{ steps.detect.outputs.sglang }}
acestep-cpp: ${{ steps.detect.outputs.acestep-cpp }}
@@ -717,6 +718,30 @@ jobs:
- name: Build turboquant backend image and run gRPC e2e tests
run: |
make test-extra-backend-turboquant
tests-buun-llama-cpp-grpc:
needs: detect-changes
if: needs.detect-changes.outputs['buun-llama-cpp'] == 'true' || needs.detect-changes.outputs.run-all == 'true'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Clone
uses: actions/checkout@v6
with:
submodules: true
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.25.4'
# Exercises the buun-llama-cpp (fork-of-a-fork) backend with the
# fork-specific TurboQuant/TCQ KV-cache types. BACKEND_TEST_CACHE_TYPE_V
# is set to turbo3 so the test round-trips through the fork's KV
# allow-list — picking a stock llama.cpp type would only re-test the
# shared code path. DFlash speculative decoding is not exercised here
# because the one known public target/drafter pair (Qwen3.5-27B) is too
# large for CI.
- name: Build buun-llama-cpp backend image and run gRPC e2e tests
run: |
make test-extra-backend-buun-llama-cpp
# tests-vllm-grpc is currently disabled in CI.
#
# The prebuilt vllm CPU wheel is compiled with AVX-512 VNNI/BF16
+21 -1
View File
@@ -1,4 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/buun-llama-cpp
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
GOCMD=go
@@ -749,6 +750,19 @@ test-extra-backend-bonsai: docker-build-bonsai
BACKEND_TEST_MODEL_URL=https://huggingface.co/prism-ml/Bonsai-8B-gguf/resolve/main/Bonsai-8B-Q1_0.gguf \
$(MAKE) test-extra-backend
## buun-llama-cpp: exercises the fork-of-a-fork backend (spiritbuun/buun-llama-cpp)
## with the *TurboQuant/TCQ-specific* KV-cache types (turbo3 for V). Same rationale
## as turboquant above: picking a standard llama.cpp type would only re-test the
## shared code path. buun inherits turboquant's turbo2/turbo3/turbo4 and adds
## turbo2_tcq / turbo3_tcq on top. DFlash speculative decoding is not exercised
## here because no small DFlash drafter model exists (the known public pair is
## Qwen3.5-27B, ~54 GB).
test-extra-backend-buun-llama-cpp: docker-build-buun-llama-cpp
BACKEND_IMAGE=local-ai-backend:buun-llama-cpp \
BACKEND_TEST_CACHE_TYPE_K=q8_0 \
BACKEND_TEST_CACHE_TYPE_V=turbo3 \
$(MAKE) test-extra-backend
## Audio transcription wrapper for the llama-cpp backend.
## Drives the new AudioTranscription / AudioTranscriptionStream RPCs against
## ggml-org/Qwen3-ASR-0.6B-GGUF (a small ASR model that requires its mmproj
@@ -1285,6 +1299,11 @@ BACKEND_PRIVACY_FILTER = privacy-filter|privacy-filter|.|false|false
# against apt gRPC/protobuf rather than a prebuilt base-grpc image; the reason
# is on the audio-cpp block in .github/backend-matrix.yml.
BACKEND_AUDIO_CPP = audio-cpp|audio-cpp|.|false|false
# buun-llama-cpp is a fork-of-a-fork (spiritbuun/buun-llama-cpp forks
# TheTom/llama-cpp-turboquant) that adds DFlash block-diffusion speculative
# decoding and extra TCQ KV-cache variants on top of TurboQuant. Same thin
# wrapper pattern as turboquant — reuses backend/cpp/llama-cpp grpc-server.
BACKEND_BUUN_LLAMA_CPP = buun-llama-cpp|buun-llama-cpp|.|false|false
# Golang backends
BACKEND_PIPER = piper|golang|.|false|true
@@ -1390,6 +1409,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_BONSAI)))
$(eval $(call generate-docker-build-target,$(BACKEND_DS4)))
$(eval $(call generate-docker-build-target,$(BACKEND_PRIVACY_FILTER)))
$(eval $(call generate-docker-build-target,$(BACKEND_AUDIO_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_BUUN_LLAMA_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_PIPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_LOCAL_STORE)))
$(eval $(call generate-docker-build-target,$(BACKEND_VALKEY_STORE)))
@@ -1459,7 +1479,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-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-nemo-speech-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-buun-llama-cpp docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-nemo-speech-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
########################################################
### Mock Backend for E2E Tests
+1 -1
View File
@@ -231,7 +231,7 @@ Most backends wrap a best-in-class upstream engine. A handful of them are native
| Backend | What it does |
|---------|-------------|
| [vllm.cpp](https://github.com/mudler/vllm.cpp) | From-scratch C++20 port of vLLM for text generation: paged KV cache, continuous batching, prefix caching, safetensors + GGUF loading, engine-enforced structured output, on CPU, CUDA, Metal and Vulkan |
| [vllm.cpp](https://github.com/mudler/vllm.cpp) | From-scratch C++20 port of vLLM for text generation: paged KV cache, continuous batching, prefix caching, safetensors + GGUF loading, engine-enforced structured output, on CPU, CUDA, Metal and Vulkan. Also serves MiniMax-H3 joint video+audio generation |
| [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/localai-org/moss-transcribe.cpp) | C++/GGML port of OpenMOSS MOSS-Transcribe-Diarize: joint long-form transcription, speaker diarization and timestamping in a single pass |
| [moss-tts.cpp](https://github.com/mudler/moss-tts.cpp) | C++/GGML port of the OpenMOSS MOSS-TTS family: text-to-speech (MOSS-TTS-Local v1.5, 48 kHz stereo) with reference-audio voice cloning, through the MOSS-Audio-Tokenizer neural codec |
+290
View File
@@ -0,0 +1,290 @@
ARG BASE_IMAGE=ubuntu:24.04
ARG GRPC_BASE_IMAGE=${BASE_IMAGE}
# The grpc target does one thing, it builds and installs GRPC. This is in it's own layer so that it can be effectively cached by CI.
# You probably don't need to change anything here, and if you do, make sure that CI is adjusted so that the cache continues to work.
FROM ${GRPC_BASE_IMAGE} AS grpc
# This is a bit of a hack, but it's required in order to be able to effectively cache this layer in CI
ARG GRPC_MAKEFLAGS="-j4 -Otarget"
ARG GRPC_VERSION=v1.65.0
ARG CMAKE_FROM_SOURCE=false
# CUDA Toolkit 13.x compatibility: CMake 3.31.9+ fixes toolchain detection/arch table issues
ARG CMAKE_VERSION=3.31.10
ENV MAKEFLAGS=${GRPC_MAKEFLAGS}
WORKDIR /build
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
build-essential curl libssl-dev \
git wget && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install CMake (the version in 22.04 is too old)
RUN <<EOT bash
if [ "${CMAKE_FROM_SOURCE}" = "true" ]; then
curl -L -s https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}.tar.gz -o cmake.tar.gz && tar xvf cmake.tar.gz && cd cmake-${CMAKE_VERSION} && ./configure && make && make install
else
apt-get update && \
apt-get install -y \
cmake && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
fi
EOT
# We install GRPC to a different prefix here so that we can copy in only the build artifacts later
# saves several hundred MB on the final docker image size vs copying in the entire GRPC source tree
# and running make install in the target container
RUN git clone --recurse-submodules --jobs 4 -b ${GRPC_VERSION} --depth 1 --shallow-submodules https://github.com/grpc/grpc && \
mkdir -p /build/grpc/cmake/build && \
cd /build/grpc/cmake/build && \
sed -i "216i\ TESTONLY" "../../third_party/abseil-cpp/absl/container/CMakeLists.txt" && \
cmake -DgRPC_INSTALL=ON -DgRPC_BUILD_TESTS=OFF -DCMAKE_INSTALL_PREFIX:PATH=/opt/grpc ../.. && \
make && \
make install && \
rm -rf /build
FROM ${BASE_IMAGE} AS builder
ARG CMAKE_FROM_SOURCE=false
ARG CMAKE_VERSION=3.31.10
# We can target specific CUDA ARCHITECTURES like --build-arg CUDA_DOCKER_ARCH='75;86;89;120'
ARG CUDA_DOCKER_ARCH
ENV CUDA_DOCKER_ARCH=${CUDA_DOCKER_ARCH}
ARG CMAKE_ARGS
ENV CMAKE_ARGS=${CMAKE_ARGS}
ARG BACKEND=rerankers
ARG BUILD_TYPE
ENV BUILD_TYPE=${BUILD_TYPE}
ARG CUDA_MAJOR_VERSION
ARG CUDA_MINOR_VERSION
ARG SKIP_DRIVERS=false
ENV CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION}
ENV CUDA_MINOR_VERSION=${CUDA_MINOR_VERSION}
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGETARCH
ARG TARGETVARIANT
ARG GO_VERSION=1.25.4
ARG UBUNTU_VERSION=2404
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
ccache git \
ca-certificates \
make \
pkg-config libcurl4-openssl-dev \
curl unzip \
libssl-dev wget && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Cuda
ENV PATH=/usr/local/cuda/bin:${PATH}
# HipBLAS requirements
ENV PATH=/opt/rocm/bin:${PATH}
# Vulkan requirements
RUN <<EOT bash
if [ "${BUILD_TYPE}" = "vulkan" ] && [ "${SKIP_DRIVERS}" = "false" ]; then
apt-get update && \
apt-get install -y --no-install-recommends \
software-properties-common pciutils wget gpg-agent && \
apt-get install -y libglm-dev cmake libxcb-dri3-0 libxcb-present0 libpciaccess0 \
libpng-dev libxcb-keysyms1-dev libxcb-dri3-dev libx11-dev g++ gcc \
libwayland-dev libxrandr-dev libxcb-randr0-dev libxcb-ewmh-dev \
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
if [ "amd64" = "$TARGETARCH" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
mkdir -p /opt/vulkan-sdk && \
mv 1.4.335.0 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.335.0 && \
./vulkansdk --no-deps --maxjobs \
vulkan-loader \
vulkan-validationlayers \
vulkan-extensionlayer \
vulkan-tools \
shaderc && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
rm -rf /opt/vulkan-sdk
fi
if [ "arm64" = "$TARGETARCH" ]; then
mkdir vulkan && cd vulkan && \
curl -L -o vulkan-sdk.tar.xz https://github.com/mudler/vulkan-sdk-arm/releases/download/1.4.335.0/vulkansdk-ubuntu-24.04-arm-1.4.335.0.tar.xz && \
tar -xvf vulkan-sdk.tar.xz && \
rm vulkan-sdk.tar.xz && \
cd 1.4.335.0 && \
cp -rfv aarch64/bin/* /usr/bin/ && \
cp -rfv aarch64/lib/* /usr/lib/aarch64-linux-gnu/ && \
cp -rfv aarch64/include/* /usr/include/ && \
cp -rfv aarch64/share/* /usr/share/ && \
cd ../.. && \
rm -rf vulkan
fi
ldconfig && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
fi
EOT
# CuBLAS requirements
RUN <<EOT bash
if ( [ "${BUILD_TYPE}" = "cublas" ] || [ "${BUILD_TYPE}" = "l4t" ] ) && [ "${SKIP_DRIVERS}" = "false" ]; then
apt-get update && \
apt-get install -y --no-install-recommends \
software-properties-common pciutils
if [ "amd64" = "$TARGETARCH" ]; then
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/x86_64/cuda-keyring_1.1-1_all.deb
fi
if [ "arm64" = "$TARGETARCH" ]; then
if [ "${CUDA_MAJOR_VERSION}" = "13" ]; then
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/sbsa/cuda-keyring_1.1-1_all.deb
else
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/arm64/cuda-keyring_1.1-1_all.deb
fi
fi
dpkg -i cuda-keyring_1.1-1_all.deb && \
rm -f cuda-keyring_1.1-1_all.deb && \
apt-get update && \
apt-get install -y --no-install-recommends \
cuda-nvcc-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcufft-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcurand-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcublas-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcusparse-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcusolver-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION}
if [ "${CUDA_MAJOR_VERSION}" = "13" ] && [ "arm64" = "$TARGETARCH" ]; then
apt-get install -y --no-install-recommends \
libcufile-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} libcudnn9-cuda-${CUDA_MAJOR_VERSION} cuda-cupti-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} libnvjitlink-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION}
fi
apt-get clean && \
rm -rf /var/lib/apt/lists/*
fi
EOT
# https://github.com/NVIDIA/Isaac-GR00T/issues/343
RUN <<EOT bash
if [ "${BUILD_TYPE}" = "cublas" ] && [ "${TARGETARCH}" = "arm64" ]; then
wget https://developer.download.nvidia.com/compute/cudss/0.6.0/local_installers/cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0_0.6.0-1_arm64.deb && \
dpkg -i cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0_0.6.0-1_arm64.deb && \
cp /var/cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0/cudss-*-keyring.gpg /usr/share/keyrings/ && \
apt-get update && apt-get -y install cudss cudss-cuda-${CUDA_MAJOR_VERSION} && \
wget https://developer.download.nvidia.com/compute/nvpl/25.5/local_installers/nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5_1.0-1_arm64.deb && \
dpkg -i nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5_1.0-1_arm64.deb && \
cp /var/nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5/nvpl-*-keyring.gpg /usr/share/keyrings/ && \
apt-get update && apt-get install -y nvpl
fi
EOT
# If we are building with clblas support, we need the libraries for the builds
RUN if [ "${BUILD_TYPE}" = "clblas" ] && [ "${SKIP_DRIVERS}" = "false" ]; then \
apt-get update && \
apt-get install -y --no-install-recommends \
libclblast-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* \
; fi
RUN if [ "${BUILD_TYPE}" = "hipblas" ] && [ "${SKIP_DRIVERS}" = "false" ]; then \
apt-get update && \
apt-get install -y --no-install-recommends \
hipblas-dev \
rocblas-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
# I have no idea why, but the ROCM lib packages don't trigger ldconfig after they install, which results in local-ai and others not being able
# to locate the libraries. We run ldconfig ourselves to work around this packaging deficiency
ldconfig && \
# Log which GPU architectures have rocBLAS kernel support
echo "rocBLAS library data architectures:" && \
(ls /opt/rocm*/lib/rocblas/library/Kernels* 2>/dev/null || ls /opt/rocm*/lib64/rocblas/library/Kernels* 2>/dev/null) | grep -oP 'gfx[0-9a-z+-]+' | sort -u || \
echo "WARNING: No rocBLAS kernel data found" \
; fi
RUN echo "TARGETARCH: $TARGETARCH"
# We need protoc installed, and the version in 22.04 is too old. We will create one as part installing the GRPC build below
# but that will also being in a newer version of absl which stablediffusion cannot compile with. This version of protoc is only
# here so that we can generate the grpc code for the stablediffusion build
RUN <<EOT bash
if [ "amd64" = "$TARGETARCH" ]; then
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v27.1/protoc-27.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
fi
if [ "arm64" = "$TARGETARCH" ]; then
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v27.1/protoc-27.1-linux-aarch_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
fi
EOT
# Install CMake (the version in 22.04 is too old)
RUN <<EOT bash
if [ "${CMAKE_FROM_SOURCE}" = "true" ]; then
curl -L -s https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}.tar.gz -o cmake.tar.gz && tar xvf cmake.tar.gz && cd cmake-${CMAKE_VERSION} && ./configure && make && make install
else
apt-get update && \
apt-get install -y \
cmake && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
fi
EOT
COPY --from=grpc /opt/grpc /usr/local
COPY . /LocalAI
RUN <<'EOT' bash
set -euxo pipefail
if [[ -n "${CUDA_DOCKER_ARCH:-}" ]]; then
CUDA_ARCH_ESC="${CUDA_DOCKER_ARCH//;/\\;}"
export CMAKE_ARGS="${CMAKE_ARGS:-} -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCH_ESC}"
echo "CMAKE_ARGS(env) = ${CMAKE_ARGS}"
rm -rf /LocalAI/backend/cpp/buun-llama-cpp-*-build
fi
cd /LocalAI/backend/cpp/buun-llama-cpp
if [ "${TARGETARCH}" = "arm64" ] || [ "${BUILD_TYPE}" = "hipblas" ]; then
make buun-llama-cpp-fallback
make buun-llama-cpp-grpc
make buun-llama-cpp-rpc-server
else
make buun-llama-cpp-avx
make buun-llama-cpp-avx2
make buun-llama-cpp-avx512
make buun-llama-cpp-fallback
make buun-llama-cpp-grpc
make buun-llama-cpp-rpc-server
fi
EOT
# Copy libraries using a script to handle architecture differences
RUN make -BC /LocalAI/backend/cpp/buun-llama-cpp package
FROM scratch
# Copy all available binaries (the build process only creates the appropriate ones for the target architecture)
COPY --from=builder /LocalAI/backend/cpp/buun-llama-cpp/package/. ./
+92
View File
@@ -0,0 +1,92 @@
# Pinned to the HEAD of master on https://github.com/spiritbuun/buun-llama-cpp.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
BUUN_LLAMA_VERSION?=22464d0848b87c5d56b52fdf6af2e5da46bf803e
LLAMA_REPO?=https://github.com/spiritbuun/buun-llama-cpp
CMAKE_ARGS?=
BUILD_TYPE?=
NATIVE?=false
ONEAPI_VARS?=/opt/intel/oneapi/setvars.sh
TARGET?=--target grpc-server
JOBS?=$(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
ARCH?=$(shell uname -m)
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
LLAMA_CPP_DIR := $(CURRENT_MAKEFILE_DIR)/../llama-cpp
GREEN := \033[0;32m
RESET := \033[0m
# buun-llama-cpp is a llama.cpp fork-of-a-fork (spiritbuun/buun-llama-cpp forked
# TheTom/llama-cpp-turboquant, which itself forked ggml-org/llama.cpp). Rather
# than duplicating grpc-server.cpp / CMakeLists.txt / prepare.sh we reuse the
# ones in backend/cpp/llama-cpp, and only swap which repo+sha the fetch step
# pulls. Each flavor target copies ../llama-cpp into a sibling
# ../buun-llama-cpp-<flavor>-build directory, then invokes llama-cpp's own
# build-llama-cpp-grpc-server with LLAMA_REPO/LLAMA_VERSION overridden to point
# at the fork.
PATCHES_DIR := $(CURRENT_MAKEFILE_DIR)/patches
# Each flavor target:
# 1. copies backend/cpp/llama-cpp/ (grpc-server.cpp + prepare.sh + CMakeLists.txt + Makefile)
# into a sibling buun-llama-cpp-<flavor>-build directory;
# 2. clones the buun fork into buun-llama-cpp-<flavor>-build/llama.cpp via the
# copy's own `llama.cpp` target, overriding LLAMA_REPO/LLAMA_VERSION;
# 3. applies patches from backend/cpp/buun-llama-cpp/patches/ to the cloned
# fork sources (for backporting upstream commits the fork hasn't pulled);
# 4. runs the copy's `grpc-server` target, which produces the binary we copy
# up as buun-llama-cpp-<flavor>.
define buun-llama-cpp-build
rm -rf $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build
cp -rf $(LLAMA_CPP_DIR) $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build
# Stock llama.cpp patches target upstream and may not apply to this fork.
# The buun-specific compatibility series is applied explicitly below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build purge
# Augment the copied grpc-server.cpp's KV-cache allow-list with the
# fork's turbo2/turbo3/turbo4/turbo2_tcq/turbo3_tcq types and wire up the
# DFlash-specific option handlers (tree_budget / draft_topk). We patch the
# *copy*, never the original under backend/cpp/llama-cpp/, so the stock
# llama-cpp build stays compiling against vanilla upstream.
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/grpc-server.cpp
$(info $(GREEN)I buun-llama-cpp build info:$(1)$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BUUN_LLAMA_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build llama.cpp
bash $(CURRENT_MAKEFILE_DIR)/apply-patches.sh $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/llama.cpp $(PATCHES_DIR)
CMAKE_ARGS="$(CMAKE_ARGS) $(2)" TARGET="$(3)" \
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BUUN_LLAMA_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build grpc-server
cp -rfv $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/grpc-server buun-llama-cpp-$(1)
endef
buun-llama-cpp-avx2:
$(call buun-llama-cpp-build,avx2,-DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on,--target grpc-server)
buun-llama-cpp-avx512:
$(call buun-llama-cpp-build,avx512,-DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on,--target grpc-server)
buun-llama-cpp-avx:
$(call buun-llama-cpp-build,avx,-DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server)
buun-llama-cpp-fallback:
$(call buun-llama-cpp-build,fallback,-DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server)
buun-llama-cpp-grpc:
$(call buun-llama-cpp-build,grpc,-DGGML_RPC=ON -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server --target rpc-server)
buun-llama-cpp-rpc-server: buun-llama-cpp-grpc
cp -rf $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-grpc-build/llama.cpp/build/bin/rpc-server buun-llama-cpp-rpc-server
package:
bash package.sh
test:
bash test-patch-grpc-server.sh
purge:
rm -rf $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-*-build
rm -rf buun-llama-cpp-* package
clean: purge
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Apply the buun-llama-cpp patch series to a cloned buun-llama-cpp checkout.
#
# buun-llama-cpp is a fork-of-a-fork that branched off upstream llama.cpp
# before some API changes the shared backend/cpp/llama-cpp/grpc-server.cpp
# depends on. We carry those upstream commits as patch files under
# backend/cpp/buun-llama-cpp/patches/ and apply them here so the reused
# grpc-server source compiles against the fork unmodified.
#
# Drop the corresponding patch from patches/ whenever the fork catches up with
# upstream — the build will fail fast if a patch stops applying, which is the
# signal to retire it.
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "usage: $0 <llama.cpp-src-dir> <patches-dir>" >&2
exit 2
fi
SRC_DIR=$1
PATCHES_DIR=$2
if [[ ! -d "$SRC_DIR" ]]; then
echo "source dir does not exist: $SRC_DIR" >&2
exit 2
fi
if [[ ! -d "$PATCHES_DIR" ]]; then
echo "no patches dir at $PATCHES_DIR, nothing to apply"
exit 0
fi
shopt -s nullglob
patches=("$PATCHES_DIR"/*.patch)
shopt -u nullglob
if [[ ${#patches[@]} -eq 0 ]]; then
echo "no .patch files in $PATCHES_DIR, nothing to apply"
exit 0
fi
cd "$SRC_DIR"
for patch in "${patches[@]}"; do
echo "==> applying $patch"
git apply --verbose "$patch"
done
echo "all buun-llama-cpp patches applied successfully"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Script to copy the appropriate libraries based on architecture
# This script is used in the final stage of the Dockerfile
set -e
CURDIR=$(dirname "$(realpath $0)")
REPO_ROOT="${CURDIR}/../../.."
# Create lib directory
mkdir -p $CURDIR/package/lib
cp -avrf $CURDIR/buun-llama-cpp-* $CURDIR/package/
cp -rfv $CURDIR/run.sh $CURDIR/package/
# Detect architecture and copy appropriate libraries
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
# x86_64 architecture
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
# ARM64 architecture
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
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries based on BUILD_TYPE
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/
ls -liah $CURDIR/package/lib/
+196
View File
@@ -0,0 +1,196 @@
#!/bin/bash
# Patch the shared backend/cpp/llama-cpp/grpc-server.cpp *copy* used by the
# buun-llama-cpp build to account for three gaps between upstream and the fork:
#
# 1. Augment the kv_cache_types[] allow-list so `LoadModel` accepts the
# fork-specific `turbo2` / `turbo3` / `turbo4` cache types plus the buun
# additions `turbo2_tcq` / `turbo3_tcq`.
#
# 2. Adapt the post-refactor speculative-decoding fields and option handlers
# to the fork's legacy flat common_params_speculative layout, while adding
# buun-exclusive tree_budget / draft_topk support.
# These reference struct fields (common_params.speculative.tree_budget
# and .draft_topk) that only exist in buun's common/common.h — adding
# them to the shared backend/cpp/llama-cpp/grpc-server.cpp would break
# the stock llama-cpp build, so we inject them only into the buun copy.
#
# 3. Replace `get_media_marker()` (added upstream in ggml-org/llama.cpp#21962,
# server-side random per-instance marker) with the legacy "<__media__>"
# literal. The fork branched before that PR, so server-common.cpp has no
# get_media_marker symbol. The fork's mtmd_default_marker() still returns
# "<__media__>", and Go-side tooling falls back to that sentinel when the
# backend does not expose media_marker, so substituting the literal keeps
# behavior identical on the buun path.
#
# We patch the *copy* sitting in buun-llama-cpp-<flavor>-build/, never the
# original under backend/cpp/llama-cpp/, so the stock llama-cpp build keeps
# compiling against vanilla upstream.
#
# Idempotent: skips each insertion if its marker is already present (so re-runs
# of the same build dir don't double-insert).
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 <grpc-server.cpp>" >&2
exit 2
fi
SRC=$1
if [[ ! -f "$SRC" ]]; then
echo "grpc-server.cpp not found at $SRC" >&2
exit 2
fi
if grep -q 'GGML_TYPE_TURBO2_TCQ' "$SRC"; then
echo "==> $SRC already has buun cache types, skipping KV allow-list patch"
else
echo "==> patching $SRC to allow turbo2/turbo3/turbo4/turbo2_tcq/turbo3_tcq KV-cache types"
# Insert the five TURBO entries right after the first ` GGML_TYPE_Q5_1,`
# line (the kv_cache_types[] allow-list). Using awk because the builder
# image does not ship python3, and GNU sed's multi-line `a\` quoting is
# awkward.
awk '
/^ GGML_TYPE_Q5_1,$/ && !done {
print
print " // buun-llama-cpp fork extras — added by patch-grpc-server.sh"
print " GGML_TYPE_TURBO2_0,"
print " GGML_TYPE_TURBO3_0,"
print " GGML_TYPE_TURBO4_0,"
print " GGML_TYPE_TURBO2_TCQ,"
print " GGML_TYPE_TURBO3_TCQ,"
done = 1
next
}
{ print }
END {
if (!done) {
print "patch-grpc-server.sh: anchor ` GGML_TYPE_Q5_1,` not found" > "/dev/stderr"
exit 1
}
}
' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> KV allow-list patch OK"
fi
if grep -q 'buun-llama-cpp legacy speculative options' "$SRC"; then
echo "==> $SRC already has legacy speculative option handlers, skipping"
else
echo "==> replacing modern speculative option handlers with the fork-compatible set"
# Replace the whole speculative option section. The fork predates chained
# speculative types and the nested draft/ngram families, so retaining any
# modern-only handler makes the copied server fail at compile time.
awk '
/} else if \(!strcmp\(optname, "spec_type"\)/ && !done {
print " // buun-llama-cpp legacy speculative options"
print " } else if (!strcmp(optname, \"spec_type\") || !strcmp(optname, \"speculative_type\")) {"
print " auto type = common_speculative_type_from_name(optval_str.substr(0, optval_str.find(\",\")));"
print " if (type != COMMON_SPECULATIVE_TYPE_COUNT) params.speculative.type = type;"
print " } else if (!strcmp(optname, \"spec_n_max\") || !strcmp(optname, \"draft_max\")) {"
print " if (optval != NULL) { try { params.speculative.n_max = std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_n_min\") || !strcmp(optname, \"draft_min\")) {"
print " if (optval != NULL) { try { params.speculative.n_min = std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_p_min\") || !strcmp(optname, \"draft_p_min\")) {"
print " if (optval != NULL) { try { params.speculative.p_min = std::stof(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_p_split\")) {"
print " if (optval != NULL) { try { params.speculative.p_split = std::stof(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_ngram_size_n\") || !strcmp(optname, \"ngram_size_n\")) {"
print " if (optval != NULL) { try { params.speculative.ngram_size_n = (uint16_t)std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_ngram_size_m\") || !strcmp(optname, \"ngram_size_m\")) {"
print " if (optval != NULL) { try { params.speculative.ngram_size_m = (uint16_t)std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_ngram_min_hits\") || !strcmp(optname, \"ngram_min_hits\")) {"
print " if (optval != NULL) { try { params.speculative.ngram_min_hits = (uint16_t)std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"draft_gpu_layers\")) {"
print " if (optval != NULL) { try { params.speculative.n_gpu_layers = std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"tree_budget\")) {"
print " if (optval != NULL) { try { params.speculative.tree_budget = std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"draft_topk\")) {"
print " if (optval != NULL) { try { params.speculative.draft_topk = std::stoi(optval_str); } catch (...) {} }"
skipping = 1
next
}
skipping && /^ }$/ { skipping = 0; done = 1; print; next }
!skipping { print }
END {
if (!done) {
print "patch-grpc-server.sh: speculative option section not found" > "/dev/stderr"
exit 1
}
}
' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> legacy speculative option-handler patch OK"
fi
# The modern server initializes a vector of speculative types when DraftModel
# is present. The fork still exposes a single enum value.
awk '
/const bool no_spec_type = params\.speculative\.types\.empty\(\)/ && !done {
print " if (params.speculative.type == COMMON_SPECULATIVE_TYPE_NONE) {"
print " params.speculative.type = COMMON_SPECULATIVE_TYPE_DRAFT;"
print " }"
skipping = 1
next
}
skipping && /^ }$/ { skipping = 0; done = 1; next }
!skipping { print }
' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
# Map supported post-refactor fields back to the names used by the pinned fork.
sed -E \
-e 's/params\.speculative\.draft\.mparams\.path/params.speculative.mparams_dft.path/g' \
-e 's/params\.speculative\.draft\.n_gpu_layers/params.speculative.n_gpu_layers/g' \
-e 's/ctx_server\.impl->model_tgt/ctx_server.impl->model/g' \
-e '/params\.cache_idle_slots =/d' \
-e '/params\.split_mode = LLAMA_SPLIT_MODE_TENSOR;/d' \
-e '/params\.speculative\.draft\.tensor_buft_overrides/d' \
"$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
if ! grep -q '^#define LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP' "$SRC"; then
sed '0,/^#include/{s/^#include/#define LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP 1\n\n#include/}' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
fi
if grep -qE 'ctx_server\.get_meta\(\)\.logit_bias_eog|params_base\.sampling\.logit_bias_eog,' "$SRC"; then
echo "==> patching $SRC to drop the logit_bias_eog arg from params_from_json_cmpl() callsites (buun still uses the pre-refactor 4-arg signature)"
# Upstream llama.cpp refactored params_from_json_cmpl to take a precomputed
# logit_bias_eog vector after buun's 2026-04-05 fork-point — simultaneously
# adding server_context_meta::logit_bias_eog as the supplier. Buun carries
# neither change: its params_from_json_cmpl is still 4-arg, and internally
# derives logit_bias_eog from the common_params it's passed. So we just
# delete the argument line entirely — the remaining 4 args match buun's
# signature and the resulting behavior matches upstream bit-for-bit
# (upstream's 5th arg is the same data buun derives internally).
#
# Guard is broad so this works whether the line has been run through this
# block before (leaving params_base.sampling.logit_bias_eog,) or not
# (leaving the original ctx_server.get_meta().logit_bias_eog,).
sed -E '/^[[:space:]]+(ctx_server\.get_meta\(\)\.logit_bias_eog|params_base\.sampling\.logit_bias_eog),$/d' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> logit_bias_eog arg drop OK"
else
echo "==> $SRC has no logit_bias_eog arg line, skipping"
fi
if grep -q 'get_media_marker()' "$SRC"; then
echo "==> patching $SRC to replace get_media_marker() with legacy \"<__media__>\" literal"
# Only one call site today (ModelMetadata), but replace all occurrences to
# stay robust if upstream adds more. Use a temp file to avoid relying on
# sed -i portability (the builder image uses GNU sed, but keeping this
# consistent with the awk block above).
sed 's/get_media_marker()/"<__media__>"/g' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> get_media_marker() substitution OK"
else
echo "==> $SRC has no get_media_marker() call, skipping media-marker patch"
fi
echo "==> all patches applied"
@@ -0,0 +1,46 @@
Subject: [PATCH] ggml-cuda/fattn: provide atomicAdd(double*,double) shim for pre-sm_60
Buun's Q² calibration path in ggml_cuda_turbo_scale_q calls
atomicAdd(&d_q_channel_sq_fattn[threadIdx.x], (double)(val * val));
but native double atomicAdd is only available on compute capability 6.0
and newer. Compiling against a CUDA arch list that includes older
architectures (LocalAI's CUDA 12 Docker image builds for the full
published arch range) fails with:
fattn.cu(812): error: no instance of overloaded function "atomicAdd"
matches the argument list, argument types are: (double *, double)
Add the canonical CUDA-programming-guide shim at the top of fattn.cu so
pre-sm_60 codegen has a definition to call. On sm_60+ the native CUDA
intrinsic is used and the shim is elided via __CUDA_ARCH__.
--- a/ggml/src/ggml-cuda/fattn.cu
+++ b/ggml/src/ggml-cuda/fattn.cu
@@ -7,6 +7,27 @@
#include <atomic>
+// Pre-sm_60 double atomicAdd shim. Native double atomicAdd(double*,double)
+// is only available on CUDA compute capability 6.0+ (see CUDA C Programming
+// Guide, B.15 Atomic Functions). Buun's Q² calibration path below calls
+// atomicAdd with a double*; without this definition, nvcc fails to find a
+// matching overload whenever the compile target list includes pre-sm_60
+// architectures. The standard CAS loop implementation below matches the
+// semantics of the native intrinsic.
+#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ < 600
+static __device__ double atomicAdd(double * address, double val) {
+ unsigned long long int * address_as_ull = (unsigned long long int *)address;
+ unsigned long long int old = *address_as_ull;
+ unsigned long long int assumed;
+ do {
+ assumed = old;
+ old = atomicCAS(address_as_ull, assumed,
+ __double_as_longlong(val + __longlong_as_double(assumed)));
+ } while (assumed != old);
+ return __longlong_as_double(old);
+}
+#endif
+
// InnerQ: update the fattn-side inverse scale array from host (all devices)
void turbo_innerq_update_fattn_scales(const float * scale_inv) {
int cur_device;
@@ -0,0 +1,32 @@
Subject: [PATCH] ggml-cuda/argmax: pass WARP_SIZE to the top-K __shfl_xor_sync calls
Two __shfl_xor_sync calls in the top-K intra-warp merge drop the `width`
argument and rely on the CUDA default (warpSize). Every other call in
the same file already passes WARP_SIZE explicitly, and the HIP/ROCm
compatibility shim at ggml/src/ggml-cuda/vendors/hip.h:33 is a 4-arg
function-like macro — so the 3-arg form fails to preprocess when
building with hipcc against ROCm:
argmax.cu:265: error: too few arguments provided to function-like
macro invocation
note: macro '__shfl_xor_sync' defined here:
#define __shfl_xor_sync(mask, var, laneMask, width) \
__shfl_xor(var, laneMask, width)
Align the two call sites with the rest of the file by passing WARP_SIZE
explicitly. On CUDA the generated code is unchanged (warpSize is the
default); on HIP it now matches the macro's arity.
--- a/ggml/src/ggml-cuda/argmax.cu
+++ b/ggml/src/ggml-cuda/argmax.cu
@@ -262,8 +262,8 @@
// Each step: lane gets partner's min element, if it beats our min, replace and re-heapify
for (int offset = WARP_SIZE / 2; offset > 0; offset >>= 1) {
for (int i = 0; i < K; i++) {
- float partner_val = __shfl_xor_sync(0xFFFFFFFF, heap_val[i], offset);
- int partner_idx = __shfl_xor_sync(0xFFFFFFFF, heap_idx[i], offset);
+ float partner_val = __shfl_xor_sync(0xFFFFFFFF, heap_val[i], offset, WARP_SIZE);
+ int partner_idx = __shfl_xor_sync(0xFFFFFFFF, heap_idx[i], offset, WARP_SIZE);
if (partner_val > heap_val[0]) {
heap_val[0] = partner_val;
heap_idx[0] = partner_idx;
@@ -0,0 +1,24 @@
Subject: [PATCH] ggml-cuda/vendors/hip: alias cudaMemcpy{To,From}Symbol to hip counterparts
Buun's Q² calibration + TCQ codebook upload paths in fattn.cu use
cudaMemcpyToSymbol / cudaMemcpyFromSymbol. The HIP-compat header in
ggml/src/ggml-cuda/vendors/hip.h already aliases the scalar cudaMemcpy
family (cudaMemcpy, cudaMemcpyAsync, cudaMemcpy2DAsync, …) but is
missing the symbol variants. Building with hipcc therefore fails with
15+ "use of undeclared identifier 'cudaMemcpyToSymbol'" errors.
Add the two missing aliases alongside the existing memcpy block. HIP
provides hipMemcpy{To,From}Symbol with the same signature as CUDA's
equivalents, so this is a straight name substitution.
--- a/ggml/src/ggml-cuda/vendors/hip.h
+++ b/ggml/src/ggml-cuda/vendors/hip.h
@@ -85,6 +85,8 @@
#define cudaMemcpyDeviceToDevice hipMemcpyDeviceToDevice
#define cudaMemcpyDeviceToHost hipMemcpyDeviceToHost
#define cudaMemcpyHostToDevice hipMemcpyHostToDevice
+#define cudaMemcpyToSymbol hipMemcpyToSymbol
+#define cudaMemcpyFromSymbol hipMemcpyFromSymbol
#define cudaMemcpyKind hipMemcpyKind
#define cudaMemset hipMemset
#define cudaMemsetAsync hipMemsetAsync
@@ -0,0 +1,36 @@
Subject: [PATCH] ggml-cuda/fattn: pass WARP_SIZE to fwht128 __shfl_xor_sync calls
Same issue as the argmax top-K fix: two __shfl_xor_sync call sites in
the FWHT-128 butterfly kernels (ggml_cuda_fwht128 and fwht128_store_half)
use the 3-arg CUDA form and omit the `width` argument that the HIP
function-like macro in vendors/hip.h:33 requires. Hipcc fails with:
fattn.cu:512: too few arguments provided to function-like macro
invocation
note: macro '__shfl_xor_sync' defined here:
#define __shfl_xor_sync(mask, var, laneMask, width) \
__shfl_xor(var, laneMask, width)
Add WARP_SIZE to both calls. CUDA codegen is unchanged (warpSize is the
default); HIP now matches the macro arity.
--- a/ggml/src/ggml-cuda/fattn.cu
+++ b/ggml/src/ggml-cuda/fattn.cu
@@ -509,7 +509,7 @@
// Intra-warp passes: shuffle xor with stride h, no smem, no sync.
#pragma unroll
for (int h = 1; h <= 16; h *= 2) {
- const float other = __shfl_xor_sync(0xFFFFFFFF, val, h);
+ const float other = __shfl_xor_sync(0xFFFFFFFF, val, h, WARP_SIZE);
val = (tid & h) ? (other - val) : (val + other);
}
@@ -533,7 +533,7 @@
static __device__ __forceinline__ void fwht128_store_half(
float val, half * dst_base) {
const int tid = threadIdx.x;
- const float neighbor = __shfl_xor_sync(0xFFFFFFFF, val, 1);
+ const float neighbor = __shfl_xor_sync(0xFFFFFFFF, val, 1, WARP_SIZE);
if ((tid & 1) == 0) {
const half2 packed = __floats2half2_rn(val, neighbor);
*((half2 *)(dst_base + tid)) = packed;
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
set -ex
# Get the absolute current dir where the script is located
CURDIR=$(dirname "$(realpath $0)")
cd /
echo "CPU info:"
grep -e "model\sname" /proc/cpuinfo | head -1
grep -e "flags" /proc/cpuinfo | head -1
BINARY=buun-llama-cpp-fallback
if grep -q -e "\savx\s" /proc/cpuinfo ; then
echo "CPU: AVX found OK"
if [ -e $CURDIR/buun-llama-cpp-avx ]; then
BINARY=buun-llama-cpp-avx
fi
fi
if grep -q -e "\savx2\s" /proc/cpuinfo ; then
echo "CPU: AVX2 found OK"
if [ -e $CURDIR/buun-llama-cpp-avx2 ]; then
BINARY=buun-llama-cpp-avx2
fi
fi
# Check avx 512
if grep -q -e "\savx512f\s" /proc/cpuinfo ; then
echo "CPU: AVX512F found OK"
if [ -e $CURDIR/buun-llama-cpp-avx512 ]; then
BINARY=buun-llama-cpp-avx512
fi
fi
if [ -n "$LLAMACPP_GRPC_SERVERS" ]; then
if [ -e $CURDIR/buun-llama-cpp-grpc ]; then
BINARY=buun-llama-cpp-grpc
fi
fi
# Extend ld library path with the dir where this script is located/lib
if [ "$(uname)" == "Darwin" ]; then
export DYLD_LIBRARY_PATH=$CURDIR/lib:$DYLD_LIBRARY_PATH
else
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
# Tell rocBLAS where to find TensileLibrary data (GPU kernel tuning files)
if [ -d "$CURDIR/lib/rocblas/library" ]; then
export ROCBLAS_TENSILE_LIBPATH=$CURDIR/lib/rocblas/library
fi
fi
# If there is a lib/ld.so, use it
if [ -f $CURDIR/lib/ld.so ]; then
echo "Using lib/ld.so"
echo "Using binary: $BINARY"
exec $CURDIR/lib/ld.so $CURDIR/$BINARY "$@"
fi
echo "Using binary: $BINARY"
exec $CURDIR/$BINARY "$@"
# We should never reach this point, however just in case we do, run fallback
exec $CURDIR/buun-llama-cpp-fallback "$@"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SOURCE="$SCRIPT_DIR/../llama-cpp/grpc-server.cpp"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
cp "$SOURCE" "$TMP_DIR/grpc-server.cpp"
bash "$SCRIPT_DIR/patch-grpc-server.sh" "$TMP_DIR/grpc-server.cpp"
bash "$SCRIPT_DIR/../llama-cpp/disable-score-task.sh" "$TMP_DIR/grpc-server.cpp"
bash "$SCRIPT_DIR/patch-grpc-server.sh" "$TMP_DIR/grpc-server.cpp"
for unsupported in \
'params.cache_idle_slots' \
'params.speculative.types' \
'params.speculative.draft.' \
'common_speculative_types_from_names' \
'COMMON_SPECULATIVE_TYPE_DRAFT_SIMPLE' \
'ctx_server.impl->model_tgt'; do
if grep -Fq "$unsupported" "$TMP_DIR/grpc-server.cpp"; then
echo "unsupported buun API remains: $unsupported" >&2
exit 1
fi
done
grep -Fq '#define LOCALAI_LLAMA_CPP_NO_SCORE_TASK 1' "$TMP_DIR/grpc-server.cpp"
grep -Fq '#define LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP 1' "$TMP_DIR/grpc-server.cpp"
grep -Fq 'params.speculative.mparams_dft.path = request->draftmodel();' "$TMP_DIR/grpc-server.cpp"
grep -Fq 'params.speculative.type = COMMON_SPECULATIVE_TYPE_DRAFT;' "$TMP_DIR/grpc-server.cpp"
grep -Fq 'ctx_server.impl->model' "$TMP_DIR/grpc-server.cpp"
echo "buun grpc-server compatibility transform passed"
+2 -2
View File
@@ -1,10 +1,10 @@
# ds4 backend Makefile.
#
# Upstream pin lives below as DS4_VERSION?=b0309611041655f4e45671cfd9c9886aff161406
# Upstream pin lives below as DS4_VERSION?=84cc882352757baf628a1776badf7cc54d584e28
# (.github/bump_deps.sh) can find and update it - matches the
# llama-cpp / ik-llama-cpp / turboquant convention.
DS4_VERSION?=b0309611041655f4e45671cfd9c9886aff161406
DS4_VERSION?=84cc882352757baf628a1776badf7cc54d584e28
DS4_REPO?=https://github.com/antirez/ds4
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+1 -1
View File
@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=cf1aa57e1a0fabfd015831718fc99d1aec01ada5
IK_LLAMA_VERSION?=a7c81affa48c6800d63111bdb33469a01d062daa
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=
+1 -1
View File
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
CRISPASR_VERSION?=21901d3f7c23554f072964828363e49ddbc2dc68
CRISPASR_VERSION?=17a6cc99422bfafadf7161e96dd7294c89da9c36
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
+15 -4
View File
@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
# vllm.cpp version
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
VLLM_CPP_VERSION?=0757cac231ecd571a83c4fd2f50805c9251fc225
VLLM_CPP_VERSION?=2b08dd246e04b3f0a4bf1f276170fd28004ced01
# MLX GEMM provider (darwin/metal only; see the metal branch below for why).
# Consumed as the prebuilt pip wheel: building MLX from source needs `xcrun
@@ -106,13 +106,24 @@ else
LIB=libvllm.so
endif
sources/vllm.cpp:
# patches/ carries fixes the pinned engine SHA does not have yet. `git apply`
# is deliberately unguarded: a patch that no longer applies must FAIL the clone
# loudly, because the alternative is a pin that silently ships without a fix it
# is documented to carry. Each patch header says which pin retires it.
VLLM_CPP_PATCHES=$(wildcard patches/*.patch)
sources/vllm.cpp: $(VLLM_CPP_PATCHES)
rm -rf sources/vllm.cpp
mkdir -p sources/vllm.cpp
cd sources/vllm.cpp && \
git init && \
git remote add origin $(VLLM_CPP_REPO) && \
git fetch --depth 1 origin $(VLLM_CPP_VERSION) && \
git checkout FETCH_HEAD
git checkout FETCH_HEAD && \
for p in $(VLLM_CPP_PATCHES); do \
echo "==> applying $$p"; \
git apply ../../$$p || exit 1; \
done
ifeq ($(MLX_ENABLED),1)
# A stamp FILE, not a phony target: a phony prerequisite is always "newer" than
@@ -165,7 +176,7 @@ $(LIB): sources/vllm.cpp $(MLX_STAMP)
cmake --build . --config Release -j$(JOBS) --target vllm_shared
cp -fL build/$(LIB) ./$(LIB)
vllm-cpp: main.go govllmcpp.go backend.go options.go $(LIB)
vllm-cpp: main.go govllmcpp.go backend.go chat.go options.go video.go $(LIB)
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o vllm-cpp ./
package: vllm-cpp
+73 -2
View File
@@ -1,12 +1,15 @@
# vllm-cpp backend
LocalAI text-generation backend for [vllm.cpp](https://github.com/mudler/vllm.cpp),
LocalAI backend for [vllm.cpp](https://github.com/mudler/vllm.cpp),
the LocalAI-team C++20 port of vLLM (paged KV cache, continuous batching,
safetensors + GGUF loading, CUDA / CPU / Metal / Vulkan) with no Python at
inference time.
It serves two things: text generation, and MiniMax-H3 joint video+audio
generation.
The backend dlopens the engine's stable C ABI (`libvllm`, `include/vllm.h`,
ABI v10) through purego:
ABI v16) through purego:
- `Load` -> `vllm_engine_load`: accepts a `.gguf` file or a HF-style model
directory (`config.json` + safetensors). `context_size` maps to
@@ -29,6 +32,12 @@ ABI v10) through purego:
LocalAI's Go-side grammar-constrained tool calling; JSON-schema / regex /
choice constraints are also exposed by the ABI.
`patches/` carries fixes the pinned engine SHA does not have yet, applied to
the clone the same way `longcat-video` patches its upstream. `git apply` is
unguarded on purpose: a patch that stops applying must fail the clone loudly
rather than leave a pin silently missing a fix it is documented to carry. Each
patch header says what retires it.
The struct mirrors in `govllmcpp.go` are hand-written against one ABI version,
and the engine refuses to load against any other. Moving `VLLM_CPP_VERSION` in
the Makefile therefore means updating `abiVersion` plus the mirrors (and their
@@ -47,6 +56,68 @@ options:
- max_num_seqs:16
```
## MiniMax-H3 video+audio generation
`GenerateVideo` -> `vllm_video_generate` (ABI v12). H3 renders picture and sound
together, so the output MP4 carries a real AAC track.
The video engine is a SECOND handle (`vllm_video_engine`), not a mode of the
text one, because H3 is a checkpoint SET rather than a model directory: the DiT,
the text encoder and two VAEs are separate artifacts, and vllm.cpp has the two
loaders refuse each other's checkpoints. `Load` takes the video branch when the
model config carries any of the video options below; `parameters.model` is the
DiT and everything else is named in `options:`.
```yaml
name: minimax-h3-fl2va-q4
backend: vllm-cpp
cuda: true
known_usecases: [video]
parameters:
model: minimax-h3/MiniMax-H3-FL2VA-Q4_K_M.gguf
options:
- video_encoder:minimax-h3/qwen3vl-32B-MiniMax-H3-Q4_K_M.gguf
- video_tokenizer:minimax-h3/tokenizer.json
- video_vae:minimax-h3/video_vae.safetensors
- video_vae_config:minimax-h3/video_vae_config.json
- audio_vae:minimax-h3/audio_vae.safetensors
- audio_vae_config:minimax-h3/audio_vae_config.json
- video_partition:fl2va
- video_device:cuda
- video_dequant_bf16:true
- video_width:1344
- video_height:768
- video_num_frames:124
```
Three things are worth knowing before touching this path.
**The partition is declared, not detected, and a mismatch does not fail
cleanly.** The FL2VA DiT serves `t2va` and `fl2va`; `ref2va` is a different
checkpoint. The community GGUF/NVFP4 quantisations strip the release metadata
and the two DiTs are byte-structurally identical, so the engine refuses every
generate until `video_partition` says which one it has. Handing reference
conditioning to an FL2VA DiT renders for hours and returns a coloured lattice
over the frame, so `checkPartitionConditioning` refuses that combination here,
before the engine is called.
**ffmpeg comes from the host.** libvllm writes the frames and the WAV and
COMPOSES the mux argv, then spawns nothing — that process boundary is upstream's
decision. `muxVideo` takes the composed argv, substitutes `argv[0]` with the
resolved binary and execs it; the backend image is `FROM scratch` and carries no
ffmpeg, the same arrangement `vibevoice-cpp` uses for transcoding. ffmpeg also
converts a `start_image`/`end_image` upload into the binary PPM at the exact
output canvas the engine requires, since libvllm vendors neither an image codec
nor a resampler.
**It is slow.** Roughly 176 s per denoise step at 1344x768 on a 20-SM device, so
the 50-step default is hours. Nothing here imposes a deadline.
Geometry mirrors the engine so the two agree: the canvas is truncated onto a
32-pixel grid, the frame count sits on the 17n+5 grid, and an unspecified canvas
with a keyframe is derived from that image's aspect on a 768-pixel short edge
(`MiniMaxH3ResolveShape`, `minimax_h3_planner.cpp`).
## Apple Silicon: the MLX GEMM provider (ON by default, gated to prefill)
`BUILD_TYPE=metal` builds vllm.cpp's MLX provider for the dense GEMM
+18 -1
View File
@@ -28,7 +28,12 @@ type VllmCpp struct {
base.Base
engine uintptr
opts loadOptions
// videoEngine is the MiniMax-H3 handle (ABI v12). It is deliberately a
// SECOND handle, not a mode of the first: H3 is a checkpoint set rather
// than a model directory, and vllm.cpp has the two loaders refuse each
// other's checkpoints. Exactly one of the two is ever non-zero.
videoEngine uintptr
opts loadOptions
}
// Stream registry: the per-request bridge between the C token callback and
@@ -109,6 +114,14 @@ func (v *VllmCpp) Load(opts *pb.ModelOptions) error {
v.opts = parseOptions(opts)
// MiniMax-H3 is a checkpoint SET behind its own engine handle, so the
// branch is taken before any text-engine knob is resolved. The two loaders
// refuse each other's checkpoints, which is why this is decided from the
// config rather than probed.
if v.opts.video.engaged() {
return v.loadVideo(opts, model)
}
// A DFlash draft is a second checkpoint the engine opens by path, and the
// engine never downloads one. Resolve it against LocalAI's models directory
// now so a repo-id spelling works, and so a missing draft fails here with an
@@ -194,6 +207,10 @@ func (v *VllmCpp) Free() error {
vllmEngineFree(v.engine)
v.engine = 0
}
if v.videoEngine != 0 {
vllmVideoEngineFree(v.videoEngine)
v.videoEngine = 0
}
return nil
}
+114 -3
View File
@@ -1,6 +1,6 @@
package main
// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v10).
// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v16).
//
// The structs below are hand-mirrored PODs of the C declarations, with
// explicit padding so the Go layout matches the C layout on linux/darwin
@@ -21,7 +21,7 @@ import (
// the header of the VLLM_CPP_VERSION pinned in the Makefile: the build checks
// the two against each other, because a mismatch is only caught at runtime by
// registerLib, where it takes the backend down on every load (issue #11379).
const abiVersion = 10
const abiVersion = 17
// The ABI's tri-state toggles (enable_prefix_caching ABI v7,
// enable_jump_forward ABI v10) share one encoding: 0 is NOT "off", it is
@@ -70,7 +70,15 @@ type cModelParams struct {
SchedulingPolicy uintptr // const char*; NULL = "fcfs" (ABI v9)
KVTransferConfig uintptr // const char* JSON; NULL = no connector (ABI v9)
EnableJumpForward int32 // tri-state 0/1/2 (ABI v10)
_ [4]byte // trailing pad to the struct's 8-byte alignment
// v14/v16 tail. LocalAI sets none of these (0 is "auto" for the device and
// "unset" for both sizing knobs, i.e. the pre-v14 engine byte for byte), but
// the fields MUST be mirrored: the C side reads sizeof(vllm_model_params)
// bytes off the pointer we hand it, so a Go struct that stopped at
// EnableJumpForward would have vllm_engine_load read 24 bytes past our
// allocation and size the KV pool from whatever sat there.
Device int32 // 0 auto, 1 cpu, 2 cuda (ABI v14)
GPUMemoryUtil float64 // 0 => 0.92 (ABI v16)
KVCacheMemoryBytes int64 // 0 => unset (ABI v16)
}
// cSamplingParams mirrors vllm_sampling_params (structured fields included).
@@ -117,6 +125,79 @@ type cCompletion struct {
CompletionTokens int32
}
// ── Video+audio generation (ABI v12, MiniMax-H3) ────────────────────────────
//
// A video engine is a SEPARATE handle from vllm_engine: H3 is a checkpoint SET
// (DiT + text encoder + two VAEs), not one model directory, and the two loaders
// refuse each other's checkpoints on purpose. Offsets are asserted in
// video_test.go the same way the text PODs are in vllmcpp_test.go.
// cVideoModelParams mirrors vllm_video_model_params. Nine pointers then three
// int32s, so only the trailing pad is implicit.
type cVideoModelParams struct {
DitPath uintptr // const char*
EncoderPath uintptr // const char*
TokenizerPath uintptr // const char*
VideoVaePath uintptr // const char*
VideoVaeConfigPath uintptr // const char*
AudioVaePath uintptr // const char*
AudioVaeConfigPath uintptr // const char*
PromptEmbedsPath uintptr // const char*
Partition uintptr // const char*; "fl2va" | "ref2va", REQUIRED
Device int32 // 0 cpu, 1 cuda
DequantBf16 int32 // 0 keep-quant, 1 dequant/stream bf16
Fp4Resident int32 // NVFP4+cuda: keep FP4 packed, Marlin W4A16
_ [4]byte // trailing pad to the struct's 8-byte alignment
}
// cVideoParams mirrors vllm_video_params. `width`/`height` and `num_frames`/
// `steps` pair up into 8-byte slots; the uint64 seed forces the alignment after
// them, and the float noise_aug leaves a pad before output_dir.
type cVideoParams struct {
Prompt uintptr // const char*
Width int32
Height int32
NumFrames int32 // <= 1 => per-task default (124 for t2va/fl2va)
Steps int32 // <= 0 => the H3 default (50)
Seed uint64
HasSeed int32
_ [4]byte
FirstFrame uintptr // const char*; fl2va keyframe, binary PPM (P6)
LastFrame uintptr // const char*
RefImage uintptr // const char*; ref2va only
RefVideo uintptr // const char*; ref2va only, a frame_%06d.ppm DIRECTORY
RefAudio uintptr // const char*; ref2va only, 16-bit PCM WAV
NoiseAug float32 // <= 0 => 1.0
_ [4]byte
OutputDir uintptr // const char*; REQUIRED
}
// cVideoResult mirrors vllm_video_result. Every member is library-allocated and
// released together by vllm_video_result_free.
type cVideoResult struct {
FrameDir uintptr // char*, holds frame_%06d.ppm
AudioPath uintptr // char*, 16-bit PCM WAV
FrameCount int32
Width int32
Height int32
Fps int32
SampleRate int32
_ [4]byte
MuxArgv uintptr // char**, NULL-terminated at MuxArgc
MuxArgc int32
_ [4]byte
}
// cVideoMuxParams mirrors vllm_video_mux_params. The library composes the argv;
// spawning it is the CALLER's job, which is why no ffmpeg lives in libvllm.
type cVideoMuxParams struct {
Frames uintptr // const char*; printf pattern, dir/frame_%06d.ppm
AudioPath uintptr // const char*; NULL/empty => a silent clip
OutputPath uintptr // const char*; the .mp4 to write
Fps int32 // <= 0 => the H3 default (24)
Crf int32 // <= 0 => the library default (18)
}
// defaultSamplingParams mirrors vllm_sampling_params_default().
func defaultSamplingParams() cSamplingParams {
return cSamplingParams{
@@ -148,6 +229,14 @@ var (
vllmLastError func() string
vllmVersion func() string
vllmABIVersion func() int32
// Video+audio generation (ABI v12).
vllmVideoEngineLoad func(params, out unsafe.Pointer) int32
vllmVideoEngineFree func(engine uintptr)
vllmVideoGenerate func(engine uintptr, params, out unsafe.Pointer) int32
vllmVideoResultFree func(out unsafe.Pointer)
vllmVideoMuxArgv func(params, outArgv, outArgc unsafe.Pointer) int32
vllmVideoMuxArgvFre func(argv uintptr, argc int32)
)
type libFunc struct {
@@ -175,6 +264,12 @@ func registerLib(libName string) error {
{&vllmLastError, "vllm_last_error"},
{&vllmVersion, "vllm_version"},
{&vllmABIVersion, "vllm_abi_version"},
{&vllmVideoEngineLoad, "vllm_video_engine_load"},
{&vllmVideoEngineFree, "vllm_video_engine_free"},
{&vllmVideoGenerate, "vllm_video_generate"},
{&vllmVideoResultFree, "vllm_video_result_free"},
{&vllmVideoMuxArgv, "vllm_video_mux_argv"},
{&vllmVideoMuxArgvFre, "vllm_video_mux_argv_free"},
} {
purego.RegisterLibFunc(lf.ptr, lib, lf.name)
}
@@ -222,3 +317,19 @@ func goString(p uintptr) string {
}
return string(unsafe.Slice((*byte)(base), n))
}
// goStringSlice copies a C `char*` array of n entries. Used for the ffmpeg argv
// the library composes: it is copied out immediately so the caller can free the
// C allocation before ever spawning the process.
func goStringSlice(p uintptr, n int32) []string {
if p == 0 || n <= 0 {
return nil
}
//nolint:govet // C-owned pointer handed over by purego, valid for this call
entries := unsafe.Slice((**byte)(unsafe.Pointer(p)), int(n)) // #nosec G103 -- C-owned, copied out immediately
out := make([]string, 0, n)
for _, e := range entries {
out = append(out, goString(uintptr(unsafe.Pointer(e)))) // #nosec G103 -- ditto
}
return out
}
+147
View File
@@ -62,6 +62,66 @@ type loadOptions struct {
// Override for the tokenizer_config.json the chat template is read from
// (ABI v9). Empty = <model_dir>/tokenizer_config.json.
tokenizerConfigPath string
// MiniMax-H3 video+audio generation (ABI v12). Present only when the config
// carries at least one of its keys; see videoOptions.engaged.
video videoOptions
}
// videoOptions is the MiniMax-H3 checkpoint SET plus its generation defaults.
//
// H3 is not one model directory: the DiT, the text encoder and the two VAEs are
// separate artifacts, which is why vllm.cpp gives video its own engine handle
// (vllm_video_engine, ABI v12) rather than another vllm_engine. The DiT is the
// model config's `parameters.model`; everything else arrives through these
// options, so one gallery entry can name five files.
//
// The geometry/frame defaults exist because H3's trained canvas is nothing like
// the generic /video defaults: 1344x768 at 124 frames is a ~5.2 s clip, and the
// frame count must sit on the 17n+5 grid. A request that leaves a field unset
// gets the model's own default from here instead of a canvas the checkpoint was
// never trained at.
type videoOptions struct {
encoderPath string // H3-Encoder GGUF or bf16 shard dir
tokenizerPath string // tokenizer.json, needed with an encoder
videoVaePath string
videoVaeConfig string
audioVaePath string
audioVaeConfig string
promptEmbedsPath string // fallback conditioning when there is no encoder
// The served checkpoint PARTITION. Community GGUF/NVFP4 files strip the
// release metadata and the FL2VA/Ref2VA DiTs are byte-structurally
// identical, so the engine refuses every generate until it is DECLARED.
// "fl2va" serves t2va + fl2va; "ref2va" serves reference conditioning.
partition string
device int32 // 0 cpu, 1 cuda (the ABI's own encoding, no auto slot)
deviceSet bool
dequantBf16 int32
fp4Resident int32
// Per-model generation defaults, applied when the request leaves the field
// at 0.
width int32
height int32
numFrames int32
steps int32
// Where frames + WAV are written. Empty = a temporary directory beside the
// requested output, removed once the mux succeeds. Set it to keep the
// frame_%06d.ppm runs around (they are what ref2va's ref_video consumes).
workdir string
// The ffmpeg binary the composed mux argv is exec'd with. Empty = "ffmpeg"
// from PATH. libvllm composes the argv and spawns nothing, by design.
ffmpeg string
crf int32
}
// engaged reports whether this config describes an H3 video engine. Load uses
// it to choose which of the two mutually exclusive engine handles to open: the
// checkpoints refuse each other, so guessing is not an option, and every key
// below is meaningless to the text engine.
func (v videoOptions) engaged() bool {
return v.encoderPath != "" || v.tokenizerPath != "" ||
v.videoVaePath != "" || v.videoVaeConfig != "" ||
v.audioVaePath != "" || v.audioVaeConfig != "" ||
v.promptEmbedsPath != "" || v.partition != ""
}
func parseOptions(opts *pb.ModelOptions) loadOptions {
@@ -110,10 +170,94 @@ func applyOptionsList(lo *loadOptions, options []string) {
if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil {
lo.enableJumpForward = boolTriState(b)
}
default:
applyVideoOption(&lo.video, strings.TrimSpace(k), v)
}
}
}
// applyVideoOption reads one MiniMax-H3 key. Split out of applyOptionsList so
// the video surface stays legible next to the videoOptions it fills, and so
// video_test.go can exercise it directly.
func applyVideoOption(vo *videoOptions, key, value string) bool {
v := strings.TrimSpace(value)
switch key {
case "video_encoder":
vo.encoderPath = v
case "video_tokenizer":
vo.tokenizerPath = v
case "video_vae":
vo.videoVaePath = v
case "video_vae_config":
vo.videoVaeConfig = v
case "audio_vae":
vo.audioVaePath = v
case "audio_vae_config":
vo.audioVaeConfig = v
case "video_prompt_embeds":
vo.promptEmbedsPath = v
case "video_partition":
vo.partition = strings.ToLower(v)
case "video_device":
switch strings.ToLower(v) {
case "cpu":
vo.device, vo.deviceSet = videoDeviceCPU, true
case "cuda", "gpu":
vo.device, vo.deviceSet = videoDeviceCUDA, true
default:
xlog.Warn("[vllm-cpp] ignoring unknown video_device", "value", v)
}
case "video_dequant_bf16":
if b, err := strconv.ParseBool(v); err == nil {
vo.dequantBf16 = boolInt32(b)
}
case "video_fp4_resident":
if b, err := strconv.ParseBool(v); err == nil {
vo.fp4Resident = boolInt32(b)
}
case "video_width":
vo.width = parseInt32(v, vo.width)
case "video_height":
vo.height = parseInt32(v, vo.height)
case "video_num_frames":
vo.numFrames = parseInt32(v, vo.numFrames)
case "video_steps":
vo.steps = parseInt32(v, vo.steps)
case "video_workdir":
vo.workdir = v
case "video_crf":
vo.crf = parseInt32(v, vo.crf)
case "ffmpeg", "ffmpeg_path":
vo.ffmpeg = v
default:
return false
}
return true
}
// videoScalarString renders an engine_args scalar so the video keys can share
// one parser with the "key:value" list. Objects and arrays have no video
// meaning and are left to the caller's unknown-key path.
func videoScalarString(v any) (string, bool) {
switch t := v.(type) {
case string:
return t, true
case bool:
return strconv.FormatBool(t), true
case float64:
return strconv.FormatFloat(t, 'f', -1, 64), true
default:
return "", false
}
}
func boolInt32(b bool) int32 {
if b {
return 1
}
return 0
}
// applyEngineArgs overlays the `engine_args:` JSON object. A document that does
// not parse is logged and skipped: engine_args is shared with the other engines
// (the vLLM and SGLang backends read the same field), so a stray key must not
@@ -160,6 +304,9 @@ func applyEngineArgs(lo *loadOptions, engineArgs string) {
lo.enableJumpForward = boolTriState(b)
}
default:
if s, ok := videoScalarString(v); ok && applyVideoOption(&lo.video, k, s) {
continue
}
xlog.Debug("[vllm-cpp] ignoring unknown engine_args key", "key", k)
}
}
+634
View File
@@ -0,0 +1,634 @@
package main
// MiniMax-H3 video+audio generation over the vllm.cpp C ABI (v12).
//
// Two things make this different from the text path, and both come from the
// engine's own shape rather than from LocalAI:
//
// 1. A video engine is loaded from a checkpoint SET - the DiT, the text
// encoder and two VAEs are separate artifacts - so it is its own handle
// (vllm_video_engine) and its own Load branch. The two loaders refuse each
// other's checkpoints on purpose.
// 2. libvllm writes frames + a WAV and COMPOSES the ffmpeg argv, but spawns
// nothing. That process boundary is deliberate upstream, so the mux lives
// here: we take the composed argv, substitute argv[0], and exec it. ffmpeg
// comes from PATH the same way the vibevoice-cpp backend takes it.
//
// Generation is SLOW - roughly 176 s per denoise step at 1344x768 on a 20-SM
// device, so a default 50-step render is hours, not seconds. Nothing here
// imposes a deadline: GenerateVideo blocks for as long as the engine needs and
// the gRPC call carries LocalAI's application context.
import (
"fmt"
"image"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"unsafe"
// Registered for image.DecodeConfig only: a staged keyframe arrives as
// whatever the caller uploaded, and we need its geometry to size the canvas.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
)
// vllm_video_model_params.device (vllm.h): no auto slot, unlike the text
// engine's v14 device field.
const (
videoDeviceCPU int32 = 0
videoDeviceCUDA int32 = 1
)
// H3's shipped geometry. The canvas is truncated onto a 32-pixel grid and the
// frame count onto the 17n+5 grid by the engine itself
// (MiniMaxH3ResolveShape / MiniMaxH3AlignFrameCount in
// src/vllm/model_executor/models/minimax_h3_planner.cpp); mirrored here only so
// a keyframe can be resampled to the exact canvas the engine will render at.
const (
h3CanvasMultiple int32 = 32
h3FrameGrid int32 = 17
h3FrameOffset int32 = 5
h3ShortEdge int32 = 768
)
// videoPartitions are the two DECLARED partitions of the H3 release. The FL2VA
// checkpoint serves t2va and fl2va; ref2va is a different checkpoint. Passing
// reference conditioning against an fl2va DiT is a partition mismatch that
// renders a coloured lattice over the frame rather than failing cleanly, which
// is why it is refused here before the engine is ever called.
const (
partitionFL2VA = "fl2va"
partitionRef2VA = "ref2va"
)
// videoRequestParams are the per-request `params` keys this backend accepts.
// Unknown keys are an error rather than a silent drop: a misspelled reference
// path would otherwise produce a perfectly successful render of the wrong
// thing, hours later.
var videoRequestParams = []string{"noise_aug", "ref_image", "ref_video", "crf"}
// loadVideo opens the H3 checkpoint set. `dit` is the model config's
// parameters.model; every other artifact comes from the options.
func (v *VllmCpp) loadVideo(opts *pb.ModelOptions, dit string) error {
vo := &v.opts.video
// Relative option paths resolve against LocalAI's models directory, which
// is where the gallery lands the five H3 files.
resolve := func(p string) string {
if p == "" || filepath.IsAbs(p) || opts.ModelPath == "" {
return p
}
return filepath.Join(opts.ModelPath, p)
}
vo.encoderPath = resolve(vo.encoderPath)
vo.tokenizerPath = resolve(vo.tokenizerPath)
vo.videoVaePath = resolve(vo.videoVaePath)
vo.videoVaeConfig = resolve(vo.videoVaeConfig)
vo.audioVaePath = resolve(vo.audioVaePath)
vo.audioVaeConfig = resolve(vo.audioVaeConfig)
vo.promptEmbedsPath = resolve(vo.promptEmbedsPath)
vo.workdir = resolve(vo.workdir)
// A VAE config carries the per-channel latents_mean/latents_std and the
// temporal clip_length/token_drop; decode is wrong without it. The release
// ships it beside the weights, so default to that rather than making every
// config repeat it.
if vo.videoVaeConfig == "" && vo.videoVaePath != "" {
vo.videoVaeConfig = siblingConfigJSON(vo.videoVaePath)
}
if vo.audioVaeConfig == "" && vo.audioVaePath != "" {
vo.audioVaeConfig = siblingConfigJSON(vo.audioVaePath)
}
if vo.partition == "" {
// The community GGUF/NVFP4 quantisations strip the release metadata and
// the two DiTs are byte-structurally identical, so the engine cannot
// infer this and refuses every generate until it is declared. The
// shipped FL2VA checkpoint is the one the gallery entry installs.
vo.partition = partitionFL2VA
xlog.Warn("[vllm-cpp] video partition not declared, assuming the FL2VA checkpoint",
"hint", "set options: [video_partition:fl2va] or [video_partition:ref2va] to match the DiT you installed")
}
if vo.partition != partitionFL2VA && vo.partition != partitionRef2VA {
return fmt.Errorf("vllm-cpp: video_partition must be %q or %q, got %q",
partitionFL2VA, partitionRef2VA, vo.partition)
}
if vo.videoVaePath == "" || vo.audioVaePath == "" {
return fmt.Errorf("vllm-cpp: MiniMax-H3 needs both VAEs: set options: " +
"[video_vae:<video vae .safetensors>, audio_vae:<audio vae .safetensors>]")
}
if vo.encoderPath == "" && vo.promptEmbedsPath == "" {
return fmt.Errorf("vllm-cpp: MiniMax-H3 needs text conditioning: set options: " +
"[video_encoder:<encoder .gguf>, video_tokenizer:<tokenizer.json>] " +
"or [video_prompt_embeds:<f32 embeddings>]")
}
if !vo.deviceSet && opts.GetCUDA() {
vo.device = videoDeviceCUDA
}
mp := cVideoModelParams{
Device: vo.device,
DequantBf16: vo.dequantBf16,
Fp4Resident: vo.fp4Resident,
}
var keep [][]byte
setStr := func(dst *uintptr, s string) {
if s == "" {
return
}
b := cString(s)
keep = append(keep, b)
*dst = uintptr(unsafe.Pointer(&b[0])) // #nosec G103 -- borrowed by C for the load call only
}
setStr(&mp.DitPath, dit)
setStr(&mp.EncoderPath, vo.encoderPath)
setStr(&mp.TokenizerPath, vo.tokenizerPath)
setStr(&mp.VideoVaePath, vo.videoVaePath)
setStr(&mp.VideoVaeConfigPath, vo.videoVaeConfig)
setStr(&mp.AudioVaePath, vo.audioVaePath)
setStr(&mp.AudioVaeConfigPath, vo.audioVaeConfig)
setStr(&mp.PromptEmbedsPath, vo.promptEmbedsPath)
setStr(&mp.Partition, vo.partition)
xlog.Info("[vllm-cpp] Load (MiniMax-H3 video)", "dit", dit, "engine", vllmVersion(),
"encoder", vo.encoderPath, "tokenizer", vo.tokenizerPath,
"videoVae", vo.videoVaePath, "audioVae", vo.audioVaePath,
"partition", vo.partition, "device", videoDeviceName(vo.device),
"dequantBf16", vo.dequantBf16 == 1, "fp4Resident", vo.fp4Resident == 1)
var engine uintptr
rc := vllmVideoEngineLoad(unsafe.Pointer(&mp), unsafe.Pointer(&engine)) // #nosec G103 -- POD out-params
runtime.KeepAlive(keep)
if rc != vllmOK {
return fmt.Errorf("vllm-cpp: video engine load failed: %s", vllmLastError())
}
v.videoEngine = engine
return nil
}
// GenerateVideo renders one clip and muxes it to opts.Dst as an MP4 carrying
// H3's jointly generated AAC audio track. It blocks for the whole render.
func (v *VllmCpp) GenerateVideo(opts *pb.GenerateVideoRequest) error {
if v.videoEngine == 0 {
return fmt.Errorf("vllm-cpp: this model is not a MiniMax-H3 video engine " +
"(load it with the video_vae / audio_vae / video_encoder options)")
}
if strings.TrimSpace(opts.GetPrompt()) == "" {
return fmt.Errorf("vllm-cpp: video generation needs a prompt")
}
dst := opts.GetDst()
if dst == "" {
return fmt.Errorf("vllm-cpp: video generation needs an output path")
}
vo := v.opts.video
extra, err := parseVideoRequestParams(opts.GetParams())
if err != nil {
return err
}
if err := checkPartitionConditioning(vo.partition, opts, extra); err != nil {
return err
}
if opts.GetNegativePrompt() != "" {
xlog.Warn("[vllm-cpp] MiniMax-H3 has no negative prompt; ignoring it")
}
if opts.GetCfgScale() != 0 {
xlog.Warn("[vllm-cpp] MiniMax-H3 has no classifier-free guidance scale; ignoring cfg_scale")
}
workdir, cleanup, err := v.videoWorkdir(dst)
if err != nil {
return err
}
defer cleanup()
width, height := firstPositive(opts.GetWidth(), vo.width), firstPositive(opts.GetHeight(), vo.height)
frames := firstPositive(opts.GetNumFrames(), vo.numFrames)
steps := firstPositive(opts.GetStep(), vo.steps)
vp := cVideoParams{
NumFrames: frames,
Steps: steps,
NoiseAug: extra.noiseAug,
}
if opts.GetSeed() > 0 {
vp.Seed = uint64(opts.GetSeed())
vp.HasSeed = 1
}
if aligned := alignFrameCount(frames); aligned != frames {
xlog.Warn("[vllm-cpp] frame count is not on H3's 17n+5 grid; the engine rounds up",
"requested", frames, "rendered", aligned)
}
// Keyframes must be binary PPM (P6) at the exact output canvas: no image
// codec and no resampler is vendored in libvllm. Resolve the canvas first,
// then stage the frames through ffmpeg into it.
//
// The REQUEST's geometry is what is honoured here, not the model-level
// default: that default is a t2va canvas, and applying it to a keyframe
// would stretch a portrait photo into a 1344x768 letterbox. With no
// requested geometry the canvas comes from the keyframe's own aspect, which
// is the rule the engine itself applies (MiniMaxH3ResolveShape).
first, last := opts.GetStartImage(), opts.GetEndImage()
if first != "" || last != "" {
width, height, err = resolveCanvas(opts.GetWidth(), opts.GetHeight(), first, last)
if err != nil {
return err
}
if first, err = stageKeyframe(vo.ffmpeg, first, width, height, workdir, "first"); err != nil {
return err
}
if last, err = stageKeyframe(vo.ffmpeg, last, width, height, workdir, "last"); err != nil {
return err
}
}
vp.Width, vp.Height = truncateToGrid(width), truncateToGrid(height)
var keep [][]byte
setStr := func(dst *uintptr, s string) {
if s == "" {
return
}
b := cString(s)
keep = append(keep, b)
*dst = uintptr(unsafe.Pointer(&b[0])) // #nosec G103 -- borrowed by C for the call only
}
setStr(&vp.Prompt, opts.GetPrompt())
setStr(&vp.OutputDir, workdir)
setStr(&vp.FirstFrame, first)
setStr(&vp.LastFrame, last)
setStr(&vp.RefImage, extra.refImage)
setStr(&vp.RefVideo, extra.refVideo)
setStr(&vp.RefAudio, opts.GetAudio())
xlog.Info("[vllm-cpp] GenerateVideo", "dst", dst, "workdir", workdir,
"width", vp.Width, "height", vp.Height, "frames", vp.NumFrames,
"steps", vp.Steps, "seeded", vp.HasSeed == 1, "partition", vo.partition)
var out cVideoResult
rc := vllmVideoGenerate(v.videoEngine, unsafe.Pointer(&vp), unsafe.Pointer(&out)) // #nosec G103 -- POD in/out params
runtime.KeepAlive(keep)
if rc != vllmOK {
return fmt.Errorf("vllm-cpp: video generation failed: %s", vllmLastError())
}
defer vllmVideoResultFree(unsafe.Pointer(&out)) // #nosec G103 -- frees the library-owned members
frameDir, audioPath := goString(out.FrameDir), goString(out.AudioPath)
xlog.Info("[vllm-cpp] rendered", "frames", out.FrameCount,
"width", out.Width, "height", out.Height, "fps", out.Fps,
"audio", audioPath, "sampleRate", out.SampleRate)
if opts.GetFps() > 0 && opts.GetFps() != out.Fps {
// Muxing at any other rate desynchronises the jointly generated audio.
xlog.Warn("[vllm-cpp] MiniMax-H3 renders at a fixed frame rate; ignoring the requested fps",
"requested", opts.GetFps(), "rendered", out.Fps)
}
return v.muxVideo(frameDir, audioPath, dst, out.Fps, extra.crf)
}
// muxVideo execs the argv libvllm composed. The encoding contract (h264 /
// yuv420p + AAC, -shortest, +faststart) belongs to the library; only the spawn
// is ours.
func (v *VllmCpp) muxVideo(frameDir, audioPath, dst string, fps, crf int32) error {
mx := cVideoMuxParams{Fps: fps, Crf: crf}
var keep [][]byte
setStr := func(dst *uintptr, s string) {
if s == "" {
return
}
b := cString(s)
keep = append(keep, b)
*dst = uintptr(unsafe.Pointer(&b[0])) // #nosec G103 -- borrowed by C for the call only
}
setStr(&mx.Frames, filepath.Join(frameDir, "frame_%06d.ppm"))
setStr(&mx.AudioPath, audioPath)
setStr(&mx.OutputPath, dst)
var argvPtr uintptr
var argc int32
rc := vllmVideoMuxArgv(unsafe.Pointer(&mx), unsafe.Pointer(&argvPtr), unsafe.Pointer(&argc)) // #nosec G103 -- POD out-params
runtime.KeepAlive(keep)
if rc != vllmOK {
return fmt.Errorf("vllm-cpp: composing the mux command failed: %s", vllmLastError())
}
argv := goStringSlice(argvPtr, argc)
vllmVideoMuxArgvFre(argvPtr, argc)
if len(argv) == 0 {
return fmt.Errorf("vllm-cpp: the library composed an empty mux command")
}
ffmpegBin, err := resolveFfmpeg(v.opts.video.ffmpeg)
if err != nil {
return err
}
argv[0] = ffmpegBin
xlog.Debug("[vllm-cpp] muxing", "argv", argv)
output, err := exec.Command(argv[0], argv[1:]...).CombinedOutput() // #nosec G204 -- argv is composed by libvllm, argv[0] is a resolved binary
if err != nil {
return fmt.Errorf("vllm-cpp: ffmpeg mux failed: %w (output: %s)", err, strings.TrimSpace(string(output)))
}
return nil
}
// resolveFfmpeg locates the mux binary. The backend image is FROM scratch and
// carries no ffmpeg, exactly like vibevoice-cpp's transcode path: the host must
// provide one, and saying so plainly beats a bare "exec: not found" after an
// hours-long render.
func resolveFfmpeg(configured string) (string, error) {
name := configured
if name == "" {
name = "ffmpeg"
}
path, err := exec.LookPath(name)
if err != nil {
return "", fmt.Errorf("vllm-cpp: %q not found: MiniMax-H3 output is muxed with ffmpeg, "+
"install it on the host or point options: [ffmpeg:<path>] at a binary: %w", name, err)
}
return path, nil
}
// videoWorkdir returns the directory the engine writes frame_%06d.ppm and
// audio.wav into, plus its cleanup.
//
// It is ALWAYS a fresh directory. Reusing one would leave a longer previous
// run's trailing frames in place for the mux to pick up, silently splicing two
// renders together. With video_workdir set the run is kept (its frames are what
// ref2va's ref_video consumes); otherwise it is removed once the mux succeeds.
func (v *VllmCpp) videoWorkdir(dst string) (string, func(), error) {
parent := v.opts.video.workdir
keep := parent != ""
if parent == "" {
parent = filepath.Dir(dst)
}
if err := os.MkdirAll(parent, 0o750); err != nil {
return "", nil, fmt.Errorf("vllm-cpp: creating the video work directory: %w", err)
}
dir, err := os.MkdirTemp(parent, "vllm-cpp-h3-")
if err != nil {
return "", nil, fmt.Errorf("vllm-cpp: creating the video work directory: %w", err)
}
if keep {
return dir, func() {}, nil
}
return dir, func() {
if err := os.RemoveAll(dir); err != nil {
xlog.Warn("[vllm-cpp] could not remove the video work directory", "dir", dir, "error", err)
}
}, nil
}
// videoExtraParams holds the per-request knobs that have no proto field.
type videoExtraParams struct {
noiseAug float32
refImage string
refVideo string
crf int32
}
func parseVideoRequestParams(params map[string]string) (videoExtraParams, error) {
var extra videoExtraParams
for k, raw := range params {
v := strings.TrimSpace(raw)
switch k {
case "noise_aug":
f, err := strconv.ParseFloat(v, 32)
if err != nil {
return extra, fmt.Errorf("vllm-cpp: params.noise_aug must be a number, got %q", raw)
}
extra.noiseAug = float32(f)
case "ref_image":
extra.refImage = v
case "ref_video":
extra.refVideo = v
case "crf":
n, err := strconv.ParseInt(v, 10, 32)
if err != nil {
return extra, fmt.Errorf("vllm-cpp: params.crf must be an integer, got %q", raw)
}
extra.crf = int32(n)
default:
return extra, fmt.Errorf("vllm-cpp: unknown params key %q (accepted: %s)",
k, strings.Join(videoRequestParams, ", "))
}
}
return extra, nil
}
// checkPartitionConditioning refuses conditioning the loaded checkpoint cannot
// serve.
//
// This is the failure this backend most needs to catch early. The FL2VA
// partition serves t2va and fl2va; handing it a reference image or audio is a
// partition mismatch, and H3 does not fail cleanly on one - it renders, for
// hours, and returns a coloured lattice over the frame. The engine's own #77
// guard covers a missing declaration; this covers a declaration that does not
// match the request.
func checkPartitionConditioning(partition string, opts *pb.GenerateVideoRequest, extra videoExtraParams) error {
hasKeyframe := opts.GetStartImage() != "" || opts.GetEndImage() != ""
hasReference := extra.refImage != "" || extra.refVideo != "" || opts.GetAudio() != ""
if hasKeyframe && hasReference {
return fmt.Errorf("vllm-cpp: fl2va keyframes (start_image/end_image) and ref2va reference " +
"conditioning (params.ref_image/params.ref_video/audio) are exclusive in the H3 pipeline")
}
switch partition {
case partitionFL2VA:
if hasReference {
return fmt.Errorf("vllm-cpp: the FL2VA checkpoint serves t2va and fl2va only - " +
"reference conditioning (params.ref_image/params.ref_video/audio) needs a ref2va DiT. " +
"Use start_image for first-frame conditioning instead")
}
case partitionRef2VA:
if hasKeyframe {
return fmt.Errorf("vllm-cpp: the Ref2VA checkpoint does not serve fl2va keyframes - " +
"pass the image as params.ref_image, or install the FL2VA checkpoint")
}
}
return nil
}
// resolveCanvas settles the output geometry BEFORE a keyframe is resampled,
// because the two have to agree exactly: the engine refuses a keyframe that is
// not already at the output resolution, and when no geometry is requested it
// derives one from the keyframe's own aspect. Mirrors _resolve_shape
// (src/vllm/model_executor/models/minimax_h3_planner.cpp:264-308).
func resolveCanvas(width, height int32, keyframes ...string) (int32, int32, error) {
if width > 0 && height > 0 {
return width, height, nil
}
for _, k := range keyframes {
if k == "" {
continue
}
w, h, err := imageDimensions(k)
if err != nil {
return 0, 0, err
}
if w <= 0 || h <= 0 {
continue
}
// A 768 short edge, the long edge snapped onto the 32 grid.
if w >= h {
return alignMultiple(float64(h3ShortEdge)*float64(w)/float64(h), h3CanvasMultiple), h3ShortEdge, nil
}
return h3ShortEdge, alignMultiple(float64(h3ShortEdge)*float64(h)/float64(w), h3CanvasMultiple), nil
}
// The shipped canvas.
return 1344, h3ShortEdge, nil
}
// stageKeyframe converts a staged upload into the binary PPM (P6) at exactly
// width x height that the engine requires. libvllm vendors no image codec and
// no resampler, so ffmpeg does both; a P6 already at the canvas passes through
// untouched.
func stageKeyframe(ffmpegPath, src string, width, height int32, workdir, name string) (string, error) {
if src == "" {
return "", nil
}
if w, h, err := ppmDimensions(src); err == nil && w == width && h == height {
return src, nil
}
ffmpegBin, err := resolveFfmpeg(ffmpegPath)
if err != nil {
return "", fmt.Errorf("converting the %s keyframe to PPM: %w", name, err)
}
out := filepath.Join(workdir, name+"_frame.ppm")
// -frames:v 1 because an animated upload (GIF) would otherwise write a
// sequence; -pix_fmt rgb24 is what the image2/ppm muxer needs for P6.
cmd := exec.Command(ffmpegBin, "-y", "-loglevel", "error", "-i", src, // #nosec G204 -- the binary is resolved, the rest are literals and staged paths
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:%d", width, height),
"-pix_fmt", "rgb24", "-f", "image2", out)
if output, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("vllm-cpp: converting the %s keyframe to PPM failed: %w (output: %s)",
name, err, strings.TrimSpace(string(output)))
}
return out, nil
}
// imageDimensions reads geometry from a staged upload, PPM included (the Go
// standard library has no netpbm decoder).
func imageDimensions(path string) (int32, int32, error) {
if w, h, err := ppmDimensions(path); err == nil {
return w, h, nil
}
f, err := os.Open(path) // #nosec G304 -- a path staged by LocalAI for this request
if err != nil {
return 0, 0, fmt.Errorf("vllm-cpp: reading the keyframe %q: %w", path, err)
}
defer func() { _ = f.Close() }()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, fmt.Errorf("vllm-cpp: the keyframe %q is not a PNG, JPEG, GIF or binary PPM: %w", path, err)
}
return int32(cfg.Width), int32(cfg.Height), nil
}
// ppmDimensions parses a binary PPM (P6) header: magic, then width, height and
// maxval as ASCII decimals separated by whitespace, with # comments allowed.
func ppmDimensions(path string) (int32, int32, error) {
f, err := os.Open(path) // #nosec G304 -- a path staged by LocalAI for this request
if err != nil {
return 0, 0, err
}
defer func() { _ = f.Close() }()
// A P6 header is a handful of bytes; 512 covers any sane comment run.
buf := make([]byte, 512)
n, err := f.Read(buf)
if n < 2 || (err != nil && n == 0) {
return 0, 0, fmt.Errorf("not a PPM")
}
if buf[0] != 'P' || buf[1] != '6' {
return 0, 0, fmt.Errorf("not a binary PPM (P6)")
}
fields := make([]int32, 0, 2)
for i := 2; i < n && len(fields) < 2; {
switch {
case buf[i] == '#':
for i < n && buf[i] != '\n' {
i++
}
case buf[i] >= '0' && buf[i] <= '9':
value := int32(0)
for i < n && buf[i] >= '0' && buf[i] <= '9' {
value = value*10 + int32(buf[i]-'0')
i++
}
fields = append(fields, value)
default:
i++
}
}
if len(fields) < 2 {
return 0, 0, fmt.Errorf("truncated PPM header")
}
return fields[0], fields[1], nil
}
// alignMultiple mirrors MiniMaxH3AlignMultiple: round-half-to-even onto the
// multiple, floored at one multiple. Half-to-even, not half-away-from-zero,
// because the reference pipeline uses Python's round().
func alignMultiple(value float64, multiple int32) int32 {
snapped := int32(math.RoundToEven(value/float64(multiple))) * multiple
if snapped < multiple {
return multiple
}
return snapped
}
// truncateToGrid mirrors the engine's canvas snap: truncation, not rounding.
func truncateToGrid(v int32) int32 {
if v <= 0 {
return 0
}
return v / h3CanvasMultiple * h3CanvasMultiple
}
// alignFrameCount mirrors MiniMaxH3AlignFrameCount: the next value on the
// 17n+5 grid. Used only to warn - the engine does the real alignment.
func alignFrameCount(frames int32) int32 {
if frames <= 0 {
return frames
}
for frames%h3FrameGrid != h3FrameOffset {
frames++
}
return frames
}
func firstPositive(values ...int32) int32 {
for _, v := range values {
if v > 0 {
return v
}
}
return 0
}
func videoDeviceName(device int32) string {
if device == videoDeviceCUDA {
return "cuda"
}
return "cpu"
}
// siblingConfigJSON is the release layout: each VAE ships its config.json in
// the directory holding its weights.
func siblingConfigJSON(weights string) string {
candidate := filepath.Join(filepath.Dir(weights), "config.json")
if _, err := os.Stat(candidate); err != nil {
return ""
}
return candidate
}
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"os"
"path/filepath"
"unsafe"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The video PODs carry the same contract as the text ones in vllmcpp_test.go:
// these are the C offsets of vllm.h on LP64, and a drift here is silent memory
// corruption rather than a compile error.
var _ = Describe("C ABI video struct mirrors", func() {
It("cVideoModelParams matches vllm_video_model_params", func() {
var p cVideoModelParams
Expect(unsafe.Offsetof(p.DitPath)).To(Equal(uintptr(0)))
Expect(unsafe.Offsetof(p.EncoderPath)).To(Equal(uintptr(8)))
Expect(unsafe.Offsetof(p.TokenizerPath)).To(Equal(uintptr(16)))
Expect(unsafe.Offsetof(p.VideoVaePath)).To(Equal(uintptr(24)))
Expect(unsafe.Offsetof(p.VideoVaeConfigPath)).To(Equal(uintptr(32)))
Expect(unsafe.Offsetof(p.AudioVaePath)).To(Equal(uintptr(40)))
Expect(unsafe.Offsetof(p.AudioVaeConfigPath)).To(Equal(uintptr(48)))
Expect(unsafe.Offsetof(p.PromptEmbedsPath)).To(Equal(uintptr(56)))
Expect(unsafe.Offsetof(p.Partition)).To(Equal(uintptr(64)))
Expect(unsafe.Offsetof(p.Device)).To(Equal(uintptr(72)))
Expect(unsafe.Offsetof(p.DequantBf16)).To(Equal(uintptr(76)))
Expect(unsafe.Offsetof(p.Fp4Resident)).To(Equal(uintptr(80)))
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(88)))
})
It("cVideoParams matches vllm_video_params", func() {
var p cVideoParams
Expect(unsafe.Offsetof(p.Prompt)).To(Equal(uintptr(0)))
Expect(unsafe.Offsetof(p.Width)).To(Equal(uintptr(8)))
Expect(unsafe.Offsetof(p.Height)).To(Equal(uintptr(12)))
Expect(unsafe.Offsetof(p.NumFrames)).To(Equal(uintptr(16)))
Expect(unsafe.Offsetof(p.Steps)).To(Equal(uintptr(20)))
Expect(unsafe.Offsetof(p.Seed)).To(Equal(uintptr(24)))
Expect(unsafe.Offsetof(p.HasSeed)).To(Equal(uintptr(32)))
Expect(unsafe.Offsetof(p.FirstFrame)).To(Equal(uintptr(40)))
Expect(unsafe.Offsetof(p.LastFrame)).To(Equal(uintptr(48)))
Expect(unsafe.Offsetof(p.RefImage)).To(Equal(uintptr(56)))
Expect(unsafe.Offsetof(p.RefVideo)).To(Equal(uintptr(64)))
Expect(unsafe.Offsetof(p.RefAudio)).To(Equal(uintptr(72)))
Expect(unsafe.Offsetof(p.NoiseAug)).To(Equal(uintptr(80)))
Expect(unsafe.Offsetof(p.OutputDir)).To(Equal(uintptr(88)))
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(96)))
})
It("cVideoResult matches vllm_video_result", func() {
var r cVideoResult
Expect(unsafe.Offsetof(r.FrameDir)).To(Equal(uintptr(0)))
Expect(unsafe.Offsetof(r.AudioPath)).To(Equal(uintptr(8)))
Expect(unsafe.Offsetof(r.FrameCount)).To(Equal(uintptr(16)))
Expect(unsafe.Offsetof(r.Width)).To(Equal(uintptr(20)))
Expect(unsafe.Offsetof(r.Height)).To(Equal(uintptr(24)))
Expect(unsafe.Offsetof(r.Fps)).To(Equal(uintptr(28)))
Expect(unsafe.Offsetof(r.SampleRate)).To(Equal(uintptr(32)))
Expect(unsafe.Offsetof(r.MuxArgv)).To(Equal(uintptr(40)))
Expect(unsafe.Offsetof(r.MuxArgc)).To(Equal(uintptr(48)))
Expect(unsafe.Sizeof(r)).To(Equal(uintptr(56)))
})
It("cVideoMuxParams matches vllm_video_mux_params", func() {
var p cVideoMuxParams
Expect(unsafe.Offsetof(p.Frames)).To(Equal(uintptr(0)))
Expect(unsafe.Offsetof(p.AudioPath)).To(Equal(uintptr(8)))
Expect(unsafe.Offsetof(p.OutputPath)).To(Equal(uintptr(16)))
Expect(unsafe.Offsetof(p.Fps)).To(Equal(uintptr(24)))
Expect(unsafe.Offsetof(p.Crf)).To(Equal(uintptr(28)))
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(32)))
})
})
var _ = Describe("video load options", func() {
It("stays disengaged for a plain text config", func() {
lo := parseOptions(&pb.ModelOptions{Options: []string{"max_num_seqs:16"}})
Expect(lo.video.engaged()).To(BeFalse())
})
It("reads the H3 checkpoint set from the options list", func() {
lo := parseOptions(&pb.ModelOptions{Options: []string{
"video_encoder:qwen3vl-32B-MiniMax-H3-Q4_K_M.gguf",
"video_tokenizer:tokenizer.json",
"video_vae:vae/diffusion_pytorch_model.safetensors",
"audio_vae:audio_vae/model.safetensors",
"video_partition:fl2va",
"video_device:cuda",
"video_dequant_bf16:true",
"video_width:1344",
"video_height:768",
"video_num_frames:124",
"video_steps:50",
}})
Expect(lo.video.engaged()).To(BeTrue())
Expect(lo.video.encoderPath).To(Equal("qwen3vl-32B-MiniMax-H3-Q4_K_M.gguf"))
Expect(lo.video.tokenizerPath).To(Equal("tokenizer.json"))
Expect(lo.video.videoVaePath).To(Equal("vae/diffusion_pytorch_model.safetensors"))
Expect(lo.video.audioVaePath).To(Equal("audio_vae/model.safetensors"))
Expect(lo.video.partition).To(Equal(partitionFL2VA))
Expect(lo.video.device).To(Equal(videoDeviceCUDA))
Expect(lo.video.deviceSet).To(BeTrue())
Expect(lo.video.dequantBf16).To(Equal(int32(1)))
Expect(lo.video.width).To(Equal(int32(1344)))
Expect(lo.video.height).To(Equal(int32(768)))
Expect(lo.video.numFrames).To(Equal(int32(124)))
Expect(lo.video.steps).To(Equal(int32(50)))
})
It("reads the same keys from engine_args", func() {
lo := parseOptions(&pb.ModelOptions{
EngineArgs: `{"video_vae":"vae/v.safetensors","audio_vae":"a.safetensors","video_num_frames":124,"video_dequant_bf16":true}`,
})
Expect(lo.video.engaged()).To(BeTrue())
Expect(lo.video.videoVaePath).To(Equal("vae/v.safetensors"))
Expect(lo.video.audioVaePath).To(Equal("a.safetensors"))
Expect(lo.video.numFrames).To(Equal(int32(124)))
Expect(lo.video.dequantBf16).To(Equal(int32(1)))
})
It("ignores an unknown video_device rather than guessing", func() {
lo := parseOptions(&pb.ModelOptions{Options: []string{"video_vae:v", "video_device:tpu"}})
Expect(lo.video.deviceSet).To(BeFalse())
Expect(lo.video.device).To(Equal(videoDeviceCPU))
})
})
var _ = Describe("per-request params", func() {
It("maps the accepted keys", func() {
extra, err := parseVideoRequestParams(map[string]string{
"noise_aug": "0.5", "ref_image": "/tmp/ref.ppm", "crf": "20",
})
Expect(err).ToNot(HaveOccurred())
Expect(extra.noiseAug).To(BeNumerically("~", 0.5, 1e-6))
Expect(extra.refImage).To(Equal("/tmp/ref.ppm"))
Expect(extra.crf).To(Equal(int32(20)))
})
It("refuses an unknown key instead of dropping it", func() {
_, err := parseVideoRequestParams(map[string]string{"resolution": "480p"})
Expect(err).To(MatchError(ContainSubstring("unknown params key")))
})
It("refuses a non-numeric noise_aug", func() {
_, err := parseVideoRequestParams(map[string]string{"noise_aug": "high"})
Expect(err).To(HaveOccurred())
})
})
// The partition guard is the correctness rule this backend exists to enforce:
// the FL2VA DiT serves t2va and fl2va, and handing it reference conditioning
// renders a broken lattice over the frame after a multi-hour generation rather
// than failing.
var _ = Describe("partition conditioning guard", func() {
It("accepts a plain t2va request on fl2va", func() {
Expect(checkPartitionConditioning(partitionFL2VA,
&pb.GenerateVideoRequest{Prompt: "a llama"}, videoExtraParams{})).To(Succeed())
})
It("accepts fl2va keyframes on fl2va", func() {
Expect(checkPartitionConditioning(partitionFL2VA,
&pb.GenerateVideoRequest{StartImage: "/tmp/a.png"}, videoExtraParams{})).To(Succeed())
})
It("refuses a reference image on fl2va", func() {
err := checkPartitionConditioning(partitionFL2VA,
&pb.GenerateVideoRequest{}, videoExtraParams{refImage: "/tmp/ref.ppm"})
Expect(err).To(MatchError(ContainSubstring("ref2va")))
})
It("refuses reference audio on fl2va", func() {
err := checkPartitionConditioning(partitionFL2VA,
&pb.GenerateVideoRequest{Audio: "/tmp/voice.wav"}, videoExtraParams{})
Expect(err).To(HaveOccurred())
})
It("refuses fl2va keyframes on ref2va", func() {
err := checkPartitionConditioning(partitionRef2VA,
&pb.GenerateVideoRequest{StartImage: "/tmp/a.png"}, videoExtraParams{})
Expect(err).To(HaveOccurred())
})
It("refuses keyframes and references together on either partition", func() {
err := checkPartitionConditioning(partitionRef2VA,
&pb.GenerateVideoRequest{StartImage: "/tmp/a.png"}, videoExtraParams{refVideo: "/tmp/clip"})
Expect(err).To(MatchError(ContainSubstring("exclusive")))
})
})
var _ = Describe("H3 geometry", func() {
It("keeps an explicitly requested canvas", func() {
w, h, err := resolveCanvas(1280, 720)
Expect(err).ToNot(HaveOccurred())
Expect(w).To(Equal(int32(1280)))
Expect(h).To(Equal(int32(720)))
})
It("falls back to the shipped 1344x768 canvas", func() {
w, h, err := resolveCanvas(0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(w).To(Equal(int32(1344)))
Expect(h).To(Equal(int32(768)))
})
It("derives a landscape canvas from a keyframe's aspect", func() {
path := writePPM(1920, 1080)
w, h, err := resolveCanvas(0, 0, path)
Expect(err).ToNot(HaveOccurred())
Expect(h).To(Equal(int32(768)))
// 768 * 16/9 = 1365.33; /32 = 42.67, round-half-to-even to 43, x32.
Expect(w).To(Equal(int32(1376)))
})
It("derives a portrait canvas from a keyframe's aspect", func() {
path := writePPM(1080, 1920)
w, h, err := resolveCanvas(0, 0, path)
Expect(err).ToNot(HaveOccurred())
Expect(w).To(Equal(int32(768)))
Expect(h).To(Equal(int32(1376)))
})
It("truncates onto the 32 grid the way the engine does", func() {
Expect(truncateToGrid(1000)).To(Equal(int32(992)))
Expect(truncateToGrid(768)).To(Equal(int32(768)))
})
It("reports the 17n+5 frame grid", func() {
Expect(alignFrameCount(124)).To(Equal(int32(124)))
Expect(alignFrameCount(120)).To(Equal(int32(124)))
Expect(alignFrameCount(100)).To(Equal(int32(107)))
})
})
var _ = Describe("keyframe staging", func() {
It("parses a binary PPM header, comments included", func() {
dir := GinkgoT().TempDir()
path := filepath.Join(dir, "commented.ppm")
Expect(os.WriteFile(path, []byte("P6\n# made by a test\n64 32\n255\n"), 0o600)).To(Succeed())
w, h, err := ppmDimensions(path)
Expect(err).ToNot(HaveOccurred())
Expect(w).To(Equal(int32(64)))
Expect(h).To(Equal(int32(32)))
})
It("refuses an ASCII PPM (P3): the engine reads P6 only", func() {
dir := GinkgoT().TempDir()
path := filepath.Join(dir, "ascii.ppm")
Expect(os.WriteFile(path, []byte("P3\n64 32\n255\n"), 0o600)).To(Succeed())
_, _, err := ppmDimensions(path)
Expect(err).To(HaveOccurred())
})
It("passes a P6 already at the canvas straight through, without ffmpeg", func() {
path := writePPM(64, 32)
out, err := stageKeyframe("", path, 64, 32, GinkgoT().TempDir(), "first")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(Equal(path))
})
It("is a no-op for an absent keyframe", func() {
out, err := stageKeyframe("", "", 64, 32, GinkgoT().TempDir(), "first")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(BeEmpty())
})
})
var _ = Describe("GenerateVideo preconditions", func() {
It("refuses when the model is not a video engine", func() {
v := &VllmCpp{}
Expect(v.GenerateVideo(&pb.GenerateVideoRequest{Prompt: "x", Dst: "/tmp/o.mp4"})).
To(MatchError(ContainSubstring("not a MiniMax-H3 video engine")))
})
})
// writePPM writes a valid P6 header of the given geometry. Only the header is
// read by anything under test, so the pixel payload is left off.
func writePPM(width, height int) string {
dir := GinkgoT().TempDir()
path := filepath.Join(dir, "frame.ppm")
header := []byte("P6\n" + itoa(width) + " " + itoa(height) + "\n255\n")
Expect(os.WriteFile(path, header, 0o600)).To(Succeed())
return path
}
func itoa(v int) string {
if v == 0 {
return "0"
}
digits := ""
for v > 0 {
digits = string(rune('0'+v%10)) + digits
v /= 10
}
return digits
}
+8 -5
View File
@@ -16,7 +16,7 @@ func TestVllmCpp(t *testing.T) {
RunSpecs(t, "vllm-cpp suite")
}
// The Go POD mirrors must match the C struct layout of vllm.h (ABI v10)
// The Go POD mirrors must match the C struct layout of vllm.h (ABI v16)
// byte-for-byte: these offsets are the C offsets on LP64 (linux/darwin
// amd64+arm64). A failure here means govllmcpp.go drifted from vllm.h.
var _ = Describe("C ABI struct mirrors", func() {
@@ -24,7 +24,7 @@ var _ = Describe("C ABI struct mirrors", func() {
// VLLM_ABI_VERSION in the vllm.h of VLLM_CPP_VERSION (Makefile).
// Moving the pin past this without growing the mirrors below ships a
// backend that refuses every load at startup (issue #11379).
Expect(abiVersion).To(Equal(10))
Expect(abiVersion).To(Equal(16))
})
It("cModelParams matches vllm_model_params", func() {
@@ -43,9 +43,12 @@ var _ = Describe("C ABI struct mirrors", func() {
Expect(unsafe.Offsetof(p.SchedulingPolicy)).To(Equal(uintptr(64)))
Expect(unsafe.Offsetof(p.KVTransferConfig)).To(Equal(uintptr(72)))
Expect(unsafe.Offsetof(p.EnableJumpForward)).To(Equal(uintptr(80)))
// 88, not 84: the struct is 8-aligned (it holds pointers), so the
// trailing int32 is padded out. Go pads identically.
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(88)))
Expect(unsafe.Offsetof(p.Device)).To(Equal(uintptr(84)))
// 88, not 92: gpu_memory_utilization is a double, so it takes the next
// 8-aligned slot after the int32 pair. Go pads identically.
Expect(unsafe.Offsetof(p.GPUMemoryUtil)).To(Equal(uintptr(88)))
Expect(unsafe.Offsetof(p.KVCacheMemoryBytes)).To(Equal(uintptr(96)))
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(104)))
})
It("cSamplingParams matches vllm_sampling_params (ABI v8)", func() {
+1 -1
View File
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# whisper.cpp version
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
WHISPER_CPP_VERSION?=306c88f4d1286aec1bf96e544632897886af5501
WHISPER_CPP_VERSION?=592feef04a1802b18cbeffd0fd0eb5d02570c2ec
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
+7
View File
@@ -320,6 +320,13 @@ impl Backend for KokorosService {
Err(Status::unimplemented("Not supported"))
}
async fn upscale_image(
&self,
_: Request<backend::UpscaleImageRequest>,
) -> Result<Response<backend::Result>, Status> {
Err(Status::unimplemented("Not supported"))
}
async fn generate_image(
&self,
_: Request<backend::GenerateImageRequest>,
+3
View File
@@ -28,6 +28,8 @@ type ModelsCMDFlags struct {
Color string `env:"COLOR" hidden:""`
NoColor string `env:"NO_COLOR" hidden:""`
HFToken string `env:"HF_TOKEN" hidden:""`
ArtifactDownloadConcurrency int `env:"LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY" help:"How many files of a model artifact to download at once. 1 (the default) downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume" group:"storage" default:"1"`
}
type ModelsList struct {
@@ -87,6 +89,7 @@ func (mi *ModelsInstall) Run(ctx *cliContext.Context) error {
artifactMaterializer := modelartifacts.NewDefaultManager(
modelartifacts.WithHuggingFaceToken(mi.HFToken),
modelartifacts.WithDownloadConcurrency(mi.ArtifactDownloadConcurrency),
)
galleryService := galleryop.NewGalleryService(&config.ApplicationConfig{
SystemState: systemState,
+3
View File
@@ -41,6 +41,7 @@ type RunCMD struct {
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH,BACKEND_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends used for inferencing" group:"backends"`
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
ArtifactDownloadConcurrency int `env:"LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY" help:"How many files of a model artifact to download at once. 1 (the default) downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume" group:"storage" default:"1"`
GeneratedContentPath string `env:"LOCALAI_GENERATED_CONTENT_PATH,GENERATED_CONTENT_PATH" type:"path" default:"${generatedcontentpath}" help:"Location for generated content (e.g. images, audio, videos)" group:"storage"`
UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"${uploadpath}" help:"Path to store uploads from files api" group:"storage"`
DataPath string `env:"LOCALAI_DATA_PATH" type:"path" default:"${basepath}/data" help:"Path for persistent data (collectiondb, agent state, tasks, jobs). Separates mutable data from configuration" group:"storage"`
@@ -278,8 +279,10 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
opts := []config.AppOption{
config.WithContext(context.Background()),
config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency),
config.WithModelArtifactMaterializer(modelartifacts.NewDefaultManager(
modelartifacts.WithHuggingFaceToken(r.HFToken),
modelartifacts.WithDownloadConcurrency(r.ArtifactDownloadConcurrency),
)),
config.WithModelPreloadDisplay(r.Color, r.NoColor != ""),
config.WithConfigFile(r.ModelsConfigFile),
@@ -16,6 +16,15 @@ func (*applicationArtifactMaterializer) Ensure(context.Context, string, modelart
return modelartifacts.Result{}, nil
}
type configurableApplicationArtifactMaterializer struct {
applicationArtifactMaterializer
concurrency int
}
func (m *configurableApplicationArtifactMaterializer) SetDownloadConcurrency(concurrency int) {
m.concurrency = concurrency
}
var _ = Describe("ApplicationConfig model artifact materializer", func() {
It("provides a default materializer", func() {
Expect(NewApplicationConfig().ModelArtifactMaterializer).NotTo(BeNil())
@@ -31,4 +40,15 @@ var _ = Describe("ApplicationConfig model artifact materializer", func() {
Expect(field.Tag.Get("json")).To(Equal("-"))
Expect(field.Tag.Get("yaml")).To(Equal("-"))
})
It("applies runtime download concurrency to configurable materializers", func() {
materializer := &configurableApplicationArtifactMaterializer{}
appConfig := NewApplicationConfig(WithModelArtifactMaterializer(materializer))
concurrency := 4
appConfig.ApplyRuntimeSettings(&RuntimeSettings{ArtifactDownloadConcurrency: &concurrency})
Expect(appConfig.ArtifactDownloadConcurrency).To(Equal(4))
Expect(materializer.concurrency).To(Equal(4))
})
})
+27 -11
View File
@@ -35,6 +35,7 @@ type ApplicationConfig struct {
// network interfaces (e.g. eth0), filtering out docker0/veth noise.
WebRTCICEInterfaces []string
UploadLimitMB, Threads, ContextSize int
ArtifactDownloadConcurrency int
F16 bool
Debug bool
EnableTracing bool
@@ -58,12 +59,12 @@ type ApplicationConfig struct {
// gzip is skipped. 0 keeps middleware.DefaultCompressionMinLength.
HTTPCompressionMinLength int
PreloadJSONModels string
PreloadModelsFromPath string
CORSAllowOrigins string
ApiKeys []string
P2PToken string
P2PNetworkID string
Federated bool
PreloadModelsFromPath string
CORSAllowOrigins string
ApiKeys []string
P2PToken string
P2PNetworkID string
Federated bool
// ExternalBaseURL is the externally visible base URL of this instance
// (scheme+host[:port]), set via LOCALAI_BASE_URL. When non-empty it is
@@ -276,11 +277,12 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
// 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
EnableBackendLogging: true,
ArtifactDownloadConcurrency: modelartifacts.DefaultDownloadConcurrency,
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"
@@ -685,6 +687,15 @@ func WithModelArtifactMaterializer(materializer ArtifactMaterializer) AppOption
}
}
func WithArtifactDownloadConcurrency(concurrency int) AppOption {
return func(o *ApplicationConfig) {
if concurrency < 1 {
concurrency = modelartifacts.DefaultDownloadConcurrency
}
o.ArtifactDownloadConcurrency = concurrency
}
}
// WithModelPreloadDisplay configures terminal rendering for model preload output.
func WithModelPreloadDisplay(renderMode string, disableColor bool) AppOption {
return func(o *ApplicationConfig) {
@@ -1190,6 +1201,11 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
xsysinfo.SetDefaultVRAMBudget(b)
}
}
if settings.ArtifactDownloadConcurrency != nil {
if configurable, ok := o.ModelArtifactMaterializer.(interface{ SetDownloadConcurrency(int) }); ok {
configurable.SetDownloadConcurrency(o.ArtifactDownloadConcurrency)
}
}
// Note: ApiKeys need env-merge handling (MergeAPIKeys) - done by the
// caller, because the env-provided keys live on the startup config.
return requireRestart
+14
View File
@@ -304,6 +304,20 @@ var BackendCapabilities = map[string]BackendCapability{
AcceptsImages: true,
Description: "SGLang — fast LLM inference with structured generation and optional vision",
},
// vllm-cpp serves two mutually exclusive engine handles from one backend:
// a text engine, and MiniMax-H3's video+audio engine when the model config
// declares the H3 checkpoint set. Both usecases are possible, and chat is
// the default because a config that says nothing is a text model.
//
// AcceptsImages is the fl2va keyframe (start_image/end_image), the same
// reason longcat-video declares it; the text path takes no image input.
"vllm-cpp": {
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodGenerateVideo},
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseVideo},
DefaultUsecases: []string{UsecaseChat},
AcceptsImages: true,
Description: "vllm.cpp — the LocalAI team's C++20 port of vLLM; text generation plus MiniMax-H3 video+audio generation",
},
"vllm-omni": {
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodGenerateImage, MethodGenerateVideo, MethodTTS},
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseImage, UsecaseVideo, UsecaseTTS, UsecaseVision},
+8
View File
@@ -38,6 +38,14 @@ var CacheTypeOptions = []FieldOption{
{Value: "q4_1", Label: "Q4_1"},
{Value: "q5_0", Label: "Q5_0"},
{Value: "q5_1", Label: "Q5_1"},
// TurboQuant KV-cache types — accepted by the turboquant and
// buun-llama-cpp fork backends; stock llama-cpp will reject them at load.
{Value: "turbo2", Label: "Turbo2 (TurboQuant)"},
{Value: "turbo3", Label: "Turbo3 (TurboQuant)"},
{Value: "turbo4", Label: "Turbo4 (TurboQuant)"},
// Trellis-Coded Quantization variants — buun-llama-cpp only.
{Value: "turbo2_tcq", Label: "Turbo2 TCQ (buun-llama-cpp)"},
{Value: "turbo3_tcq", Label: "Turbo3 TCQ (buun-llama-cpp)"},
}
var DiffusersPipelineOptions = []FieldOption{
+19
View File
@@ -816,6 +816,25 @@ func DefaultRegistry() map[string]FieldMetaOverride {
AutocompleteProvider: "models:token_classify",
Order: 201,
},
"pii.reversible_redactions": {
Section: "pii",
Label: "Reversible Redactions",
Description: "Replace masked values with wrapped request-scoped tokens and restore them when the model returns those tokens. Supports streaming responses and never persists the substitution map.",
Component: "toggle",
Order: 202,
},
"pii.reversible_token_prefix": {
Section: "pii",
Label: "Reversible Token Prefix",
Description: "Prefix for reversible redaction tokens. Defaults to [REDACTED:.",
Order: 203,
},
"pii.reversible_token_suffix": {
Section: "pii",
Label: "Reversible Token Suffix",
Description: "Suffix for reversible redaction tokens. Defaults to ].",
Order: 204,
},
// --- PII detection policy (on a token_classify detector model) ---
"pii_detection.min_score": {
+10
View File
@@ -440,8 +440,18 @@ type PIIConfig struct {
// model just opts in by listing detectors. Multiple detectors union
// their hits; overlapping spans resolve to the strongest action.
Detectors []string `yaml:"detectors,omitempty" json:"detectors,omitempty"`
// ReversibleRedactions replaces request PII with stable, request-scoped
// tokens and restores those values when the wrapped tokens appear in the response.
ReversibleRedactions bool `yaml:"reversible_redactions,omitempty" json:"reversible_redactions,omitempty"`
ReversibleTokenPrefix string `yaml:"reversible_token_prefix,omitempty" json:"reversible_token_prefix,omitempty"`
ReversibleTokenSuffix string `yaml:"reversible_token_suffix,omitempty" json:"reversible_token_suffix,omitempty"`
}
func (c ModelConfig) PIIReversibleRedactions() bool { return c.PII.ReversibleRedactions }
func (c ModelConfig) PIIReversibleTokenPrefix() string { return c.PII.ReversibleTokenPrefix }
func (c ModelConfig) PIIReversibleTokenSuffix() string { return c.PII.ReversibleTokenSuffix }
// @Description Detection policy for a token-classification (NER) model
// used as a PII detector. Lives on the detector model's own config so the
// model is a self-describing policy unit: consuming models reference it by
+10 -9
View File
@@ -33,15 +33,16 @@ type RuntimeSettings struct {
LRUEvictionRetryInterval *string `json:"lru_eviction_retry_interval,omitempty"` // Interval between retries when waiting for busy models (e.g., 1s, 2s) (default: 1s)
// Performance settings
Threads *int `json:"threads,omitempty"`
ContextSize *int `json:"context_size,omitempty"`
VRAMBudget *string `json:"vram_budget,omitempty"` // Cap VRAM for allocation ("80%" or "12GB"; "" = no cap)
F16 *bool `json:"f16,omitempty"`
Debug *bool `json:"debug,omitempty"`
EnableTracing *bool `json:"enable_tracing,omitempty"`
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
Threads *int `json:"threads,omitempty"`
ContextSize *int `json:"context_size,omitempty"`
ArtifactDownloadConcurrency *int `json:"artifact_download_concurrency,omitempty"`
VRAMBudget *string `json:"vram_budget,omitempty"` // Cap VRAM for allocation ("80%" or "12GB"; "" = no cap)
F16 *bool `json:"f16,omitempty"`
Debug *bool `json:"debug,omitempty"`
EnableTracing *bool `json:"enable_tracing,omitempty"`
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
// Security/CORS settings
CORS *bool `json:"cors,omitempty"`
+9
View File
@@ -227,6 +227,15 @@ var runtimeSettingsFields = []fieldSpec{
func(s *RuntimeSettings) **int { return &s.ContextSize },
func(o *ApplicationConfig) int { return o.ContextSize },
func(o *ApplicationConfig, v int) { o.ContextSize = v }),
field("artifact_download_concurrency",
func(s *RuntimeSettings) **int { return &s.ArtifactDownloadConcurrency },
func(o *ApplicationConfig) int { return o.ArtifactDownloadConcurrency },
func(o *ApplicationConfig, v int) {
if v < 1 {
v = 1
}
o.ArtifactDownloadConcurrency = v
}),
// VRAM budget: the cap string ("80%"/"12GB"/"" = uncapped). The live
// side effect (xsysinfo.SetDefaultVRAMBudget) is post-processing in the
// apply loop, not here - the row only owns the config member, matching
@@ -71,6 +71,7 @@ var _ = Describe("runtime settings registry", func() {
src.LRUEvictionRetryInterval = 3 * time.Second
src.Threads = 7
src.ContextSize = 8192
src.ArtifactDownloadConcurrency = 6
src.VRAMBudget = "12GiB"
src.F16 = true
src.Debug = true
+5
View File
@@ -98,4 +98,9 @@ func (o *ApplicationConfig) ApplyRuntimeSettingsAtStartup(settings *RuntimeSetti
xsysinfo.SetDefaultVRAMBudget(b)
}
}
if settings.ArtifactDownloadConcurrency != nil {
if configurable, ok := o.ModelArtifactMaterializer.(interface{ SetDownloadConcurrency(int) }); ok {
configurable.SetDownloadConcurrency(o.ArtifactDownloadConcurrency)
}
}
}
+2 -1
View File
@@ -38,6 +38,7 @@ func (i *LlamaCPPImporter) AdditionalBackends() []KnownBackendEntry {
{Name: "ik-llama-cpp", Modality: "text", Description: "GGUF drop-in replacement for llama-cpp with ik-quants"},
{Name: "turboquant", Modality: "text", Description: "GGUF drop-in replacement for llama-cpp with TurboQuant optimizations"},
{Name: "vllm-cpp", Modality: "text", Description: "vLLM-style continuous-batching engine (vllm.cpp) consuming GGUF, by the LocalAI team"},
{Name: "buun-llama-cpp", Modality: "text", Description: "GGUF drop-in replacement for llama-cpp with DFlash speculative decoding and TurboQuant/TCQ KV-cache quantization"},
}
}
@@ -136,7 +137,7 @@ func (i *LlamaCPPImporter) Import(details Details) (gallery.ModelConfig, error)
backend := "llama-cpp"
if b, ok := preferencesMap["backend"].(string); ok {
switch b {
case "ik-llama-cpp", "turboquant", "vllm-cpp":
case "ik-llama-cpp", "turboquant", "vllm-cpp", "buun-llama-cpp":
backend = b
}
}
+19 -2
View File
@@ -203,6 +203,23 @@ var _ = Describe("LlamaCPPImporter", func() {
Expect(modelConfig.Files[0].Filename).To(Equal("my-model.gguf"))
})
It("swaps the emitted backend to buun-llama-cpp when preferred", func() {
preferences := json.RawMessage(`{"backend": "buun-llama-cpp"}`)
details := Details{
URI: "https://example.com/my-model.gguf",
Preferences: preferences,
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: buun-llama-cpp"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(modelConfig.ConfigFile).NotTo(ContainSubstring("backend: llama-cpp\n"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(modelConfig.ConfigFile).To(ContainSubstring("model: my-model.gguf"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(len(modelConfig.Files)).To(Equal(1))
Expect(modelConfig.Files[0].Filename).To(Equal("my-model.gguf"))
})
It("keeps backend: llama-cpp for unknown backend preferences", func() {
// Unknown backend values must not leak into the emitted YAML —
// we only honour the curated drop-in replacements.
@@ -551,7 +568,7 @@ var _ = Describe("LlamaCPPImporter", func() {
})
Context("AdditionalBackends", func() {
It("advertises ik-llama-cpp, turboquant and vllm-cpp as drop-in replacements", func() {
It("advertises all llama-cpp drop-in replacements", func() {
entries := importer.AdditionalBackends()
names := make([]string, 0, len(entries))
@@ -560,7 +577,7 @@ var _ = Describe("LlamaCPPImporter", func() {
names = append(names, e.Name)
byName[e.Name] = e
}
Expect(names).To(ConsistOf("ik-llama-cpp", "turboquant", "vllm-cpp"))
Expect(names).To(ConsistOf("ik-llama-cpp", "turboquant", "vllm-cpp", "buun-llama-cpp"))
for _, name := range names {
e := byName[name]
+6 -7
View File
@@ -209,16 +209,15 @@ func VideoEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfi
config.Backend = model.StableDiffusionGGMLBackend
}
// Unset geometry is passed through as 0 so the BACKEND supplies its own
// default canvas. Every video backend already does: stablediffusion-ggml
// falls back to 512x512, diffusers to 1280x720, longcat-video to 832x480,
// vllm-cpp to MiniMax-H3's trained 1344x768. Forcing 512x512 here made a
// request that asked for nothing render at a size three of the four were
// never trained at, and there is no size that is right for all of them.
width := input.Width
height := input.Height
if width == 0 {
width = 512
}
if height == 0 {
height = 512
}
b64JSON := input.ResponseFormat == "b64_json"
tempDir := ""
@@ -11,6 +11,13 @@ test.describe('Settings - Backend Logging', () => {
await expect(page.locator('text=Enable Backend Logging')).toBeVisible()
})
test('artifact download concurrency is configurable', async ({ page }) => {
const input = page.getByLabel('Artifact Download Concurrency')
await expect(input).toBeVisible()
await input.fill('4')
await expect(input).toHaveValue('4')
})
test('backend logging toggle can be toggled', async ({ page }) => {
// Find the checkbox associated with backend logging
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })
@@ -0,0 +1,203 @@
{
"activity": {
"title": "Atividade",
"supporting": "Instalações, downloads e remoções nesta instância.",
"hide": "Ocultar",
"moveToHistory": "Mover para o histórico",
"moreCount": "mais {{count}}",
"waitingForInstaller": "aguardando o instalador",
"progressLabel": "Progresso de {{name}}",
"toNode": "para {{node}}",
"nodesDone": "{{done}} de {{total}} nós concluídos",
"timeLeft": "restam {{value}}",
"cancel": "Cancelar",
"cancelLabel": "Cancelar {{name}}",
"pause": "Pausar",
"pauseLabel": "Pausar {{name}} e manter os dados baixados",
"retry": "Tentar novamente",
"retryLabel": "Tentar novamente {{name}}",
"nodeCount": "{{count}} nós",
"showNodes": "Mostrar {{count}} nós",
"hideNodes": "Ocultar detalhes por nó",
"node": {
"done": "Concluído",
"failed": "Falhou",
"queued": "Na fila",
"workerBusy": "Worker ocupado",
"downloading": "Baixando"
},
"kind": {
"model": "modelo",
"backend": "backend"
},
"verb": {
"installing": "Instalando {{kind}}",
"installed": "{{kind}} instalado",
"removed": "{{kind}} removido",
"staged": "Modelo preparado",
"failed": "Não foi possível instalar {{kind}}",
"queued": "Na fila",
"staging": "Preparando modelo",
"removing": "Removendo {{kind}}",
"failedRemoval": "Não foi possível remover {{kind}}",
"failedStaging": "Não foi possível preparar o modelo"
},
"phase": {
"resolving": "Resolvendo arquivos",
"downloading": "Baixando",
"verifying": "Verificando",
"committing": "Finalizando",
"persisting": "Salvando configuração"
},
"clearHistory": "Limpar histórico",
"inProgress": "Em andamento",
"needsAttention": "Requer atenção",
"record": "Registro",
"historyNote": "Mantém as últimas 50 operações. O histórico fica na memória e é reiniciado quando o LocalAI reinicia.",
"emptyTitle": "Nenhuma operação desde a inicialização",
"emptyBody": "Instalações de modelos e backends aparecem aqui enquanto são executadas e permanecem como registro após a conclusão. O histórico fica na memória, portanto é reiniciado quando o LocalAI reinicia.",
"browseModels": "Procurar modelos",
"viewInModels": "Ver em Modelos",
"viewInBackends": "Ver em Backends",
"rowInstalled": "instalado em {{duration}}",
"rowFailed": "falhou: {{error}}",
"rowCancelled": "cancelado",
"rowRemoved": "removido",
"retryFailed": "Falha ao tentar novamente: {{message}}",
"filter": {
"all": "Todas",
"models": "Modelos",
"backends": "Backends",
"cluster": "Cluster"
},
"summaryRunning_one": "{{count}} operação em andamento.",
"summaryRunning_other": "{{count}} operações em andamento.",
"summaryFailed_one": "{{count}} operação requer atenção.",
"summaryFailed_other": "{{count}} operações requerem atenção.",
"summaryQuiet_one": "Nada em execução. {{count}} operação desde a inicialização.",
"summaryQuiet_other": "Nada em execução. {{count}} operações desde a inicialização.",
"summaryIdle": "Nada em execução.",
"emptyFiltered": "Nenhuma operação corresponde a este filtro.",
"showAll": "Mostrar tudo",
"rowInstalledPlain": "instalado"
},
"manage": {
"title": "Sistema",
"subtitle": "Gerenciar modelos e backends instalados"
},
"settings": {
"title": "Configurações",
"subtitle": "Configurar as configurações de runtime do LocalAI",
"saved": "Configurações salvas com sucesso",
"saveFailed": "Falha ao salvar: {{message}}",
"loadFailed": "Falha ao carregar as configurações: {{message}}",
"sections": {
"branding": "Identidade visual",
"watchdog": "Watchdog",
"memory": "Memória",
"backends": "Backends",
"performance": "Desempenho",
"tracing": "Rastreamento",
"api": "API e CORS",
"p2p": "P2P",
"galleries": "Galerias",
"apikeys": "Chaves de API",
"agents": "Trabalhos de Agentes",
"agentpool": "Pool de Agentes",
"assistant": "Assistente LocalAI",
"distributed": "Distribuído",
"responses": "Respostas"
}
},
"backends": {
"title": "Gerenciamento de Backends",
"subtitle": "Descubra e instale backends de IA para potencializar seus modelos"
},
"backendLogs": {
"title": "Logs de Backends",
"subtitle": "Ver logs dos backends em execução",
"empty": "Nenhum log disponível"
},
"traces": {
"title": "Rastreamentos",
"subtitle": "Ver requisições, respostas e operações de backend registradas"
},
"nodes": {
"title": "Nós Distribuídos",
"subtitle": "Gerenciar nós workers de backend e de agentes"
},
"scheduling": {
"title": "Agendamento",
"subtitle": "Regras de posicionamento de modelos e réplicas no cluster"
},
"p2p": {
"title": "Computação de IA Distribuída",
"subtitle": "Escale suas cargas de trabalho de IA por vários dispositivos com distribuição ponto a ponto"
},
"users": {
"title": "Usuários",
"subtitle": "Gerenciar usuários registrados, funções e convites"
},
"usage": {
"title": "Uso",
"subtitle": "Estatísticas de uso de tokens da API",
"sources": {
"tab": "Origens",
"mixTitle": "Mix de origens",
"ribbonAria": "{{apikey}}% chaves de API, {{web}}% Web UI, {{legacy}}% Legado",
"topSources": "Principais origens ao longo do tempo",
"searchPlaceholder": "Buscar por nome ou prefixo",
"sortBy": "Ordenar",
"sortTokens": "Tokens",
"sortRequests": "Requisições",
"sortLastUsed": "Último uso",
"sortName": "Nome",
"sortUser": "Usuário",
"webUI": "Web UI",
"legacy": "Legado",
"revoked": "revogada",
"filteredTo": "Filtrado por: {{name}}",
"clearFilter": "Limpar filtro",
"other": "Outros ({{count}})",
"noTrafficShort": "Nenhuma requisição neste período.",
"noKeysYet": "Assim que houver requisições, você as verá detalhadas aqui.",
"createKey": "Criar sua primeira chave de API",
"truncatedWarning": "Mostrando as 200 principais chaves. Aplique um filtro para reduzir ainda mais."
}
},
"explorer": {
"title": "Explorador",
"subtitle": "Navegar por arquivos e configurações"
},
"operate": {
"overview": {
"title": "Visão geral",
"subtitle": "Tudo o que está em execução nesta instalação e tudo o que precisa de uma decisão.",
"attention": {
"heading": "Requer atenção",
"clear": "Nada requer atenção. Os backends estão atualizados, nenhuma operação falhou e todos os nós estão saudáveis.",
"backendUpdate": "Atualização disponível: {{from}} → {{to}}"
},
"sections": {
"heading": "Seções",
"runtime": "Runtime",
"runtimeSummary": "{{backends}} backends · {{models}} modelos · {{updates}} atualizações · {{running}} em execução",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nós",
"observability": "Observabilidade",
"observabilitySummary": "Uso e rastreamentos",
"administration": "Administração",
"administrationSummary": "Usuários, middleware e configurações · {{memory}} de memória em uso",
"clusterSingle": "Nó único",
"observabilityCounted": "{{requests}} requisições · {{errors}} falhas · p95 {{p95}} ms"
},
"headline": {
"requests": "Requisições · {{hours}}h",
"errors": "Requisições com falha",
"p95": "latência p95",
"quiet": "Nenhuma requisição atendida nesta janela ainda.",
"host": "Memória do host"
}
}
}
}
@@ -0,0 +1,55 @@
{
"title": "Agentes",
"subtitle": "Gerenciar agentes de IA autônomos",
"actions": {
"agentHub": "Central de Agentes",
"import": "Importar",
"createAgent": "Criar Agente",
"edit": "Editar",
"chat": "Conversar",
"export": "Exportar",
"delete": "Excluir",
"pause": "Pausar",
"resume": "Retomar"
},
"table": {
"name": "Nome",
"status": "Status",
"events": "Eventos",
"actions": "Ações",
"eventsTooltip": "{{count}} eventos - Clique para ver"
},
"search": {
"placeholder": "Buscar agentes...",
"summary_one": "{{shown}} de {{total}} agente",
"summary_other": "{{shown}} de {{total}} agentes"
},
"empty": {
"noConfigured": "Nenhum agente configurado",
"noConfiguredText": "Crie um agente para começar com fluxos de trabalho autônomos de IA.",
"browseHub": "Não sabe por onde começar? Explore a <1>Central de Agentes</1> para encontrar configurações de agentes prontas que você pode importar.",
"noMatching": "Nenhum agente correspondente",
"noMatchingText": "Nenhum agente corresponde a \"{{query}}\""
},
"sections": {
"yourAgents": "Seus Agentes",
"otherUsersAgents": "Agentes de Outros Usuários"
},
"deleteDialog": {
"title": "Excluir Agente",
"message": "Excluir o agente \"{{name}}\"? Esta ação não pode ser desfeita.",
"confirm": "Excluir"
},
"toasts": {
"loadFailed": "Falha ao carregar agentes: {{message}}",
"deleted": "Agente \"{{name}}\" excluído",
"deleteFailed": "Falha ao excluir agente: {{message}}",
"paused": "Agente \"{{name}}\" pausado",
"resumed": "Agente \"{{name}}\" retomado",
"pauseFailed": "Falha ao pausar agente: {{message}}",
"resumeFailed": "Falha ao retomar agente: {{message}}",
"exported": "Agente \"{{name}}\" exportado",
"exportFailed": "Falha ao exportar agente: {{message}}",
"parseFailed": "Falha ao analisar o arquivo do agente: {{message}}"
}
}
@@ -0,0 +1,112 @@
{
"login": {
"subtitle": "Entre para continuar",
"registerSubtitle": "Crie uma conta",
"createAdminSubtitle": "Crie sua conta de administrador",
"tokenSubtitle": "Digite sua chave de API para continuar",
"email": "E-mail",
"emailPlaceholder": "voce@exemplo.com",
"name": "Nome",
"namePlaceholder": "Seu nome (opcional)",
"password": "Senha",
"passwordPlaceholder": "Digite a senha...",
"newPasswordPlaceholder": "Pelo menos 12 caracteres",
"confirmPassword": "Confirmar Senha",
"confirmPasswordPlaceholder": "Repita a senha",
"inviteCodeLabel": "Código de Convite",
"inviteCodeOptional": " (opcional — pule a espera de aprovação)",
"inviteCodePlaceholder": "Cole seu código de convite...",
"tokenPlaceholder": "Digite a chave de API...",
"tokenAltPlaceholder": "Digite o token de API...",
"signIn": "Entrar",
"signingIn": "Entrando...",
"register": "Registrar",
"creatingAccount": "Criando conta...",
"createAdminAccount": "Criar Conta de Administrador",
"signInWithGitHub": "Entrar com GitHub",
"signInWithSSO": "Entrar com SSO",
"loginWithToken": "Entrar com Token",
"showTokenLogin": "Entrar com Token de API",
"hideTokenLogin": "Ocultar login com token",
"noAccount": "Não tem uma conta?",
"hasAccount": "Já tem uma conta?",
"or": "ou",
"errors": {
"loginFailed": "Falha no login",
"registrationFailed": "Falha no registro",
"invalidToken": "Token inválido",
"passwordsDoNotMatch": "As senhas não coincidem",
"enterToken": "Digite um token",
"networkError": "Erro de rede",
"inviteRequired": "Um código de convite válido é obrigatório para registrar"
},
"messages": {
"registrationPending": "Registro bem-sucedido, aguardando aprovação."
}
},
"account": {
"title": "Conta",
"subtitle": "Perfil, credenciais e chaves de API",
"unavailable": "Conta indisponível",
"unavailableText": "A autenticação deve estar habilitada para gerenciar sua conta.",
"tabs": {
"profile": "Perfil",
"security": "Segurança",
"apiKeys": "Chaves de API"
},
"profile": {
"displayName": "Nome de exibição",
"displayNameDescription": "Seu nome público de exibição",
"avatarUrl": "URL do avatar",
"avatarUrlDescription": "URL da sua foto de perfil",
"avatarUrlPlaceholder": "https://exemplo.com/avatar.png",
"save": "Salvar",
"saving": "Salvando...",
"updated": "Perfil atualizado",
"updateFailed": "Falha ao atualizar o perfil: {{message}}"
},
"security": {
"currentPassword": "Senha atual",
"currentPasswordDescription": "Digite sua senha existente para verificar sua identidade",
"currentPasswordPlaceholder": "Senha atual",
"newPassword": "Nova senha",
"newPasswordDescription": "Deve ter pelo menos 12 caracteres",
"newPasswordPlaceholder": "Nova senha",
"confirmPassword": "Confirmar senha",
"confirmPasswordDescription": "Digite novamente sua nova senha",
"confirmPasswordPlaceholder": "Confirme a nova senha",
"changePassword": "Alterar senha",
"changing": "Alterando...",
"changed": "Senha alterada",
"passwordsDoNotMatch": "As senhas não coincidem",
"tooShort": "A nova senha deve ter pelo menos 12 caracteres",
"oauthOnly": "O gerenciamento de senha não está disponível para contas {{provider}}."
},
"apiKeys": {
"create": "Criar chave de API",
"createDescription": "Gere uma chave para acesso programático",
"namePlaceholder": "Nome da chave (ex.: meu-app)",
"createButton": "Criar",
"creating": "Criando...",
"createdToast": "Chave de API criada",
"createFailed": "Falha ao criar a chave de API: {{message}}",
"loadFailed": "Falha ao carregar as chaves de API: {{message}}",
"revoke": "Revogar",
"revokeKey": "Revogar chave",
"revokeTitle": "Revogar Chave de API",
"revokeMessage": "Revogar a chave de API \"{{name}}\"? Esta ação não pode ser desfeita.",
"revoked": "Chave de API revogada",
"revokeFailed": "Falha ao revogar a chave de API: {{message}}",
"copyNow": "Copie agora — esta chave não será mostrada novamente",
"copiedToast": "Copiado para a área de transferência",
"copyFailed": "Falha ao copiar",
"empty": "Nenhuma chave de API ainda. Crie uma acima para obter acesso programático.",
"lastUsed": "último uso em {{date}}"
}
},
"notFound": {
"title": "Página Não Encontrada",
"text": "Parece que esta página se perdeu. Vamos colocá-lo de volta no caminho certo.",
"goHome": "Ir para a Página Inicial"
}
}
@@ -0,0 +1,131 @@
{
"activity": {
"thought": "Pensamento",
"tool": "Ferramenta",
"result": "Resultado",
"toolResult": "Resultado de {{name}}",
"thinking": "Pensando..."
},
"header": {
"manageModeTooltip": "Este chat pode instalar modelos, editar configurações e gerenciar backends conversando com o LocalAI.",
"modelInfo": "Informações do modelo",
"chatSettings": "Configurações do chat",
"modelInfoTitle": "Informações do Modelo: {{model}}",
"editConfig": "Editar configuração",
"close": "Fechar"
},
"modelInfo": {
"backend": "Backend",
"modelFile": "Arquivo do modelo",
"contextSize": "Tamanho do contexto",
"threads": "Threads",
"mcp": "MCP",
"configured": "Configurado",
"chatTemplate": "Modelo de conversa",
"yes": "Sim",
"gpuLayers": "Camadas GPU"
},
"context": {
"label": "Contexto: {{percent}}%",
"labelWithTokens": "Contexto: {{percent}}% ({{tokens}} tokens)"
},
"settings": {
"title": "Configurações do Chat",
"manageMode": "Modo de gerenciamento",
"manageModeDesc": "Permita que este chat instale modelos, alterne backends e edite configurações conversando com o LocalAI.",
"systemPrompt": "Instrução do sistema",
"systemPromptPlaceholder": "Você é um assistente útil...",
"temperature": "Temperatura",
"topP": "Top P",
"topK": "Top K",
"contextSize": "Tamanho do contexto",
"contextSizePlaceholder": "2048",
"clearHistory": "Limpar histórico do chat"
},
"empty": {
"manageTitle": "Gerencie o LocalAI conversando",
"manageText": "Peça para instalar modelos, alternar backends, editar configurações ou verificar o status. O assistente resumirá as ações e aguardará sua confirmação antes de alterar qualquer coisa.",
"startTitle": "Inicie uma conversa",
"readyText": "Pronto para conversar com {{model}}",
"selectModelText": "Selecione um modelo acima para começar",
"suggestionsManage": [
"O que está instalado?",
"Instalar um modelo de chat",
"Mostrar o status do sistema",
"Atualizar um backend"
],
"suggestionsChat": [
"Explique como isto funciona",
"Ajude-me a escrever código",
"Resumir um documento",
"Gerar ideias"
],
"recent": "Recente",
"noMessages": "Nenhuma mensagem ainda",
"hintEnter": "Enter para enviar",
"hintShiftEnter": "Shift+Enter para nova linha",
"hintAttach": "Anexar arquivos"
},
"errors": {
"viewTraces": "Ver rastreamentos para detalhes"
},
"actions": {
"copy": "Copiar",
"edit": "Editar",
"editMessage": "Editar mensagem",
"save": "Salvar",
"cancel": "Cancelar",
"regenerate": "Regenerar",
"branch": "Ramificar daqui",
"jumpToLatest": "Ir para a mais recente"
},
"streaming": {
"transferring": "Transferindo modelo...",
"transferringTo": "Transferindo o modelo para {{node}}..."
},
"tokens": {
"perSec": "{{count}} tok/s",
"peak": "Pico: {{count}} tok/s",
"usage": "{{prompt}}p + {{completion}}c = {{total}}"
},
"input": {
"placeholder": "Mensagem...",
"attachFile": "Anexar arquivo",
"send": "Enviar mensagem",
"stopGenerating": "Parar a geração",
"canvasTitle": "Canvas — extrai blocos de código e mídia para um painel lateral para visualização, cópia e download",
"canvasLabel": "Canvas",
"openCanvas": "Abrir painel do canvas"
},
"deleteAllDialog": {
"title": "Excluir Todos os Chats",
"message": "Excluir todos os chats? Esta ação não pode ser desfeita.",
"confirm": "Excluir todos"
},
"toasts": {
"selectModel": "Selecione um modelo",
"copied": "Copiado para a área de transferência",
"copyFailed": "Não foi possível copiar para a área de transferência",
"chatCopied": "Chat copiado para a área de transferência",
"forked": "Novo chat criado"
},
"menu": {
"trigger": "Chats",
"triggerTitle": "Conversas (Ctrl/Cmd+K)",
"search": "Buscar conversas...",
"clearSearch": "Limpar busca",
"noMatch": "Nenhuma conversa corresponde à sua busca",
"noConversations": "Nenhuma conversa ainda",
"rename": "Renomear",
"duplicate": "Duplicar chat",
"copyChat": "Copiar chat",
"exportMarkdown": "Exportar como Markdown",
"deleteChat": "Excluir chat",
"newChat": "Novo chat",
"clearAll": "Limpar tudo",
"deleteAllTitle": "Excluir todas as conversas"
},
"message": {
"you": "Você"
}
}
@@ -0,0 +1,43 @@
{
"title": "Base de Conhecimento",
"subtitle": "Gerencie coleções de documentos para RAG de agentes",
"newPlaceholder": "Nome da nova coleção...",
"actions": {
"create": "Criar",
"creating": "Criando...",
"details": "Detalhes",
"reset": "Redefinir",
"delete": "Excluir",
"viewDetails": "Ver detalhes",
"resetCollection": "Redefinir coleção",
"deleteCollection": "Excluir coleção"
},
"sections": {
"yourCollections": "Suas Coleções",
"otherUsersCollections": "Coleções de Outros Usuários"
},
"empty": {
"title": "Nenhuma coleção ainda",
"text": "As coleções permitem organizar documentos em bases de conhecimento que os agentes podem pesquisar usando RAG (Retrieval-Augmented Generation). Crie uma coleção acima para começar.",
"noPersonal": "Você ainda não tem coleções."
},
"deleteDialog": {
"title": "Excluir Coleção",
"message": "Excluir a coleção \"{{name}}\"? Isso removerá todas as entradas e não pode ser desfeito.",
"confirm": "Excluir"
},
"resetDialog": {
"title": "Redefinir Coleção",
"message": "Redefinir a coleção \"{{name}}\"? Isso removerá todas as entradas, mas manterá a coleção.",
"confirm": "Redefinir"
},
"toasts": {
"loadFailed": "Falha ao carregar coleções: {{message}}",
"created": "Coleção \"{{name}}\" criada",
"createFailed": "Falha ao criar a coleção: {{message}}",
"deleted": "Coleção \"{{name}}\" excluída",
"deleteFailed": "Falha ao excluir a coleção: {{message}}",
"reset": "Coleção \"{{name}}\" redefinida",
"resetFailed": "Falha ao redefinir a coleção: {{message}}"
}
}
@@ -0,0 +1,115 @@
{
"unsaved": {
"title": "Descartar alterações não salvas?",
"message": "Você tem alterações não salvas que serão perdidas se você sair desta página.",
"leave": "Sair"
},
"actions": {
"save": "Salvar",
"saving": "Salvando...",
"cancel": "Cancelar",
"close": "Fechar",
"confirm": "Confirmar",
"delete": "Excluir",
"edit": "Editar",
"add": "Adicionar",
"remove": "Remover",
"create": "Criar",
"update": "Atualizar",
"refresh": "Atualizar",
"reload": "Recarregar",
"retry": "Tentar novamente",
"search": "Buscar",
"filter": "Filtrar",
"clear": "Limpar",
"reset": "Redefinir",
"apply": "Aplicar",
"back": "Voltar",
"next": "Próximo",
"previous": "Anterior",
"open": "Abrir",
"submit": "Enviar",
"select": "Selecionar",
"selectAll": "Selecionar tudo",
"copy": "Copiar",
"copied": "Copiado",
"download": "Baixar",
"upload": "Enviar",
"import": "Importar",
"export": "Exportar",
"view": "Ver",
"details": "Detalhes",
"settings": "Configurações",
"help": "Ajuda",
"yes": "Sim",
"no": "Não",
"loading": "Carregando..."
},
"status": {
"loading": "Carregando...",
"saving": "Salvando...",
"saved": "Salvo",
"ready": "Pronto",
"running": "Em execução",
"stopped": "Parado",
"starting": "Iniciando...",
"stopping": "Parando...",
"pending": "Pendente",
"active": "Ativo",
"inactive": "Inativo",
"enabled": "Habilitado",
"disabled": "Desabilitado",
"online": "Online",
"offline": "Offline",
"error": "Erro",
"success": "Sucesso",
"warning": "Aviso",
"info": "Informação",
"empty": "Nenhum item",
"none": "Nenhum",
"unknown": "Desconhecido"
},
"dialogs": {
"confirmDelete": {
"title": "Confirmar exclusão",
"message": "Tem certeza de que deseja excluir isto? Esta ação não pode ser desfeita.",
"confirm": "Excluir",
"cancel": "Cancelar"
},
"unsavedChanges": {
"title": "Alterações não salvas",
"message": "Você tem alterações não salvas. Deseja descartá-las?",
"discard": "Descartar",
"keepEditing": "Continuar editando"
}
},
"forms": {
"required": "Obrigatório",
"optional": "Opcional",
"name": "Nome",
"description": "Descrição",
"type": "Tipo",
"value": "Valor",
"search": "Buscar...",
"selectPlaceholder": "Selecione uma opção...",
"noMatch": "Nenhuma correspondência"
},
"time": {
"now": "agora",
"secondsAgo_one": "há {{count}} segundo",
"secondsAgo_other": "há {{count}} segundos",
"minutesAgo_one": "há {{count}} minuto",
"minutesAgo_other": "há {{count}} minutos",
"hoursAgo_one": "há {{count}} hora",
"hoursAgo_other": "há {{count}} horas",
"daysAgo_one": "há {{count}} dia",
"daysAgo_other": "há {{count}} dias"
},
"units": {
"bytes": "B",
"kilobytes": "KB",
"megabytes": "MB",
"gigabytes": "GB",
"terabytes": "TB"
}
}
@@ -0,0 +1,17 @@
{
"generic": "Algo deu errado",
"network": "Erro de rede. Verifique sua conexão e tente novamente.",
"unauthorized": "Você não está autorizado a executar esta ação.",
"forbidden": "Acesso negado.",
"notFound": "O recurso solicitado não foi encontrado.",
"serverError": "Erro no servidor. Tente novamente mais tarde.",
"loadFailed": "Falha ao carregar: {{message}}",
"saveFailed": "Falha ao salvar: {{message}}",
"deleteFailed": "Falha ao excluir: {{message}}",
"updateFailed": "Falha ao atualizar: {{message}}",
"createFailed": "Falha ao criar: {{message}}",
"operationFailed": "Falha na operação: {{message}}",
"invalidInput": "Entrada inválida. Verifique o formulário e tente novamente.",
"tryAgain": "Tente novamente.",
"contactAdmin": "Se o problema persistir, entre em contato com o administrador."
}
@@ -0,0 +1,119 @@
{
"cluster": {
"vram": "VRAM do cluster",
"ram": "RAM do cluster",
"nodesOnline": "{{healthy}}/{{total}} nós online"
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"greeting": {
"morning": "Bom dia",
"afternoon": "Boa tarde",
"evening": "Boa noite",
"night": "Trabalhando até tarde"
},
"statusLine": {
"modelsLoaded_one": "{{count}} modelo carregado",
"modelsLoaded_other": "{{count}} modelos carregados",
"noModelsLoaded": "Nenhum modelo carregado",
"nodes_one": "{{count}} nó",
"nodes_other": "{{count}} nós",
"loadedLabel": "Carregados",
"nodesLabel": "Nós"
},
"assistant": {
"title": "Gerencie o LocalAI conversando",
"description": "Instale modelos, alterne backends, edite configurações e verifique o status conversando com o LocalAI.",
"open": "Abrir assistente",
"tooltip": "Gerencie o LocalAI conversando"
},
"input": {
"placeholder": "Mensagem...",
"attachImage": "Anexar imagem",
"attachAudio": "Anexar áudio",
"attachFile": "Anexar arquivo",
"enterToSend": "Enter para enviar",
"selectModelFirst": "Selecione um modelo primeiro",
"sendMessage": "Enviar mensagem",
"selectModelToast": "Selecione um modelo primeiro"
},
"quickLinks": {
"manageByChat": "Gerenciar por chat",
"installedModels": "Modelos Instalados",
"browseGallery": "Explorar Galeria",
"importModel": "Importar Modelo",
"documentation": "Documentação"
},
"loadedModels": {
"heading": "Modelos ativos",
"count_one": "{{count}} modelo carregado",
"count_other": "{{count}} modelos carregados",
"stop": "Parar modelo",
"stopAll": "Parar todos",
"serving": "Servindo"
},
"stopDialog": {
"title": "Parar Modelo",
"message": "Parar o modelo {{model}}?",
"confirm": "Parar {{model}}",
"stopAllTitle": "Parar Todos os Modelos",
"stopAllMessage": "Parar todos os {{count}} modelos carregados?",
"stopAllConfirm": "Parar todos",
"stoppedToast": "Modelo {{model}} parado",
"allStoppedToast": "Todos os modelos parados",
"stopFailed": "Falha ao parar: {{message}}"
},
"wizard": {
"getStarted": "Comece com {{name}}",
"intro": "Instale seu primeiro modelo para começar. Explore a galeria ou importe o seu próprio.",
"steps": {
"step1Title": "Explore a Galeria de Modelos",
"step1Body": "Encontre o modelo certo para suas necessidades em nossa coleção selecionada.",
"step2Title": "Instale um Modelo",
"step2Body": "Clique em instalar para baixar e configurá-lo automaticamente.",
"step3Title": "Comece a Conversar",
"step3Body": "Converse com seu modelo direto do navegador ou use a API."
},
"browseGallery": "Explorar Galeria de Modelos",
"importModel": "Importar Modelo",
"docs": "Documentação",
"noModelsTitle": "Nenhum Modelo Disponível",
"noModelsBody": "Ainda não há modelos instalados. Peça ao administrador para configurar modelos para você poder começar a conversar."
},
"starters": {
"title": "Recomendados para o seu hardware",
"tier": {
"cpu": "Somente CPU",
"gpu-small": "GPU",
"gpu-mid": "GPU",
"gpu-large": "GPU"
},
"cpuNote": "Nenhuma GPU detectada — estes modelos pequenos permanecem responsivos na CPU.",
"gpuNote": "Selecionados para caber na sua VRAM disponível com espaço para o contexto.",
"install": "Instalar",
"installing": "Instalando",
"installStarted": "Instalando {{model}}…",
"installFailed": "Falha na instalação: {{message}}"
},
"connect": {
"title": "Um endpoint, toda API",
"subtitle": "O LocalAI oferece sua própria API completa — geração de imagem e vídeo, profundidade, detecção de objetos, reranking, áudio, reconhecimento facial e de voz, e voz em tempo real via WebRTC e WebSocket. Além disso, uma camada de compatibilidade permite que qualquer aplicativo criado para OpenAI, Anthropic, Ollama ou OpenAI Responses se comunique sem alterações.",
"nativeTitle": "API nativa",
"compatTitle": "Compatibilidade total",
"apiReference": "Referência completa da API",
"copy": "Copiar",
"copied": "Copiado",
"browse": "Explorar a API",
"hide": "Ocultar endpoints",
"dismiss": "Dispensar"
},
"jump": {
"heading": "Continue de onde parou",
"discover": "Descobrir",
"discoverSummary": "Explore a galeria e instale modelos",
"create": "Criar",
"createSummary": "Abra uma sessão de chat, imagem ou voz",
"operate": "Operar",
"operateSummary": "{{models}} modelos configurados · nós, atividade e rastreamentos"
}
}
@@ -0,0 +1,143 @@
{
"title": "Importar Novo Modelo",
"subtitle": {
"simple": "Importe um modelo de uma URI — a detecção automática escolhe o backend.",
"powerYaml": "Escreva a configuração YAML completa do modelo.",
"powerPrefs": "Preferências de importação refinadas."
},
"actions": {
"import": "Importar Modelo",
"importing": "Importando...",
"create": "Criar",
"saving": "Salvando...",
"browseHF": "Explorar modelos no HF",
"addCustom": "Adicionar Personalizado",
"copy": "Copiar"
},
"form": {
"modelUri": "URI do modelo",
"uriPlaceholder": "huggingface://TheBloke/Llama-2-7B-Chat-GGUF ou https://exemplo.com/modelo.gguf",
"uriHint": "Digite a URI ou o caminho do arquivo do modelo que deseja importar",
"supportedFormats": "Formatos de URI Suportados",
"options": "Opções",
"preferences": "Preferências (Opcional)",
"commonPreferences": "Preferências Comuns",
"customPreferences": "Preferências Personalizadas",
"customKeyValueHint": "Adicione pares de chave-valor personalizados para configuração avançada.",
"preferenceKey": "Chave da preferência da linha {{index}}",
"preferenceValue": "Valor da preferência da linha {{index}}",
"removePref": "Remover esta preferência",
"key": "Chave",
"value": "Valor",
"backend": "Backend",
"backendAuto": "Detecção automática (com base na URI)",
"backendLoading": "Carregando backends…",
"backendSearch": "Buscar backends...",
"backendHint": "Force um backend específico. Deixe vazio para detectar automaticamente pela URI. Itens marcados como \"seleção manual\" não são detectáveis automaticamente — escolha-os você mesmo se souber o que o modelo precisa.",
"backendErrorHint": "Não foi possível carregar a lista de backends — apenas detecção automática.",
"backendNotInstalled": "Este backend ainda não está instalado. Enviar a importação fará o download dele primeiro.",
"modelName": "Nome do Modelo",
"modelNamePlaceholder": "Deixe vazio para usar o nome do arquivo",
"modelNameHint": "Nome personalizado para o modelo. Se vazio, o nome do arquivo será usado.",
"description": "Descrição",
"descriptionPlaceholder": "Deixe vazio para usar a descrição padrão",
"descriptionHint": "Descrição personalizada para o modelo.",
"quantizations": "Quantizações",
"quantizationsPlaceholder": "q4_k_m,q4_k_s,q3_k_m (separados por vírgula)",
"quantizationsHint": "Quantizações preferidas (separadas por vírgula). Deixe vazio para o padrão (q4_k_m).",
"mmprojQuantizations": "Quantizações MMProj",
"mmprojQuantizationsPlaceholder": "fp16,fp32 (separados por vírgula)",
"mmprojQuantizationsHint": "Quantizações MMProj preferidas. Deixe vazio para o padrão (fp16).",
"embeddings": "Embeddings",
"embeddingsHint": "Habilite o suporte a embeddings para este modelo.",
"modelType": "Tipo do Modelo",
"modelTypePlaceholder": "AutoModelForCausalLM (para o backend transformers)",
"modelTypeHint": "Tipo de modelo para o backend transformers. Exemplos: AutoModelForCausalLM, SentenceTransformer, Mamba.",
"pipelineType": "Tipo de Pipeline",
"pipelineTypeHint": "Tipo de pipeline para o backend diffusers.",
"schedulerType": "Tipo de Scheduler",
"schedulerTypePlaceholder": "k_dpmpp_2m (opcional)",
"schedulerTypeHint": "Tipo de scheduler para o backend diffusers. Exemplos: k_dpmpp_2m, euler_a, ddim.",
"enableParameters": "Parâmetros Habilitados",
"enableParametersPlaceholder": "negative_prompt,num_inference_steps (separados por vírgula)",
"enableParametersHint": "Parâmetros habilitados para o backend diffusers (separados por vírgula).",
"cuda": "CUDA",
"cudaHint": "Habilite o suporte a CUDA para aceleração por GPU.",
"yamlEditor": "Editor de Configuração YAML",
"manualPick": "seleção manual",
"manualPickTooltip": "A detecção automática não roteará para este backend. Escolha-o aqui se você souber que é o que deseja."
},
"modality": {
"text": "LLM de texto",
"asr": "Reconhecimento de fala",
"tts": "Texto para fala",
"image": "Imagem / Vídeo",
"video": "Geração de vídeo",
"embeddings": "Embeddings",
"reranker": "Rerankers",
"detection": "Detecção de objetos",
"vad": "Detecção de atividade de voz",
"other": "Outro"
},
"powerTabs": {
"ariaLabel": "Aba do modo avançado",
"preferences": "Preferências",
"yaml": "YAML"
},
"switchDialog": {
"title": "Manter suas preferências personalizadas?",
"body": "Alternar para o modo Simples oculta as preferências além de backend, nome e descrição. Elas ainda serão enviadas quando você importar.",
"cancel": "Cancelar",
"discard": "Descartar e alternar",
"keep": "Manter e alternar"
},
"estimate": {
"title": "Requisitos estimados",
"download": "Download: {{size}}",
"vram": "VRAM: {{vram}}"
},
"toasts": {
"noUri": "Digite uma URI de modelo",
"noYaml": "Digite a configuração YAML",
"started": "Importação iniciada! Acompanhando o progresso...",
"startedWithMeta": "Importação iniciada! Acompanhando o progresso... ({{meta}})",
"imported": "Modelo importado com sucesso!",
"importedYaml": "Configuração do modelo importada com sucesso!",
"importFailed": "Falha na importação: {{message}}",
"startImportFailed": "Falha ao iniciar a importação: {{message}}",
"backendsLoadFailed": "Não foi possível carregar a lista de backends — usando apenas detecção automática",
"modalityClearedBackend": "Seleção de backend limpa — ela não estava no grupo {{label}}.",
"copied": "Copiado para a área de transferência"
},
"uriFormats": {
"huggingface": {
"title": "HuggingFace",
"standard": "Formato HuggingFace padrão",
"short": "Formato HuggingFace curto",
"fullUrl": "URL HuggingFace completa"
},
"http": {
"title": "URLs HTTP/HTTPS",
"direct": "Download direto de qualquer URL HTTPS"
},
"local": {
"title": "Arquivos Locais",
"filePath": "Caminho de arquivo local (absoluto)",
"directYaml": "Arquivo YAML de configuração local direto"
},
"oci": {
"title": "Registro OCI",
"registry": "Registro de contêineres OCI",
"tarball": "Arquivo tarball OCI local"
},
"ollama": {
"title": "Ollama",
"model": "Formato de modelo do Ollama"
},
"yaml": {
"title": "Arquivos de Configuração YAML",
"remote": "Arquivo de configuração YAML remoto",
"local": "Arquivo de configuração YAML local"
}
}
}
@@ -0,0 +1,461 @@
{
"studio": {
"tabs": {
"images": "Imagens",
"video": "Vídeo",
"tts": "TTS",
"sound": "Som",
"transform": "Transformar",
"threed": "3D",
"overview": "Visão geral"
},
"overview": {
"eyebrow": "{{ready}} de {{total}} modalidades prontas",
"title": "Studio",
"subtitle": "Gere imagens, vídeos, 3D, fala e som com os modelos desta máquina.",
"canMake": "O que você pode criar",
"running": "Em execução agora",
"recent": "Saídas recentes",
"noModel": "Nenhum modelo instalado",
"install": "Instalar um modelo",
"ready": "Pronto",
"seconds": "{{seconds}}s",
"describe": {
"images": "Texto para imagem, imagem para imagem, imagens de referência",
"video": "Texto para vídeo e imagem para vídeo",
"threed": "Reconstrução de malha a partir de imagem",
"tts": "Texto para fala usando sua biblioteca de vozes",
"sound": "Música e efeitos sonoros a partir de um prompt",
"transform": "Separação, aprimoramento e conversão de voz"
}
},
"groups": {
"create": "Criar",
"voice": "Voz",
"transform": "Transformar"
}
},
"image": {
"title": "Geração de Imagens",
"labels": {
"model": "Modelo",
"prompt": "Prompt",
"promptPlaceholder": "Descreva a imagem que deseja gerar...",
"negativePrompt": "Prompt Negativo",
"negativePromptPlaceholder": "O que evitar...",
"size": "Tamanho",
"count": "Quantidade (1-4)",
"advanced": "Configurações Avançadas",
"imageInputs": "Entradas de Imagem",
"steps": "Etapas",
"stepsPlaceholder": "20",
"seed": "Semente",
"seedPlaceholder": "Aleatória",
"sourceImage": "Imagem de Origem (img2img)",
"refImages": "Imagens de Referência",
"refImagesAdded_one": "{{count}} imagem adicionada",
"refImagesAdded_other": "{{count}} imagens adicionadas"
},
"actions": {
"view": "Ver",
"generate": "Gerar",
"generating": "Gerando..."
},
"empty": "As imagens geradas aparecerão aqui",
"toasts": {
"noPrompt": "Digite um prompt",
"noModel": "Selecione um modelo",
"noResults": "Nenhuma imagem gerada"
}
},
"video": {
"title": "Geração de Vídeo",
"labels": {
"model": "Modelo",
"prompt": "Prompt",
"promptPlaceholder": "Descreva o vídeo que deseja gerar...",
"duration": "Duração (segundos)",
"fps": "FPS",
"size": "Tamanho",
"advanced": "Configurações Avançadas",
"seed": "Semente",
"seedPlaceholder": "Aleatória",
"frames": "Quadros",
"referenceMedia": "Mídia de referência",
"startImage": "Imagem inicial",
"endImage": "Imagem final",
"avatarAudio": "Áudio do avatar"
},
"actions": {
"generate": "Gerar",
"generating": "Gerando..."
},
"empty": "O vídeo gerado aparecerá aqui",
"toasts": {
"noPrompt": "Digite um prompt",
"noModel": "Selecione um modelo",
"noResults": "Nenhum vídeo gerado"
}
},
"threed": {
"title": "Geração 3D",
"labels": {
"model": "Modelo",
"image": "Imagem de entrada",
"quality": "Qualidade",
"quality_auto": "Automática (melhor disponível)",
"quality_coarse": "Pré-visualização grosseira (rápida)",
"quality_512": "512³ fino",
"quality_1024": "Cascata 1024³ (lenta)",
"background": "Fundo",
"background_auto": "Remover automaticamente o fundo sólido",
"background_keep": "Manter original",
"background_black": "Remover preto",
"background_white": "Remover branco",
"advanced": "Configurações Avançadas",
"steps": "Etapas de forma",
"textureSteps": "Etapas de material",
"guidance": "Orientação",
"seed": "Semente",
"seedPlaceholder": "Aleatória"
},
"actions": {
"generate": "Gerar",
"generating": "Gerando...",
"remesh": "Aplicar remalhamento",
"remeshing": "Aplicando remalhamento...",
"showOriginal": "Mostrar original",
"download": "Baixar GLB"
},
"remesh": {
"title": "Remalhamento hermético para impressão",
"detail": "Detalhe do remalhamento",
"coarser": "Mais grosseiro · mais rápido",
"finer": "Mais fino · mais lento",
"hint": "O detalhe controla as menores características preservadas. O deslocamento de envolvimento segue automaticamente.",
"ready": "Pré-visualizando o modelo remalhado hermético. Esta é a versão que será baixada."
},
"viewer": {
"wireframe": "Wireframe",
"autoRotate": "Rotação automática",
"noWebgl": "WebGL2 não está disponível neste navegador — baixe o GLB para vê-lo em outro lugar.",
"contextLost": "A visualização 3D perdeu o contexto da GPU — recarregue a página para restaurá-la.",
"stats": "{{verts}} vértices · {{tris}} triângulos",
"hint": "arrastar: girar · pinça/roda: zoom · dois dedos/clique direito: mover · duplo clique: redefinir"
},
"empty": "O modelo 3D gerado aparecerá aqui",
"toasts": {
"noImage": "Forneça uma imagem de entrada",
"noModel": "Selecione um modelo",
"noResults": "Nenhum modelo gerado"
}
},
"tts": {
"title": "Texto para Fala",
"labels": {
"model": "Modelo",
"voice": "Voz",
"voicePlaceholder": "Falante opcional ou ID de voz",
"input": "Texto",
"inputPlaceholder": "Digite o texto para sintetizar..."
},
"actions": {
"generate": "Gerar",
"generating": "Gerando..."
},
"empty": "O áudio gerado aparecerá aqui",
"toasts": {
"noText": "Digite o texto",
"noModel": "Selecione um modelo",
"generated": "Fala gerada",
"generateFailed": "Falha na geração"
},
"voiceLibrary": {
"cloningReady": "Clonagem de voz",
"loading": "Carregando vozes salvas…",
"modelDefault": "Usar padrão do modelo",
"empty": "Nenhuma voz salva ainda.",
"create": "Criar uma",
"manage": "Gerenciar Biblioteca de Vozes",
"namedVoiceHint": "Este modelo não aceita perfis de áudio de referência. Você ainda pode digitar um falante nomeado suportado pelo seu backend."
}
},
"voiceLibrary": {
"title": "Biblioteca de Vozes",
"subtitle": "Crie e gerencie vozes de referência reutilizáveis para cada modelo instalado que suporta clonagem de voz.",
"loading": "Carregando vozes salvas…",
"listLabel": "Perfis de voz salvos",
"status": {
"ready": "Pronto"
},
"summary": {
"label": "Status da biblioteca de vozes",
"profiles": "vozes salvas",
"modelsReady_one": "{{count}} modelo compatível pronto",
"modelsReady_other": "{{count}} modelos compatíveis prontos",
"noModels": "Nenhum modelo compatível instalado"
},
"search": {
"label": "Buscar perfis de voz",
"placeholder": "Buscar vozes, idiomas ou transcrições"
},
"filters": {
"language": "Filtrar por idioma",
"allLanguages": "Todos os idiomas"
},
"empty": {
"title": "Sua biblioteca de vozes está vazia",
"body": "Grave ou envie um clipe de referência autorizado, adicione a transcrição exata e reuse-o nos modelos de TTS compatíveis."
},
"noResults": {
"title": "Nenhuma voz corresponde a estes filtros",
"body": "Tente uma busca diferente ou mostre todos os idiomas."
},
"detail": {
"eyebrow": "Voz selecionada",
"emptyTitle": "Selecione uma voz para inspecioná-la",
"emptyBody": "Áudio de referência, transcrição, compatibilidade e detalhes de consentimento aparecerão aqui.",
"referenceAudio": "Áudio de referência",
"transcript": "Transcrição de referência"
},
"metadata": {
"language": "Idioma",
"languageUnknown": "Não especificado",
"duration": "Duração",
"sampleRate": "Taxa de amostragem",
"created": "Criado em"
},
"consent": {
"confirmed": "Consentimento confirmado",
"confirmedAt": "Confirmado em {{date}}"
},
"modelSetup": {
"title": "Instalar um modelo de clonagem de voz",
"body": "Esta voz está pronta, mas precisa de um modelo que aceite áudio de referência. Escolha um verificado por este servidor.",
"loading": "Encontrando modelos compatíveis em suas galerias…",
"backendUnknown": "Backend selecionado durante a instalação",
"install": "Instalar",
"installing": "Instalando…",
"loadFailed": "Recomendações compatíveis não puderam ser carregadas.",
"noneAvailable": "Nenhum modelo compatível não instalado está disponível nas galerias configuradas.",
"capabilityNote": "As recomendações vêm dos metadados de capacidade do modelo, não de uma lista de modelos do lado do navegador."
},
"api": {
"title": "Uso da API e modelos compatíveis",
"summary": "Use esta voz fora do navegador ou verifique o suporte do modelo",
"body": "Envie a URI de voz estável do perfil com cada requisição de fala. O LocalAI resolve o áudio de referência privado e a transcrição no momento da geração; o YAML do modelo não é modificado.",
"compatibleModels": "Modelos compatíveis instalados",
"noInstalledModels": "Nenhum ainda. Instale um dos modelos verificados pelo servidor acima antes de enviar esta requisição.",
"curlExample": "Exemplo com cURL",
"copy": "Copiar requisição",
"endpointNote": "Os mesmos campos de modelo, entrada e voz também funcionam com POST /tts."
},
"actions": {
"create": "Criar voz",
"createFirst": "Crie sua primeira voz",
"retry": "Tentar novamente",
"clearFilters": "Limpar filtros",
"useInTTS": "Usar em Texto para Fala",
"delete": "Excluir voz",
"installModel": "Instalar um modelo compatível",
"browseModels": "Explorar todos os modelos"
},
"deleteDialog": {
"title": "Excluir esta voz?",
"message": "“{{name}}” e seu áudio de referência serão removidos permanentemente. O YAML dos modelos existentes não é alterado.",
"deleting": "Excluindo…"
},
"toasts": {
"deleted": "Voz {{name}} excluída",
"installStarted": "Instalando {{name}}. O progresso está disponível em Operações.",
"installFailed": "Não foi possível iniciar a instalação: {{message}}",
"apiCopied": "Requisição de API copiada",
"copyFailed": "Não foi possível copiar a requisição de API"
}
},
"voiceCreate": {
"eyebrow": "Biblioteca de Vozes",
"title": "Criar uma voz reutilizável",
"subtitle": "Adicione um clipe de referência limpo e sua transcrição exata. O LocalAI cuida dos parâmetros de clonagem específicos do backend no momento da geração.",
"sections": {
"reference": {
"title": "Áudio de referência",
"body": "Grave no navegador ou envie um clipe existente. A fala deve ser limpa, natural e de um único falante."
},
"details": {
"title": "Identidade e transcrição",
"body": "Dê aos administradores um nome reconhecível e transcreva o clipe exatamente, incluindo hesitações e pontuação."
},
"consent": {
"title": "Autorização",
"body": "A clonagem de voz pode ser sensível. Confirme a permissão antes que esta referência seja salva."
}
},
"audio": {
"label": "Referência de voz",
"normalizing": "Preparando um PCM WAV seguro…",
"preview": "Referência normalizada",
"durationError": "O áudio de referência deve ter entre 1 segundo e 2 minutos.",
"decodeError": "Este navegador não conseguiu decodificar o arquivo de áudio selecionado.",
"qualityReady": "Duração recomendada",
"qualityHint": "630 segundos geralmente produzem um clone mais forte"
},
"fields": {
"name": "Nome da voz",
"namePlaceholder": "ex.: Narrador de documentário",
"language": "Idioma (opcional)",
"languagePlaceholder": "ex.: pt-BR",
"description": "Descrição (opcional)",
"descriptionPlaceholder": "Tom, origem ou uso pretendido",
"transcript": "Transcrição exata",
"transcriptPlaceholder": "Digite cada palavra falada no clipe de referência…",
"transcriptHint": "Corresponda à gravação exatamente. Não reescreva a gramática nem remova pausas e hesitações."
},
"consent": {
"title": "Confirmo que esta voz pode ser clonada",
"body": "Tenho a permissão do falante ou outra base legal para armazenar e usar esta gravação para fala sintetizada."
},
"readiness": {
"title": "Prontidão do perfil",
"body": "Uma voz fica disponível em Texto para Fala quando todos os itens obrigatórios estão completos.",
"audio": "Referência PCM-WAV válida",
"quality": "Janela de qualidade de 630 segundos",
"name": "Nome de voz reconhecível",
"transcript": "Transcrição de referência exata",
"consent": "Autorização confirmada"
},
"privacy": {
"title": "Privada por padrão. ",
"body": "O áudio de referência é armazenado no diretório de dados do LocalAI com permissões de arquivo restritas e nunca é exposto como um caminho de pasta de modelo."
},
"actions": {
"back": "Voltar para a biblioteca",
"cancel": "Cancelar",
"save": "Salvar voz",
"saving": "Salvando…"
},
"toasts": {
"created": "{{name}} está pronta para uso"
}
},
"sound": {
"title": "Geração de Som",
"labels": {
"model": "Modelo",
"prompt": "Prompt",
"promptPlaceholder": "Descreva o som que deseja gerar...",
"instrumental": "Instrumental",
"bpm": "BPM",
"duration": "Duração (segundos)",
"language": "Idioma",
"timesignature": "Fórmula de Compasso",
"keyscale": "Tom/Escala",
"vocalLanguage": "Idioma vocal",
"vocalLanguagePlaceholder": "ex.: Português",
"caption": "Legenda",
"lyrics": "Letra",
"lyricsPlaceholder": "Letra para geração vocal",
"simple": "Simples",
"advanced": "Avançado",
"seed": "Semente",
"seedPlaceholder": "Aleatória",
"thinkMode": "Modo de pensamento"
},
"actions": {
"generate": "Gerar",
"generating": "Gerando..."
},
"empty": "O áudio gerado aparecerá aqui",
"toasts": {
"noPrompt": "Digite um prompt",
"noModel": "Selecione um modelo",
"generateFailed": "Falha na geração"
}
},
"audioTransform": {
"title": "Transformação de Áudio",
"labels": {
"model": "Modelo",
"audio": "Áudio (obrigatório)",
"reference": "Referência (opcional)",
"referenceHelp": "Sinal de loopback / extremidade distante para cancelamento de eco, falante alvo para conversão de voz. Deixe vazio para transformação incondicional.",
"advancedParameters": "Parâmetros avançados",
"advancedParametersHelp": "específicos do backend (uma chave=valor por linha, ex.: noise_gate=true)",
"advancedParametersPlaceholder": "# Opcional. Para LocalVQE:\n# noise_gate=true\n# noise_gate_threshold_dbfs=-50"
},
"input": {
"upload": "Enviar",
"uploadDescription": "Solte um arquivo aqui ou",
"uploadBrowse": "navegue",
"record": "Gravar",
"startRecording": "Iniciar gravação",
"stop": "Parar",
"encoding": "Codificando...",
"clear": "Limpar",
"microphoneUnavailable": "A captura de microfone não está disponível neste navegador.",
"echoNotice": "Os navegadores costumam aplicar seu próprio cancelamento de eco WebRTC e supressão de ruído por padrão. Isso geralmente resulta em desempenho pior do que executar o LocalVQE no áudio bruto.",
"echoTest": "Teste de eco (grave o microfone enquanto reproduz a referência)",
"stopEchoTest": "Parar teste de eco"
},
"result": {
"audio": "Áudio",
"reference": "Referência",
"inputSpectrum": "Espectro de entrada",
"outputSpectrum": "Espectro de saída",
"outputSpectrumHint": "Transforme para comparar a atenuação",
"output": "Saída"
},
"actions": {
"transform": "Transformar",
"processing": "Processando..."
},
"empty": "Escolha um arquivo de áudio (e referência opcional) para transformar"
},
"talk": {
"title": "Falar",
"subtitle": "Conversa de voz em tempo real",
"actions": {
"start": "Iniciar sessão",
"stop": "Encerrar sessão",
"connecting": "Conectando...",
"muted": "Mudo",
"mute": "Silenciar",
"unmute": "Reativar som"
},
"labels": {
"model": "Modelo",
"voice": "Voz",
"voicePlaceholder": "alloy",
"language": "Idioma",
"languagePlaceholder": "pt",
"instructions": "Instruções",
"instructionsPlaceholder": "Defina a persona do assistente..."
},
"status": {
"idle": "Ocioso",
"connecting": "Conectando...",
"listening": "Ouvindo...",
"speaking": "Falando...",
"ended": "Sessão encerrada"
},
"toasts": {
"noModel": "Selecione um modelo primeiro",
"connectFailed": "Falha ao conectar: {{message}}"
}
},
"history": {
"title": "Histórico",
"empty": "Nenhum histórico ainda",
"deleteEntry": "Excluir entrada",
"clear": "Limpar histórico",
"clearTitle": "Limpar todo o histórico",
"clearMessage": "Remover todas as entradas do histórico? Esta ação não pode ser desfeita.",
"clearConfirm": "Limpar",
"cleared": "Histórico limpo"
},
"request": {
"heading": "Requisição",
"copyCurl": "Copiar como curl",
"copied": "Copiado"
}
}
@@ -0,0 +1,38 @@
{
"title": {
"add": "Adicionar Modelo",
"edit": "Editor de Modelo"
},
"subtitle": {
"chooseModelType": "Escolha um tipo de modelo para começar",
"newModel": "Novo modelo"
},
"actions": {
"backTo": "Voltar para {{page}}",
"system": "Sistema",
"templates": "Modelos",
"createModel": "Criar Modelo",
"saveChanges": "Salvar Alterações",
"saving": "Salvando...",
"saved": "Salvo",
"switchWarning": "Salve ou descarte as alterações antes de alternar as abas.",
"discardAndSwitch": "Descartar e Alternar"
},
"tabs": {
"interactive": "Interativo",
"yaml": "YAML",
"yamlDescription": "Edite o YAML diretamente. O nome do modelo deve ser definido no YAML para que a criação funcione."
},
"forms": {
"modelName": {
"label": "Nome do Modelo",
"placeholder": "meu-nome-de-modelo",
"hint": "Use apenas letras, números, hifens, sublinhados e pontos."
},
"empty": {
"nav": "Use a barra de busca acima para adicionar campos",
"title": "Nenhum campo configurado",
"text": "Use a barra de busca acima para encontrar e adicionar campos de configuração."
}
}
}
@@ -0,0 +1,187 @@
{
"title": "Descobrir",
"subtitle": "Explore e instale modelos de IA da galeria",
"models": "Modelos",
"recommended": {
"title": "Recomendados para o seu hardware",
"cpuNote": "Nenhuma GPU detectada - modelos pequenos que permanecem responsivos na CPU.",
"gpuNote": "Dimensionados para caber na sua VRAM disponível com espaço para o contexto.",
"install": "Instalar",
"installing": "Instalando",
"installStarted": "Instalando {{model}}…",
"installFailed": "Falha na instalação: {{message}}",
"dismiss": "Dispensar recomendações",
"summary": "{{n}} modelos sugeridos",
"bestFit": "Melhor ajuste",
"alternative": "Também serve"
},
"stats": {
"available": "Disponíveis",
"installed": "Instalados"
},
"actions": {
"addModel": "Adicionar Modelo",
"importModel": "Importar Modelo",
"install": "Instalar",
"reinstall": "Reinstalar",
"delete": "Excluir"
},
"filters": {
"all": "Todos",
"llm": "Chat",
"image": "Imagem",
"video": "Vídeo",
"threed": "3D",
"multimodal": "Multimodal",
"vision": "Visão",
"tts": "TTS",
"stt": "STT",
"diarization": "Diarização",
"soundClassification": "Classificação de Som",
"soundGen": "Som",
"audioTransform": "FX de Áudio",
"realtimeAudio": "Áudio em Tempo Real",
"embedding": "Embeddings",
"rerank": "Rerank",
"detection": "Detecção",
"vad": "VAD",
"ner": "NER",
"fitsGpu": "Cabe na GPU",
"collapseVariants": "Uma linha por modelo",
"allBackends": "Todos os Backends",
"searchBackends": "Buscar backends...",
"contextSize": "Contexto:",
"useCaseLabel": "Filtrar por caso de uso",
"unavailableForBackend": "Não disponível para o backend selecionado",
"someSelected": "{{count}} selecionados",
"refineLabel": "Refinar"
},
"search": {
"placeholder": "Buscar modelos...",
"clearFilters": "Limpar filtros"
},
"table": {
"modelName": "Nome do Modelo",
"description": "Descrição",
"backend": "Backend",
"sizeVram": "Tamanho / VRAM",
"status": "Status",
"actions": "Ações",
"size": "Tamanho: {{size}}",
"vram": "VRAM: {{vram}}",
"fits": "Cabe",
"mayNotFit": "Pode não caber",
"trustRemoteCode": "Confiar em Código Remoto",
"installing": "Instalando",
"installingPct": "Instalando · {{percent}}%",
"installed": "Instalado",
"notInstalled": "Não Instalado"
},
"detail": {
"description": "Descrição",
"gallery": "Galeria",
"backend": "Backend",
"size": "Tamanho",
"vram": "VRAM",
"license": "Licença",
"tags": "Tags",
"links": "Links",
"warning": "Aviso",
"files": "Arquivos",
"fitsGpu": "Cabe na GPU",
"mayNotFitGpu": "Pode não caber na GPU",
"requiresTrustRemoteCode": "Requer Confiança em Código Remoto",
"fileCount_one": "{{count}} arquivo",
"fileCount_other": "{{count}} arquivos",
"filename": "Nome do arquivo",
"uri": "URI",
"sha256": "SHA256",
"backToAll": "Todos os modelos",
"vramAt": "VRAM em {{context}}",
"headroom": "Margem de sobra"
},
"empty": {
"title": "Nenhum modelo encontrado",
"withFilters": "Nenhum modelo corresponde à sua busca ou filtros atuais.",
"collapsedVariantsHint": "Construções alternativas que outra entrada já oferece estão ocultas. Desative \"Uma linha por modelo\" para incluí-las.",
"noFilters": "A galeria de modelos está vazia."
},
"deleteDialog": {
"title": "Excluir Modelo",
"message": "Excluir o modelo {{model}}?",
"confirm": "Excluir {{model}}",
"deletingToast": "Excluindo {{model}}..."
},
"errors": {
"loadFailed": "Falha ao carregar modelos: {{message}}",
"installFailed": "Falha na instalação: {{message}}",
"deleteFailed": "Falha ao excluir: {{message}}"
},
"selector": {
"loading": "Carregando modelos...",
"selectModel": "Selecionar modelo...",
"searchPlaceholder": "Buscar modelos...",
"noModels": "Nenhum modelo disponível"
},
"variants": {
"title": "Variantes",
"chooseVariant": "Escolha uma variante",
"auto": "Automática",
"autoSelected": "Selecionada automaticamente",
"base": "Construção base",
"doesNotFit": "Não cabe",
"unknownSize": "Tamanho desconhecido",
"unknownBackend": "Backend desconhecido",
"loading": "Carregando variantes...",
"installVariant": "Instalar {{variant}}",
"quantizationTitle": "Formato dos pesos",
"unknownQuantization": "Formato desconhecido",
"showDetails": "Mostrar detalhes completos de {{variant}}",
"hideDetails": "Ocultar detalhes completos de {{variant}}",
"detailsLoading": "Carregando detalhes...",
"detailsUnavailable": "Os detalhes de {{variant}} não puderam ser carregados.",
"features": {
"dflash": "Mais rápido: DFlash",
"mtp": "Mais rápido: MTP"
}
},
"rail": {
"sortLabel": "Ordenar modelos",
"downloadingPct": "baixando {{percent}}%",
"tooLarge": "{{size}} · muito grande",
"fitsSize": "{{size}} · cabe",
"previousPage": "Página anterior",
"nextPage": "Próxima página",
"showingCount": "{{shown}} de {{total}}",
"sizing": "medindo…"
},
"groups": {
"text": "Texto e raciocínio",
"vision": "Visão",
"audio": "Fala e áudio",
"visual": "Imagem e vídeo",
"other": "Todo o resto"
},
"chart": {
"title": "VRAM por tamanho de contexto",
"available": "{{vram}} disponíveis",
"barTitle": "o contexto {{context}} precisa de {{vram}}",
"fitsEverywhere": "Executa em qualquer tamanho de contexto neste host.",
"fitsNowhere": "Não caberá neste host em nenhum tamanho de contexto.",
"fitsUpTo": "Cabe em contextos de até {{context}}."
},
"shelves": {
"hostLabel": "Seu host",
"heroWithGpu": "{{vram}} de memória GPU, {{count}} modelos na galeria.",
"heroNoGpu": "{{count}} modelos na galeria.",
"heroHint": "Escolha qualquer item à esquerda para ver seu tamanho, variantes e se ele executará aqui.",
"browsing": "Explorando",
"pickHint": "Selecione um modelo para ver seus detalhes.",
"heroWithRam": "{{ram}} de memória do sistema, {{count}} modelos na galeria.",
"byUseCase": "Ou comece por um caso de uso",
"pickText": "Chat, raciocínio, embeddings",
"pickVision": "Leia imagens e documentos",
"pickAudio": "Fala de entrada e saída",
"pickVisual": "Gere imagens e vídeos"
}
}
@@ -0,0 +1,78 @@
{
"appName": "LocalAI",
"openMenu": "Abrir menu",
"closeMenu": "Fechar menu",
"primaryNavigation": "Navegação principal",
"switchToLightMode": "Alternar para o modo claro",
"switchToDarkMode": "Alternar para o modo escuro",
"expandSidebar": "Expandir barra lateral",
"collapseSidebar": "Recolher barra lateral",
"changeLanguage": "Alterar idioma",
"logout": "Sair",
"accountSettings": "Configurações da conta",
"account": "Conta",
"accountFor": "Conta: {{name}}",
"sections": {
"create": "Criar",
"recognition": "Reconhecimento",
"build": "Construir",
"operate": "Operar"
},
"operate": {
"inference": "Inferência",
"cluster": "Cluster",
"observability": "Observabilidade",
"access": "Acesso",
"system": "Sistema",
"activity": "Atividade",
"runtime": "Runtime",
"administration": "Administração"
},
"items": {
"home": "Início",
"discover": "Descobrir",
"chat": "Chat",
"studio": "Studio",
"talk": "Falar",
"fineTune": "Ajuste Fino",
"quantize": "Quantizar",
"faces": "Rostos",
"voices": "Vozes",
"jobs": "Trabalhos",
"operate": "Admin",
"host": "Host",
"audioTransform": "Transformação de Áudio",
"faceRecognition": "Reconhecimento Facial",
"voiceRecognition": "Reconhecimento de Voz",
"agents": "Agentes",
"skills": "Habilidades",
"memory": "Memória",
"mcpJobs": "Trabalhos de CI MCP",
"usage": "Uso",
"users": "Usuários",
"middleware": "Middleware",
"backends": "Backends",
"voiceLibrary": "Biblioteca de Vozes",
"traces": "Rastreamentos",
"nodes": "Nós",
"scheduling": "Agendamento",
"swarm": "Swarm",
"system": "Sistema",
"settings": "Configurações",
"api": "API",
"activity": "Atividade",
"overview": "Visão geral"
},
"footer": {
"github": "GitHub",
"documentation": "Documentação",
"author": "Autor",
"copyright": "© 2023-{{year}} {{author}}"
},
"console": {
"automation": "Automação",
"training": "Treinamento",
"expandNavigation": "Expandir navegação de {{section}}",
"collapseNavigation": "Recolher navegação de {{section}}"
}
}
@@ -0,0 +1,79 @@
{
"title": "Habilidades",
"subtitle": "Gerenciar habilidades de agentes (instruções e recursos reutilizáveis)",
"unavailable": {
"subtitle": "O serviço de habilidades não está disponível ou o índice está sendo reconstruído. Tente novamente em um momento.",
"retry": "Tentar novamente"
},
"actions": {
"newSkill": "Nova habilidade",
"createSkill": "Criar habilidade",
"import": "Importar",
"importing": "Importando...",
"gitRepos": "Repositórios Git",
"edit": "Editar",
"delete": "Excluir",
"export": "Exportar",
"sync": "Sincronizar",
"addRepo": "Adicionar repositório",
"adding": "Adicionando...",
"remove": "Remover",
"enable": "Habilitar",
"disable": "Desabilitar"
},
"search": {
"placeholder": "Buscar habilidades..."
},
"git": {
"title": "Repositórios Git",
"description": "Adicione repositórios Git para obter habilidades. As habilidades aparecerão na lista após a sincronização.",
"urlPlaceholder": "https://github.com/usuário/repo ou git@github.com:usuário/repo.git",
"noRepos": "Nenhum repositório Git configurado. Adicione um acima.",
"disabled": "Desabilitado",
"removeRepo": "Remover repositório"
},
"card": {
"noDescription": "Sem descrição",
"readOnly": "Somente leitura",
"editTitle": "Editar habilidade",
"deleteTitle": "Excluir habilidade",
"exportTitle": "Exportar como .tar.gz"
},
"empty": {
"title": "Nenhuma habilidade encontrada",
"text": "Crie uma habilidade ou importe uma para começar.",
"noPersonal": "Você ainda não tem habilidades."
},
"sections": {
"yourSkills": "Suas Habilidades",
"otherUsersSkills": "Habilidades de Outros Usuários"
},
"deleteDialog": {
"title": "Excluir Habilidade",
"message": "Excluir a habilidade \"{{name}}\"? Esta ação não pode ser desfeita.",
"confirm": "Excluir"
},
"removeRepoDialog": {
"title": "Remover Repositório Git",
"message": "Remover este repositório Git? As habilidades dele não estarão mais disponíveis.",
"confirm": "Remover"
},
"toasts": {
"loadFailed": "Falha ao carregar habilidades",
"deleted": "Habilidade \"{{name}}\" excluída",
"deleteFailed": "Falha ao excluir a habilidade",
"exported": "Habilidade \"{{name}}\" exportada",
"exportFailed": "Falha na exportação",
"imported": "Habilidade importada de \"{{file}}\"",
"importFailed": "Falha na importação",
"loadReposFailed": "Falha ao carregar repositórios Git",
"repoAdded": "Repositório Git adicionado e sincronizando",
"addRepoFailed": "Falha ao adicionar o repositório",
"synced": "Repositório sincronizado",
"syncFailed": "Falha na sincronização",
"toggled": "Repositório alternado",
"toggleFailed": "Falha ao alternar",
"removed": "Repositório removido",
"removeFailed": "Falha na remoção"
}
}
+1
View File
@@ -12,6 +12,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: 'zh-CN', name: '简体中文', flag: 'ZH' },
{ code: 'id', name: 'Bahasa Indonesia', flag: 'ID' },
{ code: 'ko', name: '한국어', flag: 'KO' },
{ code: 'pt-BR', name: 'Português (Brasil)', flag: 'BR' },
]
export const NAMESPACES = [
@@ -394,6 +394,9 @@ export default function Settings() {
<SettingRow label="Default Context Size" description="Default context window size for models">
<input className="input col-w-120" type="number" value={settings.context_size ?? ''} onChange={(e) => update('context_size', parseInt(e.target.value) || 0)} placeholder="2048" />
</SettingRow>
<SettingRow label="Artifact Download Concurrency" description="Maximum artifact files downloaded at once. 1 downloads sequentially.">
<input aria-label="Artifact Download Concurrency" className="input" type="number" min="1" style={{ width: 120 }} value={settings.artifact_download_concurrency ?? 1} onChange={(e) => update('artifact_download_concurrency', Math.max(1, parseInt(e.target.value) || 1))} />
</SettingRow>
<SettingRow label="VRAM Budget" description="Cap VRAM used for model allocation on this node. Percentage (e.g. 80%) or absolute (e.g. 12GB). Empty uses all detected VRAM.">
<input className="input col-w-120" type="text" value={settings.vram_budget ?? ''} onChange={(e) => update('vram_budget', e.target.value)} placeholder="e.g. 80% or 12GB" />
</SettingRow>
+25 -2
View File
@@ -194,6 +194,16 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
texts := adapter.Scan(parsed)
updates := make([]ScannedText, 0, len(texts))
prefix, suffix := defaultReversibleTokenPrefix, defaultReversibleTokenSuffix
if cfg, ok := rawCfg.(responsePIIConfig); ok {
if cfg.PIIReversibleTokenPrefix() != "" {
prefix = cfg.PIIReversibleTokenPrefix()
}
if cfg.PIIReversibleTokenSuffix() != "" {
suffix = cfg.PIIReversibleTokenSuffix()
}
}
pseudonyms := newPseudonymizer(prefix, suffix)
var blocked bool
var firstEventID string
@@ -259,7 +269,11 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
if res.Blocked {
blocked = true
}
updates = append(updates, ScannedText{Index: st.Index, Text: res.Redacted})
redacted := res.Redacted
if cfg, ok := rawCfg.(responsePIIConfig); ok && cfg.PIIReversibleRedactions() {
redacted = pseudonyms.replace(st.Text, res.Spans)
}
updates = append(updates, ScannedText{Index: st.Index, Text: redacted})
}
if blocked {
@@ -279,7 +293,16 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
if firstEventID != "" {
c.Set(ctxKeyPIIEventID, firstEventID)
}
return next(c)
if len(pseudonyms.original) == 0 {
return next(c)
}
writer := newRestoringWriter(c.Response().Writer, pseudonyms.original)
c.Response().Writer = writer
err := next(c)
if finishErr := writer.Finish(); err == nil {
err = finishErr
}
return err
}
}
}
+60 -2
View File
@@ -60,10 +60,16 @@ func setRequestOnContext(req *fakeRequest) echo.MiddlewareFunc {
type fakeModelPIIConfig struct {
enabled bool
detectors []string
reverse bool
prefix string
suffix string
}
func (f fakeModelPIIConfig) PIIIsEnabled() bool { return f.enabled }
func (f fakeModelPIIConfig) PIIDetectors() []string { return f.detectors }
func (f fakeModelPIIConfig) PIIIsEnabled() bool { return f.enabled }
func (f fakeModelPIIConfig) PIIDetectors() []string { return f.detectors }
func (f fakeModelPIIConfig) PIIReversibleRedactions() bool { return f.reverse }
func (f fakeModelPIIConfig) PIIReversibleTokenPrefix() string { return f.prefix }
func (f fakeModelPIIConfig) PIIReversibleTokenSuffix() string { return f.suffix }
func withModelConfig(cfg fakeModelPIIConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -129,6 +135,58 @@ var _ = Describe("RequestMiddleware (NER)", func() {
Expect(events[0].Direction).To(Equal(DirectionIn))
})
It("restores distinct pseudonyms across streaming write boundaries", func() {
body := &fakeRequest{Messages: []string{"Email alice@example.com or bob@example.com"}}
mw := RequestMiddleware(&Redactor{}, store(), fakeAdapter(), nil,
WithNERResolver(resolverFor(map[string]NERConfig{
"privacy-filter": nerCfg(ActionMask,
NEREntity{Group: "EMAIL", Start: 6, End: 23, Score: 0.95},
NEREntity{Group: "EMAIL", Start: 27, End: 42, Score: 0.95}),
})))
e := echo.New()
e.POST("/chat", func(c echo.Context) error {
Expect(body.Messages[0]).To(Equal("Email [REDACTED:EMAIL_001] or [REDACTED:EMAIL_002]"))
_, err := c.Response().Write([]byte(`data: {"delta":"EMAIL_001 and [REDACTED:EMAIL_0`))
Expect(err).ToNot(HaveOccurred())
_, err = c.Response().Write([]byte(`01] and [REDACTED:EMAIL_002]"}` + "\n\n"))
return err
}, setRequestOnContext(body), withModelConfig(fakeModelPIIConfig{
enabled: true, detectors: []string{"privacy-filter"}, reverse: true,
}), mw)
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{}`))
w := httptest.NewRecorder()
e.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(Equal("data: {\"delta\":\"EMAIL_001 and alice@example.com and bob@example.com\"}\n\n"))
})
It("uses configured reversible redaction token delimiters", func() {
body := &fakeRequest{Messages: []string{"Email alice@example.com"}}
mw := RequestMiddleware(&Redactor{}, store(), fakeAdapter(), nil,
WithNERResolver(resolverFor(map[string]NERConfig{
"privacy-filter": nerCfg(ActionMask,
NEREntity{Group: "EMAIL", Start: 6, End: 23, Score: 0.95}),
})))
e := echo.New()
e.POST("/chat", func(c echo.Context) error {
Expect(body.Messages[0]).To(Equal("Email <PII:EMAIL_001>"))
_, err := c.Response().Write([]byte(`{"text":"<PII:EMAIL_001>"}`))
return err
}, setRequestOnContext(body), withModelConfig(fakeModelPIIConfig{
enabled: true, detectors: []string{"privacy-filter"}, reverse: true,
prefix: "<PII:", suffix: ">",
}), mw)
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{}`))
w := httptest.NewRecorder()
e.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(Equal(`{"text":"alice@example.com"}`))
})
It("blocks (400) when a detected entity's action is block", func() {
st := store()
body := &fakeRequest{Messages: []string{"my password is hunter2 ok"}}
+142
View File
@@ -0,0 +1,142 @@
package pii
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"unicode"
)
type responsePIIConfig interface {
PIIReversibleRedactions() bool
PIIReversibleTokenPrefix() string
PIIReversibleTokenSuffix() string
}
const (
defaultReversibleTokenPrefix = "[REDACTED:"
defaultReversibleTokenSuffix = "]"
)
type pseudonymizer struct {
byValue map[string]string
original map[string]string
counts map[string]int
prefix string
suffix string
}
func newPseudonymizer(prefix, suffix string) *pseudonymizer {
return &pseudonymizer{
byValue: map[string]string{},
original: map[string]string{},
counts: map[string]int{},
prefix: prefix,
suffix: suffix,
}
}
func (p *pseudonymizer) replace(text string, spans []Span) string {
var b strings.Builder
last := 0
for _, span := range spans {
if span.Action != ActionMask || span.Start < last || span.End > len(text) {
continue
}
b.WriteString(text[last:span.Start])
value := text[span.Start:span.End]
token, ok := p.byValue[value]
if !ok {
group := pseudonymGroup(span.Pattern)
p.counts[group]++
token = fmt.Sprintf("%s%s_%03d%s", p.prefix, group, p.counts[group], p.suffix)
p.byValue[value] = token
p.original[token] = value
}
b.WriteString(token)
last = span.End
}
b.WriteString(text[last:])
return b.String()
}
func pseudonymGroup(pattern string) string {
if i := strings.LastIndexByte(pattern, ':'); i >= 0 {
pattern = pattern[i+1:]
}
var b strings.Builder
for _, r := range strings.ToUpper(pattern) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
if b.Len() == 0 {
return "PII"
}
return b.String()
}
type restoringWriter struct {
http.ResponseWriter
pending string
replacements map[string]string
}
func newRestoringWriter(w http.ResponseWriter, originals map[string]string) *restoringWriter {
replacements := make(map[string]string, len(originals))
for token, original := range originals {
encoded, _ := json.Marshal(original)
replacements[token] = string(encoded[1 : len(encoded)-1])
}
return &restoringWriter{ResponseWriter: w, replacements: replacements}
}
func (w *restoringWriter) Write(data []byte) (int, error) {
w.pending += string(data)
ready, pending := w.splitReady(w.replace(w.pending))
w.pending = pending
if ready != "" {
if _, err := w.ResponseWriter.Write([]byte(ready)); err != nil {
return 0, err
}
}
return len(data), nil
}
func (w *restoringWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *restoringWriter) Finish() error {
if w.pending == "" {
return nil
}
_, err := w.ResponseWriter.Write([]byte(w.replace(w.pending)))
w.pending = ""
return err
}
func (w *restoringWriter) replace(s string) string {
for token, original := range w.replacements {
s = strings.ReplaceAll(s, token, original)
}
return s
}
func (w *restoringWriter) splitReady(s string) (string, string) {
keep := 0
for token := range w.replacements {
limit := min(len(token)-1, len(s))
for n := 1; n <= limit; n++ {
if strings.HasSuffix(s, token[:n]) && n > keep {
keep = n
}
}
}
return s[:len(s)-keep], s[len(s)-keep:]
}
+1 -1
View File
@@ -172,7 +172,7 @@ LocalAI supports various types of backends:
- **Text-to-Speech Backends**: For speech synthesis (e.g., piper, Kokoro, VibeVoice, Qwen3-TTS, [NeMo-Speech.cpp]({{%relref "features/nemo-speech-cpp" %}}), [audio.cpp]({{%relref "features/audio-cpp" %}}))
- **Sound Generation Backends**: For music and audio generation (e.g., ACE-Step, [audio.cpp]({{%relref "features/audio-cpp" %}}))
- **Sound Classification Backends**: For sound-event classification / audio tagging - identifying everyday sounds like baby cry, glass breaking, alarms (e.g., ced.cpp)
- **Image & Video Generation Backends**: For diffusion and audio-conditioned avatar models (e.g., stable-diffusion.cpp, diffusers, vLLM-Omni, [LongCat-Video]({{%relref "features/video-generation" %}}))
- **Image & Video Generation Backends**: For diffusion and audio-conditioned avatar models (e.g., stable-diffusion.cpp, diffusers, vLLM-Omni, [LongCat-Video]({{%relref "features/video-generation" %}}), [vllm.cpp / MiniMax-H3]({{%relref "features/video-generation" %}}))
- **3D Generation Backends**: For image-to-3D mesh generation ([trellis2.cpp]({{%relref "features/3d-generation" %}}) — Microsoft TRELLIS.2, producing GLB assets with PBR textures)
- **Vision & Detection Backends**: For object detection, segmentation, depth, and face/voice recognition (e.g., rf-detr.cpp, locate-anything.cpp, sam3.cpp, insightface)
- **Audio Processing Backends**: For voice activity detection and audio enhancement (e.g., Silero VAD, LocalVQE, [audio.cpp]({{%relref "features/audio-cpp" %}}))
+2 -1
View File
@@ -49,6 +49,7 @@ You can configure these settings via the web UI or through environment variables
- **Threads**: Number of threads used for parallel computation (recommended: number of physical cores)
- **Context Size**: Default context size for models (default: `512`)
- **Artifact Download Concurrency**: Maximum number of artifact files downloaded at once. `1` downloads sequentially (default: `1`)
- **F16**: Enable GPU acceleration using 16-bit floating point
- **VRAM Budget**: Cap on VRAM used for model allocation (for example `80%` or `12GB`; empty means no cap). See [VRAM Management]({{%relref "advanced/vram-management" %}})
@@ -138,6 +139,7 @@ The `runtime_settings.json` file follows this structure:
"lru_eviction_retry_interval": "1s",
"threads": 8,
"context_size": 2048,
"artifact_download_concurrency": 4,
"f16": false,
"debug": false,
"cors": true,
@@ -223,4 +225,3 @@ If P2P is not starting:
2. Check network connectivity
3. Ensure the P2P network ID matches across nodes (if using federated mode)
4. Review logs for P2P-related errors
+77
View File
@@ -685,6 +685,83 @@ The `cache_type_k` / `cache_type_v` fields map to llama.cpp's `-ctk` / `-ctv` fl
- [Tracked branch: `feature/turboquant-kv-cache`](https://github.com/TheTom/llama-cpp-turboquant/tree/feature/turboquant-kv-cache)
### buun-llama-cpp (DFlash speculative decoding + TurboQuant/TCQ KV-cache)
[buun-llama-cpp](https://github.com/spiritbuun/buun-llama-cpp) is a fork-of-a-fork: spiritbuun forked `TheTom/llama-cpp-turboquant` (the `turboquant` backend above) and added two independent features on top:
1. **DFlash** — a block-diffusion speculative decoding scheme that uses a dedicated drafter model (new `DFlashDraftModel` GGUF architecture). On a target/drafter pair it emits a block of tokens per speculation step and can be combined with tree-structured verification ("DDTree") for multi-branch draft expansion.
2. **TCQ (Trellis-Coded Quantization)** — two additional KV-cache types (`turbo2_tcq`, `turbo3_tcq`) on top of the TurboQuant `turbo2` / `turbo3` / `turbo4` already shipped by the parent fork, delivering 1044% KL reduction over scalar quantization at 23 bits per value.
Like `turboquant`, this backend shares LocalAI's stock `llama-cpp` gRPC server sources — so any GGUF model that runs on `llama-cpp` also runs on `buun-llama-cpp`. Pick it over `turboquant` specifically when you want DFlash speculative decoding or the newer TCQ KV-cache variants.
#### Features
- Drop-in GGUF compatibility with upstream `llama.cpp`.
- DFlash block-diffusion speculative decoding (CUDA/Metal; no CPU fallback).
- TurboQuant KV-cache types (`turbo2`, `turbo3`, `turbo4`) inherited from the parent `turboquant` fork, plus buun-exclusive `turbo2_tcq` and `turbo3_tcq` variants.
- Same feature surface as `llama-cpp`: text generation, embeddings, tool calls, multimodal via mmproj.
- Available on CPU (AVX/AVX2/AVX512/fallback), NVIDIA CUDA 12/13, AMD ROCm/HIP, Intel SYCL f32/f16, Vulkan, and NVIDIA L4T — but note that DFlash and `turbo*` KV types have no CPU fallback and error at model-load on CPU-only builds.
#### Setup
`buun-llama-cpp` ships as a separate container image in the LocalAI backend gallery. Install it like any other backend:
```bash
local-ai backends install buun-llama-cpp
```
Or pick a specific flavor for your hardware (example tags: `cpu-buun-llama-cpp`, `cuda12-buun-llama-cpp`, `cuda13-buun-llama-cpp`, `rocm-buun-llama-cpp`, `intel-sycl-f16-buun-llama-cpp`, `vulkan-buun-llama-cpp`).
#### YAML configuration — TCQ KV-cache
To run a model with TurboQuant/TCQ quantized KV-cache, set the backend and pick a `turbo*` cache type:
```yaml
name: my-model
backend: buun-llama-cpp
parameters:
model: file.gguf
# Accepted values for the two fork-aware backends include the stock llama.cpp
# types (f16, f32, q8_0, q4_0, q4_1, q5_0, q5_1), the TurboQuant types
# (turbo2, turbo3, turbo4), and the buun-only TCQ variants (turbo2_tcq,
# turbo3_tcq). turbo3 / turbo4 / turbo*_tcq auto-enable flash_attention.
cache_type_k: turbo3
cache_type_v: turbo3_tcq
context_size: 8192
```
#### YAML configuration — DFlash speculative decoding
DFlash requires a **dedicated drafter model** in the new `DFlashDraftModel` GGUF architecture. At time of writing the only known public target/drafter pair is [`z-lab/Qwen3.5-27B`](https://huggingface.co/z-lab/Qwen3.5-27B) + [`z-lab/Qwen3.5-27B-DFlash`](https://huggingface.co/z-lab/Qwen3.5-27B-DFlash).
```yaml
name: qwen3-dflash
backend: buun-llama-cpp
parameters:
# Target model (quantized as usual)
model: Qwen3.5-27B-Q4_K_M.gguf
# Drafter model produced by buun's convert_hf_to_gguf.py from the
# DFlashDraftModel checkpoint. Resolved relative to the models path.
draft_model: Qwen3.5-27B-DFlash.gguf
options:
# Switches the speculative pipeline from the default draft-model mode to
# DFlash (block-diffusion). Required to activate the DFlash code path.
- spec_type:dflash
# Optional tuning:
# - tree_budget:0 # 0 = flat DFlash; >0 = DDTree verification budget
# - draft_topk:1 # drafter top-K per position (1 = argmax)
# - spec_n_max:16 # cap on draft tokens per speculation step
```
Under the hood LocalAI wires `draft_model` through to the grpc-server's `params.speculative.mparams_dft.path`, and `spec_type:dflash` is forwarded through the options passthrough to buun's `common_speculative_type_from_name("dflash")`. The `tree_budget` and `draft_topk` options are buun-exclusive; they reference struct fields that only exist in buun's fork, so they're surfaced on this backend only (passing them to stock `llama-cpp` is a no-op).
#### Reference
- [spiritbuun/buun-llama-cpp](https://github.com/spiritbuun/buun-llama-cpp)
- [TCQ paper / dataset](https://huggingface.co/datasets/spiritbuun/turboquant-tcq-kv-cache) — *"Closing the Gap: Trellis-Coded Quantization for KV Cache at 2-3 Bits"*
- DFlash target/drafter pair: [`z-lab/Qwen3.5-27B`](https://huggingface.co/z-lab/Qwen3.5-27B) + [`z-lab/Qwen3.5-27B-DFlash`](https://huggingface.co/z-lab/Qwen3.5-27B-DFlash)
### vLLM
[vLLM](https://github.com/vllm-project/vllm) is a fast and easy-to-use library for LLM inference.
+155 -3
View File
@@ -6,7 +6,7 @@ url = "/features/video-generation/"
aliases = ["/features/longcat-video/"]
+++
LocalAI can generate videos from text prompts and optional image or audio conditioning via the `/video` endpoint. Supported backends include `diffusers`, `stablediffusion`, `vllm-omni`, and the dedicated `longcat-video` backend.
LocalAI can generate videos from text prompts and optional image or audio conditioning via the `/video` endpoint. Supported backends include `diffusers`, `stablediffusion`, `vllm-omni`, `vllm-cpp` (MiniMax-H3, which generates video **and** audio together), and the dedicated `longcat-video` backend.
## API
@@ -25,8 +25,8 @@ The request body is JSON with the following fields:
| `start_image` | `string` | No | | Starting image as base64 string or URL |
| `end_image` | `string` | No | | Ending image for guided generation |
| `audio` | `string` | No | | Audio conditioning as base64, a data URI, or URL |
| `width` | `int` | No | 512 | Video width in pixels |
| `height` | `int` | No | 512 | Video height in pixels |
| `width` | `int` | No | backend | Video width in pixels; omit it to get the model's own default canvas |
| `height` | `int` | No | backend | Video height in pixels; omit it to get the model's own default canvas |
| `num_frames` | `int` | No | | Number of frames |
| `fps` | `int` | No | | Frames per second |
| `seconds` | `string` | No | | Duration in seconds |
@@ -279,6 +279,158 @@ With distillation enabled, Avatar uses eight inference steps and fixed text/audi
- **Out of memory while loading**: use BF16 on unified-memory hardware, close other GPU workloads, or reduce model concurrency. INT8 is not guaranteed to reduce peak load memory.
- **Slow first request**: the backend and checkpoints are downloaded and loaded on demand; subsequent requests reuse the loaded pipeline.
## MiniMax-H3 (vllm.cpp)
The `vllm-cpp` backend — LocalAI's own C++ port of vLLM — also serves MiniMax-H3, which generates **video and audio jointly**. The clip comes back as an MP4 with a real AAC track rather than a silent render.
| Gallery model | Upstream checkpoint | Inputs | Output |
|---------------|---------------------|--------|--------|
| `minimax-h3-fl2va-q4` | `MiniMaxAI/MiniMax-H3`, Q4_K_M FL2VA partition | text, optional start/end frame | video with generated audio |
```bash
local-ai models install minimax-h3-fl2va-q4
```
{{% notice warning %}}
This is a large, slow model. The five weight files total roughly 40 GB, and generation was measured at about 176 seconds per denoise step at the default 1344x768 canvas on a 20-SM device — so the 50-step default is a **multi-hour** request, not a multi-second one. Nothing in the path imposes a deadline, but plan for a long-running HTTP call, and use a CUDA host.
{{% /notice %}}
### Ask for the sound
The model generates picture and sound from the same prompt, so a prompt that only describes what is *seen* produces room tone and ambience. To get speech, say that the character talks and put the words in the prompt:
```text
It is TALKING to the camera: its mouth moves clearly in sync with its speech,
in a dry, deadpan tone.
It says, clearly and audibly: "Michael scheduled another all-hands.
It is about the printer. Again."
Audio: a single clear voice, close-miked, with quiet room tone underneath.
```
### Geometry and clip length
The trained canvas is **1344x768 at 124 frames and 24 fps**, about 5.2 seconds, and that is what the gallery entry defaults to. Two rules the engine enforces:
- The canvas is truncated onto a 32-pixel grid.
- The frame count sits on a **17n+5** grid (…, 90, 107, 124, 141, …). A count off the grid is rounded up, and LocalAI logs the value it actually rendered.
The trained clip range is roughly 124 to 362 frames (about 5 to 15 seconds).
### Text-to-video with sound
```bash
curl http://localhost:8080/video \
-H "Content-Type: application/json" \
-d '{
"model": "minimax-h3-fl2va-q4",
"prompt": "A cyan llama mascot in a grey office chair, talking to the camera. It says, clearly and audibly: \"the printer is down again\". Audio: one clear close-miked voice.",
"num_frames": 124,
"step": 50,
"seed": 42
}'
```
### First-frame conditioning
`start_image` pins the supplied image as frame 0 (H3's `fl2va` task); `end_image` pins the last frame. LocalAI converts the upload to the binary PPM at the exact output canvas that the engine requires, using `ffmpeg`, so PNG and JPEG uploads work. When no `width`/`height` is given, the canvas is derived from the image's aspect on a 768-pixel short edge.
```bash
curl http://localhost:8080/video \
-H "Content-Type: application/json" \
-d "{
\"model\": \"minimax-h3-fl2va-q4\",
\"prompt\": \"the subject turns toward the camera and starts speaking\",
\"start_image\": \"$(base64 --wrap=0 portrait.png)\"
}"
```
### Partitions: what this checkpoint will and will not do
MiniMax-H3 ships as two DiT partitions, and the gallery entry installs **FL2VA**, which serves `t2va` (text only) and `fl2va` (first/last frame). Reference conditioning — a whole reference image, a reference clip, or reference audio — belongs to the separate **Ref2VA** checkpoint.
This matters because the mismatch does not fail cleanly upstream: a reference passed to an FL2VA DiT renders for hours and returns a coloured lattice over the frame. The backend refuses the combination up front instead, naming the partition. The community quantisations strip the release metadata and the two DiTs are byte-structurally identical, so the partition is *declared* in the model config (`video_partition`) rather than detected.
### ffmpeg is required on the host
The engine writes frames and a WAV and composes the `ffmpeg` command line; LocalAI runs it. That process boundary is deliberate upstream, so the backend image ships no `ffmpeg`: install one on the host, or point `options: [ffmpeg:/path/to/ffmpeg]` at a binary. Without it, generation succeeds and the mux fails with a message saying so.
### MiniMax-H3 model configuration
```yaml
name: minimax-h3-fl2va-q4
backend: vllm-cpp
cuda: true
known_usecases:
- video
known_input_modalities:
- text
- image
known_output_modalities:
- video
options:
- video_encoder:minimax-h3/qwen3vl-32B-MiniMax-H3-Q4_K_M.gguf
- video_tokenizer:minimax-h3/tokenizer.json
- video_vae:minimax-h3/video_vae.safetensors
- video_vae_config:minimax-h3/video_vae_config.json
- audio_vae:minimax-h3/audio_vae.safetensors
- audio_vae_config:minimax-h3/audio_vae_config.json
- video_partition:fl2va
- video_device:cuda
- video_dequant_bf16:true
- video_width:1344
- video_height:768
- video_num_frames:124
parameters:
model: minimax-h3/MiniMax-H3-FL2VA-Q4_K_M.gguf
```
`parameters.model` is the DiT. H3 is a checkpoint *set* rather than one model directory, so the encoder, the tokenizer and the two VAEs are named in `options`. Relative paths resolve against the models directory.
#### Load options
| Option | Default | Description |
|--------|---------|-------------|
| `video_encoder` | — | H3 text encoder (GGUF or a bf16 shard directory). Required unless `video_prompt_embeds` is set |
| `video_tokenizer` | — | `tokenizer.json` for the encoder |
| `video_vae` | — | Video VAE weights (`.safetensors`). Required |
| `video_vae_config` | `config.json` beside the weights | Carries `latents_mean` / `latents_std` and `clip_length` / `token_drop`; the decode is wrong without it |
| `audio_vae` | — | Audio VAE weights. Required |
| `audio_vae_config` | `config.json` beside the weights | As above, for audio |
| `video_prompt_embeds` | — | Pre-computed f32 conditioning, as an alternative to an encoder |
| `video_partition` | `fl2va` | `fl2va` or `ref2va`; must match the DiT you installed |
| `video_device` | `cpu`, or `cuda` when the config sets `cuda: true` | `cpu` or `cuda` |
| `video_dequant_bf16` | `false` | Dequantise and stream the DiT as bf16; what the Q4_K_M GGUF arm wants |
| `video_fp4_resident` | `false` | NVFP4 on CUDA: keep FP4 packed and use the Marlin W4A16 GEMM |
| `video_width` / `video_height` | 1344 / 768 in the gallery entry | Default canvas when the request omits it |
| `video_num_frames` | 124 in the gallery entry | Default clip length when the request omits it |
| `video_steps` | engine default (50) | Default denoise steps when the request omits it |
| `video_workdir` | a temporary directory | Where `frame_%06d.ppm` and `audio.wav` land. Set it to keep every run's frames |
| `video_crf` | 18 | x264 CRF for the mux |
| `ffmpeg` | `ffmpeg` from `PATH` | The mux binary |
#### Per-request parameters
The `/video` request's `params` object accepts string values. Unknown keys are rejected rather than ignored, so a typo does not cost you a multi-hour render of the wrong thing.
| Parameter | Description |
|-----------|-------------|
| `noise_aug` | Keyframe pinning strength; the default is 1.0 |
| `ref_image` | `ref2va` only: one whole reference image, as a binary PPM |
| `ref_video` | `ref2va` only: a directory of `frame_%06d.ppm` |
| `crf` | Per-request x264 CRF override |
`negative_prompt`, `cfg_scale` and `fps` have no MiniMax-H3 equivalent: H3 has no negative prompt or CFG scale, and it renders at a fixed frame rate that the audio track is synchronised to. Setting them is logged and ignored rather than silently honoured.
### MiniMax-H3 troubleshooting
- **`ffmpeg not found`**: install ffmpeg on the host or set `options: [ffmpeg:<path>]`. The frames and WAV are already rendered; only the mux failed.
- **`the FL2VA checkpoint serves t2va and fl2va only`**: you passed a reference image, clip or audio to the FL2VA DiT. Use `start_image` for first-frame conditioning, or install a Ref2VA checkpoint.
- **`video_partition must be "fl2va" or "ref2va"`**: the config declares something else.
- **`unknown params key`**: `params` accepts only the four keys above.
- **Text inside the frame comes out malformed**: this is the model's weakest area. Composite logos and signage in afterwards.
## Error Responses
| Status Code | Description |
+20 -2
View File
@@ -165,15 +165,33 @@ pii:
enabled: true # default-on for cloud-proxy; explicit for audit
detectors:
- privacy-filter-multilingual
reversible_redactions: true # restore request PII if the model echoes its wrapped token
reversible_token_prefix: "[REDACTED:" # optional; this is the default
reversible_token_suffix: "]" # optional; this is the default
```
`reversible_redactions` enables bijective, request-scoped replacement. Each
masked value is sent to the backend as a stable wrapped token such as
`[REDACTED:EMAIL_001]` instead of a generic redaction marker. If the model includes that token in
its response, LocalAI restores the original value before returning JSON or SSE
to the caller. The substitution map exists only for that request and is never
logged or persisted. Leave the option unset (the default) for irreversible
`[REDACTED:...]` masking.
The prefix and suffix reduce collisions with ordinary model output and can be
customized with `reversible_token_prefix` and `reversible_token_suffix`.
Reversible redactions provide less confidentiality than irreversible masking:
any third party that can observe both the redacted request and restored response
may be able to infer the original values.
Multiple detectors **union** their detections; overlapping spans resolve to
the strongest action (`block` > `mask` > `allow`). A configured detector
that can't be loaded **fails the request closed** (HTTP 503,
`error.type=pii_ner_unavailable`) rather than silently skipping the check.
The same NER path runs on the [MITM proxy]({{< relref "mitm-proxy.md" >}})
request body for intercepted hosts. Response/output redaction is out of
scope for now.
request body for intercepted hosts. Reversible response restoration currently
applies to LocalAI API routes; the MITM proxy keeps its own output-redaction
policy.
### Instance-wide default detector
+1
View File
@@ -28,6 +28,7 @@ Complete reference for all LocalAI command-line interface (CLI) parameters and e
| `--localai-config-dir` | `BASEPATH/configuration` | Directory for dynamic loading of certain configuration files (currently runtime_settings.json, api_keys.json, and external_backends.json). See [Runtime Settings]({{%relref "features/runtime-settings" %}}) for web-based configuration. | `$LOCALAI_CONFIG_DIR` |
| `--localai-config-dir-poll-interval` | | Time duration to poll the LocalAI Config Dir if your system has broken fsnotify events (example: `1m`) | `$LOCALAI_CONFIG_DIR_POLL_INTERVAL` |
| `--models-config-file` | | YAML file containing a list of model backend configs (alias: `--config-file`) | `$LOCALAI_MODELS_CONFIG_FILE`, `$CONFIG_FILE` |
| `--artifact-download-concurrency` | `1` | How many files of a model artifact to download at once. `1` downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume. Whole files only — a single file is never split, so resume and per-file checksum verification are unaffected | `$LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY` |
## Backend Flags
@@ -23,6 +23,7 @@ All backends listed here can be installed on demand from the [Backend Gallery]({
| [llama.cpp](https://github.com/ggerganov/llama.cpp) | LLM inference in C/C++. Supports LLaMA, Mamba, RWKV, Falcon, Starcoder, GPT-2, [and many others](https://github.com/ggerganov/llama.cpp?tab=readme-ov-file#description) | GPT, Functions | yes | yes | CPU, CUDA 12/13, ROCm, Intel SYCL, Vulkan, Metal, Jetson L4T |
| [ik_llama.cpp](https://github.com/ikawrakow/ik_llama.cpp) | Hard fork of llama.cpp optimized for CPU/hybrid CPU+GPU with IQK quants, custom quant mixes, and MLA for DeepSeek | GPT | yes | yes | CPU (AVX2+) |
| [turboquant](https://github.com/TheTom/llama-cpp-turboquant) | llama.cpp fork adding the TurboQuant KV-cache quantization scheme | GPT | yes | yes | CPU, CUDA 12/13, ROCm, Intel SYCL, Vulkan, Jetson L4T |
| [buun-llama-cpp](https://github.com/spiritbuun/buun-llama-cpp) | llama.cpp fork with DFlash block-diffusion speculative decoding and TurboQuant/TCQ KV-cache quantization (23 bits per value). Accelerated paths are CUDA/Metal only. | GPT, Functions | yes | yes | CUDA, Metal (CPU fallback for non-turbo/non-DFlash only) |
| [ds4](https://github.com/antirez/ds4) | DeepSeek V4 Flash single-model inference engine, optimized for Metal and CUDA | GPT | no | yes | CPU, CUDA 12/13, Metal, Jetson L4T |
| [vllm.cpp](https://github.com/mudler/vllm.cpp) | From-scratch C++20 port of vLLM by the LocalAI team: paged KV cache, continuous batching, prefix caching, safetensors + GGUF, engine-enforced structured output, no Python at inference | GPT, Functions | no | yes | CPU, CUDA 12/13 (Blackwell-family), Vulkan, Metal, Jetson L4T (GB10) |
| [vLLM](https://github.com/vllm-project/vllm) | Fast LLM serving with PagedAttention; GPTQ/AWQ/FP8 quantization | GPT, Functions, Multimodal | no | yes | CUDA 12/13, ROCm, Intel SYCL, Jetson L4T |
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "v4.8.1"
"version": "v4.8.2"
}
+96 -1
View File
@@ -2089,7 +2089,7 @@
files:
- filename: ds4flash.gguf
uri: https://huggingface.co/unsloth/DeepSeek-V4-Flash-GGUF
sha256: a9aadd5a1921708c97aecaf29e6b3d5c0aa252aadc3b706d1281f68361bd52b9
sha256: 492a2af8558781d9c494815ab7df5a47114ad1d836e4e0a13a3e8f0e5e791bff
- name: "qwopus3.6-35b-a3b-coder-mtp"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -8497,6 +8497,101 @@
- vae/**
parameters:
model: meituan-longcat/LongCat-Video-Avatar-1.5
- name: minimax-h3-fl2va-q4
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:
- https://huggingface.co/MiniMaxAI/MiniMax-H3
- https://huggingface.co/realrebelai/MiniMax-H3_GGUFs
- https://huggingface.co/lilcheaty/MiniMax-H3-NVFP4
- https://github.com/mudler/vllm.cpp
description: |
MiniMax-H3 served by vllm.cpp, LocalAI's own C++ port of vLLM. It generates
video AND audio jointly from a text prompt, so a clip comes back as an MP4
with a real soundtrack rather than a silent render: ask for speech in the
prompt and the model lip-syncs it.
This is the Q4_K_M quantisation of the FL2VA partition, which serves
text-to-video (t2va) and first/last-frame conditioning (fl2va). Reference
conditioning (ref2va) is a different checkpoint and is refused by this one.
Roughly 40 GB of weights across five files, plus the two VAE configs that
carry the latent statistics. The default canvas is 1344x768
at 124 frames and 24 fps, about 5.2 seconds. Generation is slow — measured
at roughly 176 s per denoise step at that canvas on a 20-SM device, so the
50-step default is a multi-hour job. Muxing the finished frames needs
ffmpeg on the host.
license: other
icon: https://huggingface.co/MiniMaxAI/MiniMax-H3/resolve/main/assets/minimax-h3.png
tags:
- text-to-video
- image-to-video
- video-generation
- audio-video-generation
- minimax-h3
- vllm-cpp
- cuda
- gpu
- dgx-spark
last_checked: "2026-08-09"
overrides:
backend: vllm-cpp
# The video engine has no auto device slot; this is what selects CUDA over
# the CPU queue, which for a multi-hour render is not a small difference.
cuda: true
known_usecases:
- video
known_input_modalities:
- text
- image
known_output_modalities:
- video
options:
# MiniMax-H3 is a checkpoint SET, not one model directory: parameters.model
# is the DiT and the rest of the set is named here.
- video_encoder:minimax-h3/qwen3vl-32B-MiniMax-H3-Q4_K_M.gguf
- video_tokenizer:minimax-h3/tokenizer.json
- video_vae:minimax-h3/video_vae.safetensors
- video_vae_config:minimax-h3/video_vae_config.json
- audio_vae:minimax-h3/audio_vae.safetensors
- audio_vae_config:minimax-h3/audio_vae_config.json
# The community GGUF strips the release metadata and the FL2VA and Ref2VA
# DiTs are byte-structurally identical, so the partition must be declared.
# This is the FL2VA checkpoint: t2va and fl2va, never ref2va.
- video_partition:fl2va
- video_device:cuda
# Q4_K_M streams up as bf16; keep-quant is for the NVFP4 arm.
- video_dequant_bf16:true
# H3's trained canvas and clip length. Frame counts sit on a 17n+5 grid.
- video_width:1344
- video_height:768
- video_num_frames:124
parameters:
model: minimax-h3/MiniMax-H3-FL2VA-Q4_K_M.gguf
files:
- filename: minimax-h3/MiniMax-H3-FL2VA-Q4_K_M.gguf
sha256: 5e8fa6e960d5fbd547390ceec63fcead275435d8f3bd2466a8a2cbd8c2e361e3
uri: huggingface://realrebelai/MiniMax-H3_GGUFs/MiniMax-H3-FL2VA-Q4_K_M.gguf
- filename: minimax-h3/qwen3vl-32B-MiniMax-H3-Q4_K_M.gguf
sha256: 1bf75e038c5895b97b6ea16cc1e3d32076254b06ec3df10657650d86dc82279e
uri: huggingface://realrebelai/MiniMax-H3_GGUFs/qwen3vl-32B-MiniMax-H3-Q4_K_M.gguf
- filename: minimax-h3/video_vae.safetensors
sha256: 7c1f131492e7eddacaac9069a61b81bdd39de5cc96561e677c5eab1cdce5e522
uri: huggingface://lilcheaty/MiniMax-H3-NVFP4/vae/minimax_h3_video_vae_fp16.safetensors
- filename: minimax-h3/audio_vae.safetensors
sha256: 37dddc2f3e6d5d5139d823d5ea283bbf304dadcb885b1ccda818aa13dade5ea2
uri: huggingface://MiniMaxAI/MiniMax-H3/FL2VA/audio_vae/model.safetensors
# Each VAE config carries its per-channel latents_mean / latents_std and the
# temporal clip_length / token_drop. The decode is wrong without them, so
# they ship with the weights rather than being optional extras.
- filename: minimax-h3/video_vae_config.json
sha256: 3edd2cdd1ebc823c868be55ef917e1b3b8a398fde4d3150dae44a3bf05d9f627
uri: huggingface://MiniMaxAI/MiniMax-H3/FL2VA/video_vae/config.json
- filename: minimax-h3/audio_vae_config.json
sha256: d8f3bcc62e23c7e9806970fa63cca6139c06faa3797cf9c94034f60db8512771
uri: huggingface://MiniMaxAI/MiniMax-H3/FL2VA/audio_vae/config.json
- filename: minimax-h3/tokenizer.json
sha256: a5d85b6dcc535e6b93115a9ef287e6132fdbf30270da6218194ba742261173c7
uri: huggingface://MiniMaxAI/MiniMax-H3/FL2VA/tokenizer/tokenizer.json
- name: vllm-omni-qwen3-omni-30b
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:
+66 -15
View File
@@ -2,8 +2,10 @@ package downloader
import (
"context"
"sync"
"github.com/mudler/xlog"
"golang.org/x/sync/errgroup"
)
// FileTask describes one download operation and an optional post-download
@@ -23,23 +25,72 @@ type FileTask struct {
// The helper centralizes the shared download path so callers only provide
// source/destination metadata and any post-download hook they need.
func DownloadFilesWithContext(ctx context.Context, tasks []FileTask, status func(string, string, string, float64), opts ...DownloadOption) error {
for i := range tasks {
task := tasks[i]
if err := ctx.Err(); err != nil {
return err
}
taskOpts := append([]DownloadOption{}, opts...)
taskOpts = append(taskOpts, task.Options...)
if err := downloadTaskWithRetry(ctx, task, status, taskOpts); err != nil {
return err
}
if task.AfterDownload != nil {
if err := task.AfterDownload(task.Destination); err != nil {
return err
}
return DownloadFilesWithConcurrency(ctx, tasks, status, 1, opts...)
}
// DownloadFilesWithConcurrency runs up to concurrency downloads at once. A
// concurrency of one or less keeps the original sequential path, so callers that
// have not opted in are byte-for-byte unaffected: tasks still run in slice order
// and the first failure still returns before any later task starts.
//
// Only whole files run in parallel. A single file is never split, so the
// .partial resume machinery and the per-file SHA check in downloadTaskWithRetry
// keep working untouched.
//
// The status callback is serialized, because it belongs to the caller and the
// sequential path gave it an implicit guarantee of never being entered twice at
// once. AfterDownload is deliberately *not* serialized: it does the per-file
// verify-and-promote work that parallelism is meant to overlap, so hooks must be
// safe to run concurrently with each other.
func DownloadFilesWithConcurrency(ctx context.Context, tasks []FileTask, status func(string, string, string, float64), concurrency int, opts ...DownloadOption) error {
if concurrency < 1 {
concurrency = 1
}
if status != nil && concurrency > 1 {
var statusMutex sync.Mutex
unsynchronized := status
status = func(fileName, current, total string, percent float64) {
statusMutex.Lock()
defer statusMutex.Unlock()
unsynchronized(fileName, current, total, percent)
}
}
return nil
// errgroup.WithContext cancels the derived context on the first error, which
// is what stops in-flight transfers instead of letting them run to
// completion, and Wait reports that first error rather than the
// context.Canceled the siblings observe.
group, groupCtx := errgroup.WithContext(ctx)
group.SetLimit(concurrency)
for i := range tasks {
task := tasks[i]
if err := groupCtx.Err(); err != nil {
break
}
group.Go(func() error {
if err := groupCtx.Err(); err != nil {
return err
}
taskOpts := append([]DownloadOption{}, opts...)
taskOpts = append(taskOpts, task.Options...)
if err := downloadTaskWithRetry(groupCtx, task, status, taskOpts); err != nil {
return err
}
if task.AfterDownload != nil {
return task.AfterDownload(task.Destination)
}
return nil
})
}
if err := group.Wait(); err != nil {
return err
}
// A caller-cancelled context with no task in flight leaves the group clean,
// so report the cancellation the sequential loop would have reported.
return ctx.Err()
}
// downloadTaskWithRetry fetches one file, retrying transient failures. Without
+151
View File
@@ -0,0 +1,151 @@
package downloader_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/pkg/downloader"
)
var _ = Describe("DownloadFilesWithConcurrency", func() {
// slowServer holds every request open until it has seen `hold` of them at
// once, or the client gives up. A sequential executor can never satisfy a
// hold above one, so this doubles as proof that parallelism really happens
// rather than just being configured.
slowServer := func(delay time.Duration) (*httptest.Server, *int32) {
var inFlight int32
var peak int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
current := atomic.AddInt32(&inFlight, 1)
for {
observed := atomic.LoadInt32(&peak)
if current <= observed || atomic.CompareAndSwapInt32(&peak, observed, current) {
break
}
}
time.Sleep(delay)
atomic.AddInt32(&inFlight, -1)
_, _ = w.Write([]byte("payload"))
}))
return server, &peak
}
tasksFor := func(server *httptest.Server, dir string, count int) []downloader.FileTask {
tasks := make([]downloader.FileTask, 0, count)
for i := 0; i < count; i++ {
tasks = append(tasks, downloader.FileTask{
URI: downloader.URI(fmt.Sprintf("%s/file-%d", server.URL, i)),
Destination: filepath.Join(dir, fmt.Sprintf("file-%d.bin", i)),
FileIndex: i,
TotalFiles: count,
})
}
return tasks
}
It("overlaps transfers up to the limit and no further", func() {
server, peak := slowServer(60 * time.Millisecond)
DeferCleanup(server.Close)
tasks := tasksFor(server, GinkgoT().TempDir(), 8)
err := downloader.DownloadFilesWithConcurrency(context.Background(), tasks, nil, 3)
Expect(err).NotTo(HaveOccurred())
Expect(*peak).To(BeNumerically(">", 1), "downloads never overlapped, so the limit was not applied")
Expect(*peak).To(BeNumerically("<=", 3), "more transfers ran at once than the configured limit")
})
It("keeps a concurrency of one strictly sequential", func() {
server, peak := slowServer(10 * time.Millisecond)
DeferCleanup(server.Close)
tasks := tasksFor(server, GinkgoT().TempDir(), 5)
err := downloader.DownloadFilesWithConcurrency(context.Background(), tasks, nil, 1)
Expect(err).NotTo(HaveOccurred())
Expect(*peak).To(Equal(int32(1)), "a limit of one must never overlap transfers")
})
It("treats a non-positive concurrency as sequential", func() {
server, peak := slowServer(10 * time.Millisecond)
DeferCleanup(server.Close)
tasks := tasksFor(server, GinkgoT().TempDir(), 4)
err := downloader.DownloadFilesWithConcurrency(context.Background(), tasks, nil, 0)
Expect(err).NotTo(HaveOccurred())
Expect(*peak).To(Equal(int32(1)))
})
It("reports the first hook error and stops starting new work", func() {
server, _ := slowServer(0)
DeferCleanup(server.Close)
var started int32
tasks := tasksFor(server, GinkgoT().TempDir(), 24)
for i := range tasks {
index := i
tasks[i].AfterDownload = func(string) error {
atomic.AddInt32(&started, 1)
if index == 0 {
return fmt.Errorf("verification failed for shard %d", index)
}
time.Sleep(20 * time.Millisecond)
return nil
}
}
err := downloader.DownloadFilesWithConcurrency(context.Background(), tasks, nil, 2)
Expect(err).To(MatchError(ContainSubstring("verification failed for shard 0")))
Expect(atomic.LoadInt32(&started)).To(BeNumerically("<", int32(len(tasks))),
"the executor kept starting work after a failure instead of cancelling")
})
It("returns the caller's cancellation rather than running the plan", func() {
server, _ := slowServer(0)
DeferCleanup(server.Close)
ctx, cancel := context.WithCancel(context.Background())
cancel()
var ran int32
tasks := tasksFor(server, GinkgoT().TempDir(), 3)
for i := range tasks {
tasks[i].AfterDownload = func(string) error {
atomic.AddInt32(&ran, 1)
return nil
}
}
err := downloader.DownloadFilesWithConcurrency(ctx, tasks, nil, 4)
Expect(err).To(MatchError(context.Canceled))
Expect(atomic.LoadInt32(&ran)).To(BeZero())
})
It("serializes the status callback so callers need no locking of their own", func() {
server, _ := slowServer(5 * time.Millisecond)
DeferCleanup(server.Close)
// A deliberately unsynchronized counter: if the executor let two
// callbacks in at once, -race would flag this write.
unguarded := 0
tasks := tasksFor(server, GinkgoT().TempDir(), 6)
err := downloader.DownloadFilesWithConcurrency(context.Background(), tasks, func(string, string, string, float64) {
unguarded++
}, 4)
Expect(err).NotTo(HaveOccurred())
Expect(unguarded).To(BeNumerically(">", 0))
})
})
+40 -6
View File
@@ -13,6 +13,7 @@ import (
"path"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"time"
@@ -51,6 +52,12 @@ const (
// the backend download the same repo in-band.
DefaultLockWait = 30 * time.Minute
// DefaultDownloadConcurrency keeps materialization sequential unless an
// operator opts in. Parallel transfers help a repo of many small shards on a
// fast link, but they multiply memory and disk pressure on the shared models
// volume, so the safe default is the behaviour this package already had.
DefaultDownloadConcurrency = 1
initialLockRetryInterval = 100 * time.Millisecond
maxLockRetryInterval = 5 * time.Second
)
@@ -65,6 +72,9 @@ type Manager struct {
// the process run that created it, and outliving that run is precisely what
// it must not do.
writerID string
// downloadConcurrency bounds how many of a snapshot's files transfer at
// once. One means the sequential behaviour this package shipped with.
downloadConcurrency atomic.Int64
}
type ManagerOption func(*Manager)
@@ -100,6 +110,25 @@ func WithLockWait(wait time.Duration) ManagerOption {
}
}
// WithDownloadConcurrency bounds how many of a snapshot's files are fetched at
// once. Values below one mean sequential, which is the default: a shared models
// volume is often the bottleneck rather than the network, so raising this is a
// deployment decision rather than something to assume.
func WithDownloadConcurrency(concurrency int) ManagerOption {
return func(manager *Manager) {
manager.SetDownloadConcurrency(concurrency)
}
}
// SetDownloadConcurrency updates the limit used by future file download
// batches. Values below one select the safe sequential default.
func (m *Manager) SetDownloadConcurrency(concurrency int) {
if concurrency < 1 {
concurrency = DefaultDownloadConcurrency
}
m.downloadConcurrency.Store(int64(concurrency))
}
func NewManager(resolver SnapshotResolver, options ...ManagerOption) *Manager {
manager := &Manager{
resolver: resolver,
@@ -107,6 +136,7 @@ func NewManager(resolver SnapshotResolver, options ...ManagerOption) *Manager {
lockWait: DefaultLockWait,
writerID: newWriterID(),
}
manager.SetDownloadConcurrency(DefaultDownloadConcurrency)
for _, option := range options {
option(manager)
}
@@ -376,7 +406,11 @@ func (m *Manager) materializeLocked(ctx context.Context, modelsPath string, spec
// read this manifest, and getting its order or contents wrong would make a
// corrupt tree look valid.
manifest := Manifest{Version: ManifestVersion, Artifact: spec, Files: make([]ManifestFile, len(snapshot.Files))}
completedBytes := int64(0)
// completedBytes is atomic because WithDownloadConcurrency lets several
// AfterDownload hooks add to it while other files' progress callbacks read
// it. Each hook still writes its own manifest.Files slot, so the manifest
// stays in snapshot order no matter which file finishes first.
completedBytes := new(atomic.Int64)
skippedFiles := 0
skippedBytes := int64(0)
tasks := make([]downloader.FileTask, 0, len(snapshot.Files))
@@ -398,7 +432,7 @@ func (m *Manager) materializeLocked(ctx context.Context, modelsPath string, spec
snapshotAbs := filepath.Join(layout.Partial, filepath.FromSlash(snapshotRel))
if entry, ok := reuseMaterializedFile(snapshotAbs, file); ok {
manifest.Files[taskIndex] = entry
completedBytes += file.Size
completedBytes.Add(file.Size)
skippedFiles++
skippedBytes += file.Size
continue
@@ -419,7 +453,7 @@ func (m *Manager) materializeLocked(ctx context.Context, modelsPath string, spec
Phase: PhaseDownloading,
Artifact: spec.Name,
File: file.Path,
CurrentBytes: completedBytes + event.Written,
CurrentBytes: completedBytes.Load() + event.Written,
TotalBytes: totalBytes,
CompletedFiles: taskIndex,
TotalFiles: len(snapshot.Files),
@@ -431,7 +465,7 @@ func (m *Manager) materializeLocked(ctx context.Context, modelsPath string, spec
Phase: PhaseVerifying,
Artifact: spec.Name,
File: file.Path,
CurrentBytes: completedBytes + file.Size,
CurrentBytes: completedBytes.Load() + file.Size,
TotalBytes: totalBytes,
CompletedFiles: taskIndex,
TotalFiles: len(snapshot.Files),
@@ -454,7 +488,7 @@ func (m *Manager) materializeLocked(ctx context.Context, modelsPath string, spec
return err
}
manifest.Files[taskIndex] = entry
completedBytes += file.Size
completedBytes.Add(file.Size)
return nil
},
}
@@ -471,7 +505,7 @@ func (m *Manager) materializeLocked(ctx context.Context, modelsPath string, spec
"remaining_files", len(tasks),
"total_files", len(snapshot.Files))
}
if err := downloader.DownloadFilesWithContext(ctx, tasks, nil); err != nil {
if err := downloader.DownloadFilesWithConcurrency(ctx, tasks, nil, int(m.downloadConcurrency.Load())); err != nil {
return Result{}, err
}
if err := root.RemoveAll(".downloads"); err != nil {
@@ -0,0 +1,180 @@
package modelartifacts_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
"github.com/mudler/LocalAI/pkg/modelartifacts"
)
var _ = Describe("artifact materialization with bounded download concurrency", func() {
// shardedSnapshot serves `count` distinct files and reports the peak number
// of simultaneous requests, so a test can tell configured concurrency from
// actual concurrency.
shardedSnapshot := func(count int, delay time.Duration) (hfapi.Snapshot, *httptest.Server, *int32) {
bodies := make(map[string][]byte, count)
files := make([]hfapi.SnapshotFile, 0, count)
var inFlight, peak int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
current := atomic.AddInt32(&inFlight, 1)
for {
observed := atomic.LoadInt32(&peak)
if current <= observed || atomic.CompareAndSwapInt32(&peak, observed, current) {
break
}
}
time.Sleep(delay)
atomic.AddInt32(&inFlight, -1)
_, _ = w.Write(bodies[r.URL.Path])
}))
for i := 0; i < count; i++ {
// Later shards are served first-come, so give them descending delays
// as well: completion order ends up unrelated to snapshot order,
// which is exactly what the manifest must survive.
body := []byte(fmt.Sprintf("shard-%02d-bytes", i))
urlPath := fmt.Sprintf("/shard-%02d", i)
bodies[urlPath] = body
sum := sha256.Sum256(body)
files = append(files, hfapi.SnapshotFile{
Path: fmt.Sprintf("shards/model-%02d.safetensors", i),
Size: int64(len(body)),
LFSOID: hex.EncodeToString(sum[:]),
URL: server.URL + urlPath,
})
}
return hfapi.Snapshot{
Endpoint: "https://huggingface.co", Repo: "owner/sharded",
RequestedRevision: "main", ResolvedRevision: "0123456789abcdef0123456789abcdef01234567",
Files: files,
}, server, &peak
}
spec := modelartifacts.Spec{Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/sharded"}}
It("records the manifest in snapshot order regardless of completion order", func() {
snapshot, server, peak := shardedSnapshot(12, 40*time.Millisecond)
DeferCleanup(server.Close)
manager := modelartifacts.NewManager(&fakeSnapshotResolver{snapshot: snapshot},
modelartifacts.WithDownloadConcurrency(4))
modelsPath := GinkgoT().TempDir()
result, err := manager.Ensure(context.Background(), modelsPath, spec)
Expect(err).NotTo(HaveOccurred())
Expect(*peak).To(BeNumerically(">", 1), "files never overlapped, so this proves nothing about ordering")
Expect(*peak).To(BeNumerically("<=", 4))
Expect(result.Manifest.Files).To(HaveLen(len(snapshot.Files)))
for i, file := range result.Manifest.Files {
Expect(file.Path).To(Equal(snapshot.Files[i].Path),
"manifest entry %d is out of snapshot order", i)
Expect(file.SHA256).To(HaveLen(64))
}
// Every shard must also be on disk, not merely recorded.
for _, file := range snapshot.Files {
onDisk := filepath.Join(modelsPath, filepath.FromSlash(result.RelativePath), filepath.FromSlash(file.Path))
info, statErr := os.Stat(onDisk)
Expect(statErr).NotTo(HaveOccurred())
Expect(info.Size()).To(Equal(file.Size))
}
})
It("produces the same manifest sequentially and concurrently", func() {
sequentialSnapshot, sequentialServer, _ := shardedSnapshot(8, 0)
DeferCleanup(sequentialServer.Close)
sequential, err := modelartifacts.NewManager(&fakeSnapshotResolver{snapshot: sequentialSnapshot}).
Ensure(context.Background(), GinkgoT().TempDir(), spec)
Expect(err).NotTo(HaveOccurred())
concurrentSnapshot, concurrentServer, _ := shardedSnapshot(8, 0)
DeferCleanup(concurrentServer.Close)
concurrent, err := modelartifacts.NewManager(&fakeSnapshotResolver{snapshot: concurrentSnapshot},
modelartifacts.WithDownloadConcurrency(8)).
Ensure(context.Background(), GinkgoT().TempDir(), spec)
Expect(err).NotTo(HaveOccurred())
Expect(concurrent.Manifest.Files).To(Equal(sequential.Manifest.Files))
})
It("applies live concurrency updates to subsequent materializations", func() {
snapshot, server, peak := shardedSnapshot(8, 40*time.Millisecond)
DeferCleanup(server.Close)
manager := modelartifacts.NewManager(&fakeSnapshotResolver{snapshot: snapshot})
manager.SetDownloadConcurrency(4)
_, err := manager.Ensure(context.Background(), GinkgoT().TempDir(), spec)
Expect(err).NotTo(HaveOccurred())
Expect(*peak).To(BeNumerically(">", 1))
Expect(*peak).To(BeNumerically("<=", 4))
})
It("still resumes past files an interrupted pass already completed", func() {
snapshot, server, _ := shardedSnapshot(6, 0)
DeferCleanup(server.Close)
var requests atomic.Int32
counting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
server.Config.Handler.ServeHTTP(w, r)
}))
DeferCleanup(counting.Close)
for i := range snapshot.Files {
snapshot.Files[i].URL = counting.URL + snapshot.Files[i].URL[len(server.URL):]
}
manager := modelartifacts.NewManager(&fakeSnapshotResolver{snapshot: snapshot},
modelartifacts.WithDownloadConcurrency(3))
modelsPath := GinkgoT().TempDir()
first, err := manager.Ensure(context.Background(), modelsPath, spec)
Expect(err).NotTo(HaveOccurred())
Expect(requests.Load()).To(Equal(int32(len(snapshot.Files))))
// A committed artifact is served from cache without touching the network.
second, err := manager.Ensure(context.Background(), modelsPath, first.Spec)
Expect(err).NotTo(HaveOccurred())
Expect(second.CacheHit).To(BeTrue())
Expect(requests.Load()).To(Equal(int32(len(snapshot.Files))))
})
It("fails the whole materialization when a shard cannot be verified", func() {
snapshot, server, _ := shardedSnapshot(6, 0)
DeferCleanup(server.Close)
// Corrupt one shard's expected digest: the download succeeds, the
// per-file SHA check does not.
snapshot.Files[3].LFSOID = hex.EncodeToString(make([]byte, 32))
manager := modelartifacts.NewManager(&fakeSnapshotResolver{snapshot: snapshot},
modelartifacts.WithDownloadConcurrency(3))
modelsPath := GinkgoT().TempDir()
_, err := manager.Ensure(context.Background(), modelsPath, spec)
Expect(err).To(HaveOccurred())
// Nothing may be published under the final path when a shard failed.
entries, readErr := os.ReadDir(filepath.Join(modelsPath, ".artifacts", "huggingface"))
if readErr == nil {
for _, entry := range entries {
_, statErr := os.Stat(filepath.Join(modelsPath, ".artifacts", "huggingface", entry.Name(), "manifest.json"))
Expect(statErr).To(HaveOccurred(), "a failed materialization published a manifest")
}
}
})
})
+6 -1
View File
@@ -78,6 +78,11 @@ export function inferBackendPath(item) {
// via a thin wrapper Makefile. Changes to either dir should retrigger it.
return `backend/cpp/turboquant/`;
}
if (item.dockerfile.endsWith("buun-llama-cpp")) {
// buun-llama-cpp is a llama.cpp fork that reuses backend/cpp/llama-cpp
// sources via a thin wrapper Makefile. Changes to either dir retrigger it.
return `backend/cpp/buun-llama-cpp/`;
}
if (item.dockerfile.endsWith("bonsai")) {
// bonsai is a llama.cpp fork that reuses backend/cpp/llama-cpp sources
// via a thin wrapper Makefile. Changes to either dir should retrigger it.
@@ -152,7 +157,7 @@ export function backendChanged(backend, pathPrefix, changedFiles) {
// Fork backends reuse backend/cpp/llama-cpp sources via thin wrappers;
// changes to either directory must retrigger their pipelines.
return (backend === "turboquant" || backend === "bonsai") &&
return (backend === "turboquant" || backend === "buun-llama-cpp" || backend === "bonsai") &&
changedFiles.some(file => file.startsWith("backend/cpp/llama-cpp/"));
}