Compare commits

...

24 Commits

Author SHA1 Message Date
Ettore Di Giacinto
79b48ac2e7 fix(watchdog): start the live watchdog on a cold enable from Settings (#9125)
The React Settings "Enable Watchdog" master toggle only ever writes the
idle/busy flags; watchdog_enabled is vestigial in that UI. The live
start/stop decision in UpdateSettingsEndpoint keyed off the raw, stale
watchdog_enabled request field, so a cold enable (idle/busy=true,
watchdog_enabled=false) called StopWatchdog() and the watchdog stayed
stopped until the next restart - at which point startup re-derived it
from the idle flag. Net: enabling the watchdog appeared to do nothing.

Derive the run-state from idle||busy as the single source of truth,
mirroring the startup invariant:

- ApplyRuntimeSettings now sets WatchDog = idle||busy whenever either
  field is present (so a full disable also brings it down), while an API
  client posting only watchdog_enabled keeps its explicit value.
- Add ApplicationConfig.WatchdogShouldRun() mirroring startWatchdog's
  gating (idle/busy, LRU eviction, memory reclaimer); the /api/settings
  handler uses it to decide start vs stop.
- Belt-and-suspenders: the Settings.jsx master toggle also writes
  watchdog_enabled = idle||busy.

Assisted-by: claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-12 21:48:59 +00:00
Aniruddh Jha
51f4f67c47 fix(agents): emit chat event timestamps in milliseconds (#9867) (#10243)
Agent chat replies rendered a broken timestamp in the web UI
("Invalid Timestamp" / "12:00 AM", identical for every reply) because
the SSE timestamp unit was inconsistent across producers.

EventBridge.PublishEvent emitted Unix nanoseconds while the local
dispatcher (dispatcher.go) already emitted Unix milliseconds, and the
React UI fed the value straight into `new Date(ts)` after dividing by
1e6. Nanoseconds also overflow JS's safe-integer range (~1.7e18).

Standardize on Unix milliseconds: switch PublishEvent to UnixMilli and
drop the /1e6 conversion in AgentChat.jsx so both SSE paths agree and
match the React UI's expectation. Add a regression test asserting the
published timestamp is in milliseconds.
2026-06-12 23:18:44 +02:00
LocalAI [bot]
cf71e291b4 fix(darwin): fix vibevoice-cpp build linkage + fail-safe go backend packaging (#10276)
* fix(darwin): never package a go backend build tree as a working image

The darwin/arm64 vibevoice-cpp image shipped the source tree with a
half-built CMake directory (build-libgovibevoicecpp-fallback.so/) and no
backend binary, so the backend could never start: run.sh exec'd a
vibevoice-cpp binary that was not in the package and LocalAI timed out
waiting for the gRPC service.

Two durable, backend-agnostic defenses:

- backend/go/vibevoice-cpp/Makefile: mirror whisper's cleanup discipline so a
  partial CMake tree cannot survive into packaging. Run `make purge` before
  each variant build and `rm -rfv build*` after. The old recipe only removed
  its build dir after a successful `mv`, so a failed build left the half-built
  tree behind.

- scripts/build/golang-darwin.sh: before creating the OCI image, remove any
  stray build-* directory and assert that the binary run.sh launches actually
  exists. A build that produced no binary now fails the job loudly instead of
  publishing a source tree as a working backend. The binary name is derived
  from run.sh's `exec $CURDIR/<binary>` line (parakeet-cpp launches
  parakeet-cpp-grpc, so it is not always ${BACKEND}) with a ${BACKEND}
  fallback.

The underlying native build failure that left vibevoice-cpp half-built still
needs to be reproduced and fixed on Apple Silicon; this change ensures such a
failure can never again be published as a working image.

Refs #10267

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

* fix(vibevoice-cpp): build libvibevoice.a on darwin (link target, not path)

The darwin build failed with:

    No rule to make target 'vibevoice/libvibevoice.a', needed by
    'libgovibevoicecpp.so'.  Stop.

The upstream vibevoice project is added with add_subdirectory(... EXCLUDE_FROM_ALL),
so its `vibevoice` static-library target is only built when something links it
as a target. The Apple branch linked only `$<TARGET_FILE:vibevoice>` - a bare
archive path with no target reference - so CMake never emitted a rule to build
libvibevoice.a, while the Linux branch worked because it passes the `vibevoice`
target name inside the --whole-archive flags.

Link the `vibevoice` target on Apple (establishing the build dependency) and
apply -force_load as a separate link option to keep whole-archive semantics so
purego can dlsym the vv_capi_* symbols.

Refs #10267

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-12 23:13:50 +02:00
LocalAI [bot]
a7a7bd646b fix(mlx): route vision-language models to the mlx-vlm backend (#10274)
Vision-language checkpoints such as mlx-community/gemma-4-E4B-it-qat-4bit
declare the "image-text-to-text" pipeline tag on HuggingFace. The mlx
importer hardcoded backend "mlx" for every mlx-community model, so these
VLMs were served by the text-only mlx-lm backend whose tokenizer does not
carry the processor chat template. The template was never applied and the
model produced degenerate, looping output that echoed the prompt.

Detect the "image-text-to-text" pipeline tag in the importer and route those
models to mlx-vlm, which applies the processor-aware chat template. An
explicit backend preference still wins.

As a defensive backstop, the mlx backend now warns loudly when the loaded
model has no chat template, so a misrouted VLM surfaces the problem instead
of silently looping.

Fixes #10269


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-12 23:12:42 +02:00
LocalAI [bot]
cec93d2e00 docs: ⬆️ update docs version mudler/LocalAI (#10279)
⬆️ 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-06-12 23:12:30 +02:00
LocalAI [bot]
722bdb87e9 chore: ⬆️ Update mudler/parakeet.cpp to b8012f11e5269126eddb7f4fd02f891a2ccc29b0 (#10281)
* ⬆️ Update mudler/parakeet.cpp

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

* fix(parakeet-cpp): close streaming segments on <EOB> after ABI v5 eou/eob split

parakeet.cpp ABI v5 (the pin this PR bumps to) splits the streaming JSON
"eou" flag: in v4 "eou":1 fired for either <EOU> (end of utterance) or
<EOB> (backchannel); in v5 "eou" means <EOU> only, with a new separate
"eob" field for the backchannel token.

The streamSegmenter closed a segment on "eou" alone, so after the bump a
backchannel token would silently stop ending a segment and merge into the
next utterance. Read the new "eob" field and flush on either signal to
preserve the v4 segmentation boundaries. The flat stream_feed eou_out path
is unaffected: its mask is still non-zero for either event.

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

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-12 23:12:04 +02:00
LocalAI [bot]
50dea8c983 feat(crispasr): bundle espeak-ng and add piper TTS voices to the gallery (#10283)
CrispASR's piper backend phonemizes non-English text via espeak-ng (dlopen,
the MIT-clean path; English uses a built-in G2P). The FROM scratch crispasr
image shipped none of it, so non-English piper voices loaded but failed
synthesis with "phonemization failed". Bundle the espeak-ng runtime so they
work:

- Dockerfile.golang: install espeak-ng-data + libespeak-ng1 and its libpcaudio0
  / libsonic0 deps in the crispasr builder (espeak's dlopen fails without the
  latter two).
- package.sh: copy libespeak-ng.so.1, libpcaudio.so.0, libsonic.so.0 into
  package/lib/ and the espeak-ng-data dir into the package root.
- run.sh: export CRISPASR_ESPEAK_DATA_PATH so the bundled data is found.

Add 9 single-speaker piper voices (de/en/it, incl. Italian paola + riccardo) to
the gallery, run through backend:piper, hosted at
LocalAI-Community/piper-voices-GGUF (converted from rhasspy/piper-voices with
CrispASR's convert-piper-to-gguf.py). Only single-speaker low/medium voices are
included; the engine does not yet support multi-speaker or high-quality piper
decoders.

All 9 verified end-to-end: each synthesizes a WAV at the model's native sample
rate using only the image-bundled espeak payload.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-12 23:10:30 +02:00
LocalAI [bot]
46ba70632b fix(crispasr): write piper TTS WAV at the model's native sample rate (#10277)
CrispASR's piper backend returns PCM at the voice's native rate (from the GGUF
piper.sample_rate key: 16 kHz for x_low/low, 22.05 kHz for medium/high) and does
not resample, but the Go WAV encoder hardcoded 24000 Hz. Every piper voice was
therefore written with a wrong header and played back at the wrong pitch/speed.

Read piper.sample_rate from the model's GGUF metadata at Load via the vendored
gguf-parser-go and use it for the WAV header, falling back to the 24 kHz default
for the other CrispASR TTS engines (vibevoice/orpheus/chatterbox/qwen3-tts) that
emit 24 kHz and carry no such key.

Adds unit specs (minimal crafted GGUFs + WAV-header decode) and an env-gated
end-to-end spec (CRISPASR_PIPER_MODEL_PATH). Verified e2e: en_GB-cori-medium
synthesizes a 22050 Hz WAV through backend:piper.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-12 23:10:17 +02:00
LocalAI [bot]
60facc7252 fix(darwin): publish sherpa-onnx and speaker-recognition images for darwin/arm64 (#10275)
Neither the sherpa-onnx nor the speaker-recognition backend had a
darwin/arm64 image, so `local-ai backends install` failed with "no child
with platform darwin/arm64" on macOS. This left /v1/audio/diarization (the
sherpa-onnx path) and /v1/voice/embed without any usable backend on Apple
Silicon.

Both backends build on darwin/arm64:
- sherpa-onnx (Go) already fetches the onnxruntime osx-arm64 runtime in its
  Makefile; it only needed a darwin matrix entry (build-type metal, lang go,
  like whisper and silero-vad).
- speaker-recognition (Python) needed a requirements-mps.txt so the mps build
  installs plain onnxruntime (which ships a macOS arm64 wheel) instead of the
  onnxruntime-gpu pulled by its base requirements (which does not).

Add both to the includeDarwin build matrix, wire the metal capability and
metal image aliases into the gallery, and add the speaker-recognition
requirements-mps.txt.

Fixes #10268


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-12 22:32:42 +02:00
LocalAI [bot]
8c8204d3c4 feat(parakeet-cpp): enable GGML_CUDA_GRAPHS in the cublas build (#10273)
ggml leaves GGML_CUDA_GRAPHS off by default. Passing -DGGML_CUDA_GRAPHS=ON
for cublas builds lets the CUDA backend capture and replay the compute
graph for a small free speedup (about 1% measured on a GB10, never
negative). It is not gated by parakeet.cpp's CMake options, so it passes
straight through to ggml.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-12 18:47:36 +02:00
LocalAI [bot]
4ce0f6102a chore(model gallery): 🤖 add 1 new models via gallery agent (#10270)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-12 16:21:35 +02:00
Richard Palethorpe
085fc53bbc fix(router): production-ready request router + auto-size batch for embedding/rerank (#10104)
* fix(router): score classifier production-readiness

Conversation trimming runs through the classifier model's chat template
and trims by exact token count, sized to the model's n_batch which is
now scaled to context so long probes can't crash the backend. Missing
chat_message templates are a hard error at router build time. Router-
facing factories (Embedder/Scorer/Reranker/TokenCounter) re-resolve
ModelConfig per call so a model installed post-startup doesn't bind a
stub Backend="" config and silently fall into the loader's auto-
iterate path.

New 'vector_store' backend trace recorded inside localVectorStore on
every Search/Insert — including the backend-load-failure path that
previously vanished into an xlog.Warn — with outcome tagging
(hit/miss/empty_store/backend_load_error/find_error/insert_error/ok).
Companion cleanup drops misleading similarity:0 and input_tokens_count:0
from non-hit and text-mode traces.

Gallery local-store-development aliases to 'local-store' so the master
image satisfies pkg/model.LocalStoreBackend lookups from the embedding
cache.

Misc: llama-cpp TokenizeString reads the correct 'prompt' JSON key
(the original bug); ModelTokenize nil-guard; non-fatal mitm proxy
startup; PII 'route_local' renamed to 'allow' with docs/UI in sync;
model-editor footer no longer eats the edit area on small screens;
several config-editor template/dropdown/section fixes.

Tests: e2e router specs (casual/code-hint + long-conversation trim),
vector_store trace specs, lazy-factory specs, gallery dev-alias
resolution, Playwright trace badge + scroll regression.

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

* feat(backend): auto-size batch to context for embedding and rerank models

Embedding and rerank models pool over the whole input in a single physical batch (n_ubatch). With batch left at the 512 default, the backend rejects longer inputs with "input is too large to process", silently capping a large-context embedder (e.g. 8k/32k) at 512 tokens. Size n_batch to the context for these single-pass usecases, mirroring the existing FLAG_SCORE behaviour; an explicit batch: still wins.

Extracts EffectiveContextSize/EffectiveBatchSize from grpcModelOpts so the effective decode window has one home for other callers to reuse.

Adds an e2e-aio regression test that embeds a >512-token input. The AIO embedding model is switched to nomic-embed-text-v1.5 (2048 context) because the previous granite model was capped at 512 tokens and could not exercise the larger batch.

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

* fix(gallery): raise arch-router scoring output cap via parallel:64

Scoring decodes the whole prompt+candidate in a single llama_decode and
reads one logit row per candidate token. The vendored llama.cpp server
caps causal output rows at n_parallel, so the default of 1 aborts with
GGML_ASSERT(n_outputs_max <= cparams.n_outputs_max) on multi-token route
labels. Set options: [parallel:64] on both arch-router quant entries to
lift the cap; kv_unified (the grpc-server default) keeps the full context
per sequence, so this does not split the KV cache.

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

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-06-12 16:21:15 +02:00
LocalAI [bot]
56cc4f63fc feat(backend): locate-anything-cpp (open-vocabulary object detection via ggml) (#10264)
* feat(backend): add locate-anything-cpp backend (open-vocab detection via la_capi)

A Go/purego backend wrapping locate-anything.cpp's la_capi C ABI, implementing
the gRPC Detect RPC: image + open-vocabulary text prompt -> labeled boxes.
Mirrors backend/go/rfdetr-cpp; static-links ggml into a per-CPU-variant .so.

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

* ci(backend): register locate-anything-cpp in build matrix

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

* feat(gallery): locate-anything gallery entry + model importer

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

* test(backend): locate-anything-cpp Load+Detect wire test

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

* feat(gallery): add locate-anything-3b model to the gallery index

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

* ci(backend): register locate-anything.cpp in bump_deps auto-bump

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

* ci(test): e2e smoke for locate-anything-cpp in test-extra (loads the 3B + image, runs Detect)

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Signed-off-by: mudler <mudler@localai.io>
Co-authored-by: mudler <mudler@localai.io>
2026-06-12 14:59:07 +02:00
LocalAI [bot]
a53f34e78f chore: ⬆️ Update ggml-org/llama.cpp to 4c6595503fe45d5a39f88d194e270f64c7424677 (#10261)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-12 14:57:52 +02:00
Dedy F. Setyawan
1cea96f09f feat(react-ui): add Indonesian language support (#10266)
Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
2026-06-12 10:08:58 +02:00
LocalAI [bot]
006a9d38c7 chore: ⬆️ Update mudler/parakeet.cpp to 9db92be63179a27201d3b88d5d40c545b2ac48ae (#10263)
⬆️ Update mudler/parakeet.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-12 09:18:21 +02:00
LocalAI [bot]
892ce951ce chore: ⬆️ Update antirez/ds4 to d881f2a05e8ff6bec001315a36b794b4aa310173 (#10262)
⬆️ 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-06-12 09:18:07 +02:00
LocalAI [bot]
7cda221d36 docs: ⬆️ update docs version mudler/LocalAI (#10259)
⬆️ 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-06-12 09:17:49 +02:00
LocalAI [bot]
9a88eb81e7 chore: ⬆️ Update CrispStrobe/CrispASR to d745bda4386ae0f9d1d2f23fff8ec95d76428221 (#10260)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-12 09:17:34 +02:00
pos-ei-don
58cdc050e9 fix(cuda): install cuda-nvrtc-dev alongside the other CUDA dev packages (#10257)
Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
2026-06-11 23:57:00 +02:00
pos-ei-don
b962f4a192 fix(vllm): parse tool_call function arguments before applying the chat template (#10256)
Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
2026-06-11 23:55:38 +02:00
LocalAI [bot]
b6fcb3e1db chore: ⬆️ Update CrispStrobe/CrispASR to 4b27392ffd0991a857594652cbb8b57e585bcd7b (#10241)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-11 18:33:58 +02:00
LocalAI [bot]
ff09683d84 chore: ⬆️ Update ggml-org/llama.cpp to ac4cddeb0dbd778f650bf568f6f08344a06abe3a (#10239)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-11 18:33:38 +02:00
LocalAI [bot]
f618636c71 docs: fix broken relref to realtime page (#10255)
Hugo fails the gh-pages build with REF_NOT_FOUND because the relref
in model-configuration.md uses the 'docs/' prefix; refs are resolved
relative to content/, so the page lives at 'features/openai-realtime'
(as the other ref in the same file already uses).


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-11 18:32:50 +02:00
147 changed files with 5767 additions and 434 deletions

View File

@@ -703,6 +703,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-locate-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1543,6 +1556,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-locate-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1569,6 +1595,19 @@ include:
backend: "rfdetr-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-locate-anything-cpp'
base-image: "ubuntu:24.04"
ubuntu-version: '2404'
runs-on: 'ubuntu-24.04-arm'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -2806,6 +2845,74 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# locate-anything-cpp
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-locate-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f32-locate-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f16-locate-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-locate-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-locate-anything-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2899,6 +3006,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
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-locate-anything-cpp'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "locate-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
# whisper
- build-type: ''
cuda-major-version: ""
@@ -4341,6 +4461,10 @@ includeDarwin:
tag-suffix: "-metal-darwin-arm64-silero-vad"
build-type: "metal"
lang: "go"
- backend: "sherpa-onnx"
tag-suffix: "-metal-darwin-arm64-sherpa-onnx"
build-type: "metal"
lang: "go"
- backend: "local-store"
tag-suffix: "-metal-darwin-arm64-local-store"
build-type: "metal"
@@ -4348,3 +4472,6 @@ includeDarwin:
- backend: "llama-cpp-quantization"
tag-suffix: "-metal-darwin-arm64-llama-cpp-quantization"
build-type: "mps"
- backend: "speaker-recognition"
tag-suffix: "-metal-darwin-arm64-speaker-recognition"
build-type: "mps"

View File

@@ -62,6 +62,10 @@ jobs:
variable: "RFDETR_VERSION"
branch: "main"
file: "backend/go/rfdetr-cpp/Makefile"
- repository: "mudler/locate-anything.cpp"
variable: "LOCATEANYTHING_VERSION"
branch: "master"
file: "backend/go/locate-anything-cpp/Makefile"
- repository: "predict-woo/qwen3-tts.cpp"
variable: "QWEN3TTS_CPP_VERSION"
branch: "main"

View File

@@ -38,6 +38,7 @@ jobs:
acestep-cpp: ${{ steps.detect.outputs.acestep-cpp }}
qwen3-tts-cpp: ${{ steps.detect.outputs.qwen3-tts-cpp }}
rfdetr-cpp: ${{ steps.detect.outputs.rfdetr-cpp }}
locate-anything-cpp: ${{ steps.detect.outputs.locate-anything-cpp }}
vibevoice-cpp: ${{ steps.detect.outputs.vibevoice-cpp }}
localvqe: ${{ steps.detect.outputs.localvqe }}
voxtral: ${{ steps.detect.outputs.voxtral }}
@@ -563,7 +564,7 @@ jobs:
- name: Run e2e-backends smoke
env:
BACKEND_IMAGE: quay.io/go-skynet/local-ai-backends:master-cpu-llama-cpp
BACKEND_TEST_CAPS: health,load,predict,stream,logprobs,logit_bias
BACKEND_TEST_CAPS: health,load,predict,stream,logprobs,logit_bias,tokenize
run: |
make test-extra-backend
# Realtime e2e with sherpa-onnx driving VAD + STT + TTS against a mocked LLM.
@@ -901,6 +902,45 @@ jobs:
- name: Test rfdetr-cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/rfdetr-cpp test
# Per-backend e2e for locate-anything-cpp: builds the .so + Go binary and
# runs `make -C backend/go/locate-anything-cpp test`. test.sh fetches the
# locate-anything-q8_0 GGUF (~6.3 GB, NVIDIA LocateAnything-3B) from the
# published mudler/locate-anything.cpp-gguf HF repo + a COCO image, then the
# Go wire test loads the model and runs an open-vocabulary Detect, asserting
# at least one labeled box. Heavier than the other Go backends (it is a 3B),
# so it is gated to changes under backend/go/locate-anything-cpp/.
tests-locate-anything-cpp:
needs: detect-changes
if: needs.detect-changes.outputs.locate-anything-cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v6
with:
submodules: true
- name: Dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake curl libopenblas-dev
- name: Setup Go
uses: actions/setup-go@v5
- name: Display Go version
run: go version
- name: Proto Dependencies
run: |
# Install protoc
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- name: Build locate-anything-cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/locate-anything-cpp
- name: Test locate-anything-cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/locate-anything-cpp test
# Per-backend smoke for vibevoice-cpp: builds the .so + Go binary and
# runs `make -C backend/go/vibevoice-cpp test`. test.sh auto-downloads
# the published mudler/vibevoice.cpp-models bundle (TTS Q8_0 + ASR Q4_K

View File

@@ -108,6 +108,7 @@ RUN <<EOT bash
apt-get update && \
apt-get install -y --no-install-recommends \
cuda-nvcc-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
cuda-nvrtc-dev-${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} \

View File

@@ -566,6 +566,7 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/python/speaker-recognition
$(MAKE) -C backend/rust/kokoros kokoros-grpc
$(MAKE) -C backend/go/rfdetr-cpp
$(MAKE) -C backend/go/locate-anything-cpp
test-extra: prepare-test-extra
$(MAKE) -C backend/python/transformers test
@@ -593,6 +594,7 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/python/speaker-recognition test
$(MAKE) -C backend/rust/kokoros test
$(MAKE) -C backend/go/rfdetr-cpp test
$(MAKE) -C backend/go/locate-anything-cpp test
##
## End-to-end gRPC tests that exercise a built backend container image.

View File

@@ -206,6 +206,16 @@ RUN if [ "${BACKEND}" = "opus" ]; then \
apt-get clean && rm -rf /var/lib/apt/lists/*; \
fi
# CrispASR's piper TTS backend dlopens libespeak-ng at runtime to phonemize
# non-English text (the MIT-clean path; English uses a built-in G2P). Install
# the espeak-ng runtime + its libpcaudio/libsonic deps + voice data so
# package.sh can bundle them into the FROM scratch image.
RUN if [ "${BACKEND}" = "crispasr" ]; then \
apt-get update && apt-get install -y --no-install-recommends \
espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0 && \
apt-get clean && rm -rf /var/lib/apt/lists/*; \
fi
COPY . /LocalAI
RUN git config --global --add safe.directory /LocalAI

View File

@@ -126,6 +126,7 @@ RUN <<EOT bash
apt-get update && \
apt-get install -y --no-install-recommends \
cuda-nvcc-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
cuda-nvrtc-dev-${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} \

View File

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

View File

@@ -1,5 +1,5 @@
LLAMA_VERSION?=039e20a2db9e87b2477c76cc04905f3e1acad77f
LLAMA_VERSION?=4c6595503fe45d5a39f88d194e270f64c7424677
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=

View File

@@ -3486,7 +3486,7 @@ public:
if (body.count("prompt") != 0) {
const bool add_special = json_value(body, "add_special", false);
llama_tokens tokens = tokenize_mixed(ctx_server.impl->vocab, body.at("content"), add_special, true);
llama_tokens tokens = tokenize_mixed(ctx_server.impl->vocab, body.at("prompt"), add_special, true);
for (const auto& token : tokens) {

View File

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

View File

@@ -11,6 +11,7 @@ import (
"github.com/go-audio/audio"
"github.com/go-audio/wav"
gguf "github.com/gpustack/gguf-parser-go"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/utils"
@@ -37,6 +38,39 @@ var (
type CrispASR struct {
base.SingleThread
// sampleRate is the output rate (Hz) of the loaded TTS engine's PCM, used to
// write a correct WAV header. Most CrispASR TTS backends emit 24 kHz, but
// piper returns its model's native rate (16 kHz for x_low/low voices,
// 22.05 kHz for medium/high), so it is read from the GGUF metadata at Load.
sampleRate int
}
// defaultTTSSampleRate is the output rate assumed for CrispASR TTS engines that
// don't advertise one in GGUF metadata (vibevoice/orpheus/chatterbox/qwen3-tts
// all emit 24 kHz). piper is the exception and carries piper.sample_rate.
const defaultTTSSampleRate = 24000
// piperSampleRate reads the piper.sample_rate metadata key from a GGUF model.
// CrispASR's piper backend returns PCM at the model's native rate without
// resampling, so the WAV header must match it. Returns ok=false for non-piper
// models (key absent) or an unreadable file, letting the caller fall back to
// defaultTTSSampleRate.
func piperSampleRate(modelPath string) (int, bool) {
// Only scalar architecture keys are read, so skip the large array metadata
// (phoneme map) and mmap the header - same rationale as pkg/vram's reader.
f, err := gguf.ParseGGUFFile(modelPath, gguf.UseMMap(), gguf.SkipLargeMetadata())
if err != nil {
return 0, false
}
kv, ok := f.Header.MetadataKV.Get("piper.sample_rate")
if !ok || kv.ValueType != gguf.GGUFMetadataValueTypeUint32 {
return 0, false
}
rate := int(kv.ValueUint32())
if rate <= 0 {
return 0, false
}
return rate, true
}
// splitOption splits a "prefix:value" model option into its key and value,
@@ -103,6 +137,14 @@ func (w *CrispASR) Load(opts *pb.ModelOptions) error {
return fmt.Errorf("Failed to load CrispASR transcription model")
}
// Determine the TTS output sample rate for the WAV header. piper voices
// carry their native rate in GGUF metadata and CrispASR does not resample;
// every other engine emits the 24 kHz default.
w.sampleRate = defaultTTSSampleRate
if rate, ok := piperSampleRate(opts.ModelFile); ok {
w.sampleRate = rate
}
// Load the companion file (codec/tokenizer/s3gen) after the session is open.
// rc==0 means success or "not applicable" for the active backend; only a
// negative code is fatal.
@@ -390,7 +432,7 @@ func (w *CrispASR) synthesize(text string) ([]float32, error) {
}
defer CppTTSFree(ptr)
src := unsafe.Slice((*float32)(unsafe.Pointer(ptr)), int(n)) //nolint:govet // ptr addresses C-allocated PCM returned across the purego boundary; copied out immediately below, before tts_free.
out := make([]float32, int(n)) // copy out of C memory before free
out := make([]float32, int(n)) // copy out of C memory before free
copy(out, src)
return out, nil
}
@@ -417,7 +459,7 @@ func (w *CrispASR) TTS(req *pb.TTSRequest) error {
if err != nil {
return err
}
return writeWAV24k(req.Dst, pcm)
return writeWAV(req.Dst, pcm, w.sampleRate)
}
// TTSStream is the streaming counterpart to TTS. CrispASR has no progressive
@@ -447,7 +489,7 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
}
defer func() { _ = os.Remove(dst) }()
if err := writeWAV24k(dst, pcm); err != nil {
if err := writeWAV(dst, pcm, w.sampleRate); err != nil {
return err
}
@@ -459,14 +501,14 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
return nil
}
// writeWAV24k writes pcm as a 24000 Hz, mono, 16-bit PCM WAV at dst.
func writeWAV24k(dst string, pcm []float32) error {
// writeWAV writes pcm as a sampleRate Hz, mono, 16-bit PCM WAV at dst.
func writeWAV(dst string, pcm []float32, sampleRate int) error {
f, err := os.Create(dst)
if err != nil {
return fmt.Errorf("crispasr: create %q: %w", dst, err)
}
enc := wav.NewEncoder(f, 24000, 16, 1, 1)
enc := wav.NewEncoder(f, sampleRate, 16, 1, 1)
ints := make([]int, len(pcm))
for i, s := range pcm {
if s > 1 {
@@ -477,7 +519,7 @@ func writeWAV24k(dst string, pcm []float32) error {
ints[i] = int(s * 32767)
}
buf := &audio.IntBuffer{
Format: &audio.Format{NumChannels: 1, SampleRate: 24000},
Format: &audio.Format{NumChannels: 1, SampleRate: sampleRate},
Data: ints,
SourceBitDepth: 16,
}

View File

@@ -0,0 +1,164 @@
package main
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"github.com/go-audio/wav"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// GGUF metadata value type tags (subset) from the GGUF spec.
const (
ggufTypeUint32 uint32 = 4
ggufTypeString uint32 = 8
)
type ggufKV struct {
key string
vtype uint32
val any
}
// writeMinimalGGUF emits a valid, tensor-less GGUF file carrying only the given
// metadata key-values. Enough for the header-only parse path piperSampleRate
// uses; avoids pulling a real multi-MB voice into the test.
func writeMinimalGGUF(path string, kvs []ggufKV) error {
var b bytes.Buffer
b.WriteString("GGUF") // magic
_ = binary.Write(&b, binary.LittleEndian, uint32(3)) // version
_ = binary.Write(&b, binary.LittleEndian, uint64(0)) // tensor count
_ = binary.Write(&b, binary.LittleEndian, uint64(len(kvs)))
for _, kv := range kvs {
_ = binary.Write(&b, binary.LittleEndian, uint64(len(kv.key)))
b.WriteString(kv.key)
_ = binary.Write(&b, binary.LittleEndian, kv.vtype)
switch v := kv.val.(type) {
case uint32:
_ = binary.Write(&b, binary.LittleEndian, v)
case string:
_ = binary.Write(&b, binary.LittleEndian, uint64(len(v)))
b.WriteString(v)
}
}
return os.WriteFile(path, b.Bytes(), 0o644)
}
// wavSampleRate decodes the WAV header at path and returns its sample rate.
func wavSampleRate(path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer func() { _ = f.Close() }()
dec := wav.NewDecoder(f)
dec.ReadInfo()
return int(dec.SampleRate), nil
}
var _ = Describe("piper sample rate", func() {
Context("piperSampleRate", func() {
It("reads piper.sample_rate from a piper GGUF (medium = 22050)", func() {
p := filepath.Join(GinkgoT().TempDir(), "voice.gguf")
Expect(writeMinimalGGUF(p, []ggufKV{
{key: "general.architecture", vtype: ggufTypeString, val: "piper"},
{key: "piper.sample_rate", vtype: ggufTypeUint32, val: uint32(22050)},
})).To(Succeed())
rate, ok := piperSampleRate(p)
Expect(ok).To(BeTrue(), "piper.sample_rate should be found")
Expect(rate).To(Equal(22050))
})
It("reads the low-quality rate (16000)", func() {
p := filepath.Join(GinkgoT().TempDir(), "voice.gguf")
Expect(writeMinimalGGUF(p, []ggufKV{
{key: "piper.sample_rate", vtype: ggufTypeUint32, val: uint32(16000)},
})).To(Succeed())
rate, ok := piperSampleRate(p)
Expect(ok).To(BeTrue())
Expect(rate).To(Equal(16000))
})
It("returns ok=false for a non-piper GGUF (no piper.sample_rate key)", func() {
p := filepath.Join(GinkgoT().TempDir(), "other.gguf")
Expect(writeMinimalGGUF(p, []ggufKV{
{key: "general.architecture", vtype: ggufTypeString, val: "vibevoice"},
})).To(Succeed())
_, ok := piperSampleRate(p)
Expect(ok).To(BeFalse())
})
It("returns ok=false for an unreadable/non-GGUF file", func() {
p := filepath.Join(GinkgoT().TempDir(), "garbage.gguf")
Expect(os.WriteFile(p, []byte("not a gguf"), 0o644)).To(Succeed())
_, ok := piperSampleRate(p)
Expect(ok).To(BeFalse())
})
})
// End-to-end through the built .so. Gated on CRISPASR_PIPER_MODEL_PATH (a
// real piper voice GGUF) like the other model-backed specs; never runs in
// default CI. Proves CrispASR's piper backend output rate flows into the
// WAV header instead of the hardcoded 24 kHz default.
Context("piper TTS end-to-end", func() {
It("writes the WAV at the model's native piper.sample_rate", func() {
model := os.Getenv("CRISPASR_PIPER_MODEL_PATH")
if model == "" {
Skip("set CRISPASR_PIPER_MODEL_PATH to run the piper e2e spec")
}
ensureLibLoaded()
expected, ok := piperSampleRate(model)
Expect(ok).To(BeTrue(), "model should carry piper.sample_rate metadata")
w := &CrispASR{}
Expect(w.Load(&pb.ModelOptions{
ModelFile: model,
Options: []string{"backend:piper"},
Threads: 4,
})).To(Succeed())
dst := filepath.Join(GinkgoT().TempDir(), "piper.wav")
Expect(w.TTS(&pb.TTSRequest{Text: "Hello from CrispASR piper.", Dst: dst})).To(Succeed())
info, err := os.Stat(dst)
Expect(err).ToNot(HaveOccurred())
Expect(info.Size()).To(BeNumerically(">", 1024), "expected a non-trivial WAV")
rate, err := wavSampleRate(dst)
Expect(err).ToNot(HaveOccurred())
Expect(rate).To(Equal(expected),
"WAV header rate must equal the model's native piper.sample_rate, not the 24k default")
})
})
Context("writeWAV", func() {
It("writes the WAV header at the given sample rate (22050 for piper, not the 24k default)", func() {
dst := filepath.Join(GinkgoT().TempDir(), "out.wav")
pcm := make([]float32, 220) // 10 ms of silence is enough for a header
Expect(writeWAV(dst, pcm, 22050)).To(Succeed())
rate, err := wavSampleRate(dst)
Expect(err).ToNot(HaveOccurred())
Expect(rate).To(Equal(22050))
})
It("writes a 16000 Hz header for low-quality piper voices", func() {
dst := filepath.Join(GinkgoT().TempDir(), "out.wav")
pcm := make([]float32, 160)
Expect(writeWAV(dst, pcm, 16000)).To(Succeed())
rate, err := wavSampleRate(dst)
Expect(err).ToNot(HaveOccurred())
Expect(rate).To(Equal(16000))
})
})
})

View File

@@ -51,6 +51,32 @@ else
exit 1
fi
# Bundle espeak-ng (+ its libpcaudio/libsonic runtime deps) and its voice data so
# the piper TTS backend can phonemize non-English text. CrispASR dlopens
# libespeak-ng.so.1 at runtime (the MIT-clean path); the dlopen succeeds loading
# libespeak-ng but FAILS if libpcaudio/libsonic are absent, so all three .so are
# required. run.sh points CRISPASR_ESPEAK_DATA_PATH at the bundled data dir.
# Best-effort: only copied when present, so a local dev build without espeak-ng
# installed still packages the rest (English voices keep working).
ESPEAK_LIBDIR=""
for d in /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu; do
if [ -f "$d/libespeak-ng.so.1" ]; then
ESPEAK_LIBDIR="$d"
break
fi
done
if [ -n "$ESPEAK_LIBDIR" ]; then
echo "Bundling espeak-ng from $ESPEAK_LIBDIR ..."
cp -arfLv "$ESPEAK_LIBDIR/libespeak-ng.so.1" $CURDIR/package/lib/
cp -arfLv "$ESPEAK_LIBDIR/libpcaudio.so.0" $CURDIR/package/lib/
cp -arfLv "$ESPEAK_LIBDIR/libsonic.so.0" $CURDIR/package/lib/
if [ -d "$ESPEAK_LIBDIR/espeak-ng-data" ]; then
cp -arfLv "$ESPEAK_LIBDIR/espeak-ng-data" $CURDIR/package/
fi
else
echo "espeak-ng not found; non-English piper voices will not phonemize"
fi
# Package GPU libraries based on BUILD_TYPE
# The GPU library packaging script will detect BUILD_TYPE and copy appropriate GPU libraries
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"

View File

@@ -41,6 +41,11 @@ fi
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
export CRISPASR_LIBRARY=$LIBRARY
# Point piper's espeak-ng phonemizer at the bundled voice data. The variable
# names the directory CONTAINING espeak-ng-data (package.sh drops it next to
# this script). Harmless when espeak-ng wasn't bundled.
export CRISPASR_ESPEAK_DATA_PATH=$CURDIR
# If there is a lib/ld.so, use it
if [ -f $CURDIR/lib/ld.so ]; then
echo "Using lib/ld.so"

View File

@@ -0,0 +1,7 @@
sources/
build*/
package/
liblocateanythingcpp*.so
locate-anything-cpp
test-models/
test-data/

View File

@@ -0,0 +1,57 @@
cmake_minimum_required(VERSION 3.18)
project(liblocateanythingcpp LANGUAGES C CXX)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Static-link ggml + locate_anything so the resulting .so has no runtime
# dependency on extra ggml/locate_anything shared libraries — only on
# libc/libstdc++/libgomp, which the LocalAI package step bundles into the
# docker image.
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build static libraries" FORCE)
# locate-anything.cpp build switches: skip CLI/tests, keep static lib.
set(LA_BUILD_CLI OFF CACHE BOOL "Disable locate-anything CLI" FORCE)
set(LA_BUILD_TESTS OFF CACHE BOOL "Disable locate-anything tests" FORCE)
set(LA_SHARED OFF CACHE BOOL "Build locate_anything as static lib" FORCE)
# Unlike rt-detr.cpp, locate-anything.cpp ships no in-tree ggml patches, so
# there is no apply_ggml_patches.sh hook to shim here.
add_subdirectory(./sources/locate-anything.cpp)
# locate-anything.cpp's top-level CMakeLists points its own target's include
# dirs at ${CMAKE_SOURCE_DIR}/{include,src,third_party,...}. CMAKE_SOURCE_DIR
# is the *top-level* source dir of the whole CMake tree, so when we pull it in
# via add_subdirectory it resolves to OUR directory, not theirs, and the
# locate_anything target fails to find its own headers (la_capi.h, stb_image.h,
# la_gguf_keys.h). Re-add the correct, subdir-relative include paths to the
# already-defined target so it compiles regardless of where it's nested.
set(LA_SRC ${CMAKE_CURRENT_SOURCE_DIR}/sources/locate-anything.cpp)
target_include_directories(locate_anything PRIVATE
${LA_SRC}/include
${LA_SRC}/src
${LA_SRC}/third_party
${LA_SRC}/third_party/stb)
# locate-anything.cpp's C-API symbols already live inside liblocate_anything
# (src/la_capi.cpp is compiled into the lib). We re-export them via a MODULE
# library that links locate_anything so the symbols are visible at dlopen time.
add_library(locateanythingcpp MODULE
sources/locate-anything.cpp/src/la_capi.cpp)
target_include_directories(locateanythingcpp PRIVATE
sources/locate-anything.cpp/include
sources/locate-anything.cpp/src
sources/locate-anything.cpp/third_party
sources/locate-anything.cpp/third_party/stb
)
target_link_libraries(locateanythingcpp PRIVATE locate_anything ggml)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS 9.0)
target_link_libraries(locateanythingcpp PRIVATE stdc++fs)
endif()
set_property(TARGET locateanythingcpp PROPERTY CXX_STANDARD 17)
set_target_properties(locateanythingcpp PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})

View File

@@ -0,0 +1,134 @@
CMAKE_ARGS?=
BUILD_TYPE?=
NATIVE?=false
GOCMD?=go
GO_TAGS?=
JOBS?=$(shell nproc --ignore=1)
# locate-anything.cpp. Pin to a specific commit for a stable build; leaving
# this on `master` always picks up the latest C-API surface (incl. the
# per-detection accessor functions used by golocateanythingcpp.go).
LOCATEANYTHING_REPO?=https://github.com/mudler/locate-anything.cpp.git
LOCATEANYTHING_VERSION?=60e450945476d5e97e0754a8c0e71a9ea81690e0
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
endif
# Forward LocalAI's BUILD_TYPE to the matching ggml backend switch.
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS+=-DGGML_CUDA=ON -DLA_GGML_CUDA=ON
else ifeq ($(BUILD_TYPE),openblas)
CMAKE_ARGS+=-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON
else ifeq ($(BUILD_TYPE),hipblas)
ROCM_HOME ?= /opt/rocm
ROCM_PATH ?= /opt/rocm
export CXX=$(ROCM_HOME)/llvm/bin/clang++
export CC=$(ROCM_HOME)/llvm/bin/clang
AMDGPU_TARGETS?=gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1200,gfx1201
CMAKE_ARGS+=-DGGML_HIPBLAS=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON -DLA_GGML_VULKAN=ON
else ifeq ($(OS),Darwin)
ifneq ($(BUILD_TYPE),metal)
CMAKE_ARGS+=-DGGML_METAL=OFF
else
CMAKE_ARGS+=-DGGML_METAL=ON
CMAKE_ARGS+=-DGGML_METAL_EMBED_LIBRARY=ON
CMAKE_ARGS+=-DLA_GGML_METAL=ON
endif
endif
ifeq ($(BUILD_TYPE),sycl_f16)
CMAKE_ARGS+=-DGGML_SYCL=ON \
-DCMAKE_C_COMPILER=icx \
-DCMAKE_CXX_COMPILER=icpx \
-DGGML_SYCL_F16=ON
endif
ifeq ($(BUILD_TYPE),sycl_f32)
CMAKE_ARGS+=-DGGML_SYCL=ON \
-DCMAKE_C_COMPILER=icx \
-DCMAKE_CXX_COMPILER=icpx
endif
sources/locate-anything.cpp:
mkdir -p sources && \
git clone --recursive $(LOCATEANYTHING_REPO) sources/locate-anything.cpp && \
cd sources/locate-anything.cpp && \
git checkout $(LOCATEANYTHING_VERSION) && \
git submodule update --init --recursive --depth 1 --single-branch
# Detect OS
UNAME_S := $(shell uname -s)
# Only build CPU variants on Linux
ifeq ($(UNAME_S),Linux)
VARIANT_TARGETS = liblocateanythingcpp-avx.so liblocateanythingcpp-avx2.so liblocateanythingcpp-avx512.so liblocateanythingcpp-fallback.so
else
# On non-Linux (e.g., Darwin), build only fallback variant
VARIANT_TARGETS = liblocateanythingcpp-fallback.so
endif
locate-anything-cpp: main.go golocateanythingcpp.go $(VARIANT_TARGETS)
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o locate-anything-cpp ./
package: locate-anything-cpp
bash package.sh
build: package
clean: purge
rm -rf liblocateanythingcpp*.so locate-anything-cpp package sources
purge:
rm -rf build*
# Build all variants (Linux only)
ifeq ($(UNAME_S),Linux)
liblocateanythingcpp-avx.so: sources/locate-anything.cpp
rm -rfv build-$@
$(info ${GREEN}I locate-anything-cpp build info:avx${RESET})
SO_TARGET=$@ CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) liblocateanythingcpp-custom
rm -rfv build-$@
liblocateanythingcpp-avx2.so: sources/locate-anything.cpp
rm -rfv build-$@
$(info ${GREEN}I locate-anything-cpp build info:avx2${RESET})
SO_TARGET=$@ CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on" $(MAKE) liblocateanythingcpp-custom
rm -rfv build-$@
liblocateanythingcpp-avx512.so: sources/locate-anything.cpp
rm -rfv build-$@
$(info ${GREEN}I locate-anything-cpp build info:avx512${RESET})
SO_TARGET=$@ CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on" $(MAKE) liblocateanythingcpp-custom
rm -rfv build-$@
endif
# Build fallback variant (all platforms)
liblocateanythingcpp-fallback.so: sources/locate-anything.cpp
rm -rfv build-$@
$(info ${GREEN}I locate-anything-cpp build info:fallback${RESET})
SO_TARGET=$@ CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) liblocateanythingcpp-custom
rm -rfv build-$@
liblocateanythingcpp-custom: CMakeLists.txt
mkdir -p build-$(SO_TARGET) && \
cd build-$(SO_TARGET) && \
cmake .. $(CMAKE_ARGS) && \
cmake --build . --config Release -j$(JOBS) && \
cd .. && \
mv build-$(SO_TARGET)/liblocateanythingcpp.so ./$(SO_TARGET)
all: locate-anything-cpp package
# `test` is invoked by the top-level Makefile's `test-extra` target. It builds
# the backend binary + the fallback shared library (needed for dlopen at
# runtime), then runs test.sh which downloads the q8_0 GGUF + COCO image and
# exercises the gRPC Load/Detect wire path via the Go smoke test in
# main_test.go.
test: locate-anything-cpp liblocateanythingcpp-fallback.so
bash test.sh

View File

@@ -0,0 +1,174 @@
package main
// golocateanythingcpp.go - gRPC handlers (Load, Detect) for the
// locate-anything-cpp backend.
//
// Embeds base.SingleThread to default unimplemented RPCs to "not supported"
// while we only implement open-vocabulary object detection (Detect).
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// la_ctx* is an opaque handle. la_capi_load returns it directly (0 == failure),
// unlike rfdetr's out-parameter convention.
var (
// la_capi_load(const char* gguf_path, int n_threads) -> la_ctx* (0 = fail)
CapiLoad func(gguf string, nThreads int32) uintptr
// la_capi_free(la_ctx* ctx)
CapiFree func(handle uintptr)
// la_capi_locate_path(ctx, image_path, prompt, mode) -> char* json (0 = err)
CapiLocatePath func(handle uintptr, imagePath string, prompt string, mode int32) uintptr
// la_capi_locate_buffer(ctx, bytes, len, prompt, mode) -> char* json (0 = err)
CapiLocateBuffer func(handle uintptr, bytes uintptr, length uintptr, prompt string, mode int32) uintptr
// la_capi_get_n_detections(ctx) -> int
CapiGetNDetections func(handle uintptr) int32
// la_capi_get_detection_box(ctx, i, out_xyxy[4]) -> int (0 on success)
CapiGetDetectionBox func(handle uintptr, i int32, outXYXY uintptr) int32
// la_capi_get_detection_label(ctx, i, buf, buf_size) -> int (required size incl NUL; two-call sizing)
CapiGetDetectionLabel func(handle uintptr, i int32, buf uintptr, bufSize int32) int32
// la_capi_free_string(char* s)
CapiFreeString func(s uintptr)
// la_capi_last_error(ctx) -> const char* (owned by ctx, "" if none / null ctx).
// purego marshals the returned C string into a Go string (a copy), so we
// never free it and avoid raw pointer arithmetic.
CapiLastError func(handle uintptr) string
)
type LocateAnythingCpp struct {
base.SingleThread
handle uintptr
}
// Load loads the GGUF model at opts.ModelFile (joined with opts.ModelPath if
// relative) and stores the la_ctx handle for later Detect calls.
func (r *LocateAnythingCpp) Load(opts *pb.ModelOptions) error {
modelFile := opts.ModelFile
if modelFile == "" {
modelFile = opts.Model
}
if modelFile == "" {
return fmt.Errorf("locate-anything-cpp: ModelFile is empty")
}
var modelPath string
if filepath.IsAbs(modelFile) {
modelPath = modelFile
} else {
modelPath = filepath.Join(opts.ModelPath, modelFile)
}
if _, err := os.Stat(modelPath); err != nil {
return fmt.Errorf("locate-anything-cpp: model file not found: %s: %w", modelPath, err)
}
threads := opts.Threads
if threads <= 0 {
threads = 4
}
// Release previous model if any (re-Load).
if r.handle != 0 {
CapiFree(r.handle)
r.handle = 0
}
h := CapiLoad(modelPath, threads)
if h == 0 {
// la_capi_last_error needs a ctx; on a failed load we have none (it
// returns "" for a null ctx), so the text is best-effort. Surface it
// when present.
if msg := CapiLastError(0); msg != "" {
return fmt.Errorf("locate-anything-cpp: la_capi_load failed for %s: %s", modelPath, msg)
}
return fmt.Errorf("locate-anything-cpp: la_capi_load failed for %s", modelPath)
}
r.handle = h
return nil
}
// Detect runs open-vocabulary detection on the base64-encoded image in opts.Src
// using the required text prompt in opts.Prompt, returning one pb.Detection per
// located object with its predicted label as ClassName.
func (r *LocateAnythingCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, error) {
if r.handle == 0 {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: model not loaded")
}
// Open-vocabulary detection is prompt-driven; without a prompt there is
// nothing to locate.
prompt := opts.Prompt
if prompt == "" {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: a text prompt is required (open-vocabulary detection)")
}
// Decode base64 image and write to temp file.
imgData, err := base64.StdEncoding.DecodeString(opts.Src)
if err != nil {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to decode base64 image: %w", err)
}
tmpFile, err := os.CreateTemp("", "locate-anything-*.img")
if err != nil {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to create temp file: %w", err)
}
defer func() { _ = os.Remove(tmpFile.Name()) }()
if _, err := tmpFile.Write(imgData); err != nil {
_ = tmpFile.Close()
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to write temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to close temp file: %w", err)
}
// mode 0 = hybrid (Parallel Box Decoding). The JSON return value is unused:
// structured detections are read via the accessor functions. Still must
// free the returned string.
jsonPtr := CapiLocatePath(r.handle, tmpFile.Name(), prompt, 0)
if jsonPtr != 0 {
CapiFreeString(jsonPtr)
}
n := CapiGetNDetections(r.handle)
if n < 0 {
return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: invalid n_detections=%d", n)
}
detections := make([]*pb.Detection, 0, n)
for i := int32(0); i < n; i++ {
var xyxy [4]float32 // x1, y1, x2, y2
if CapiGetDetectionBox(r.handle, i, uintptr(unsafe.Pointer(&xyxy[0]))) != 0 {
continue
}
// Two-call sizing for the label string.
label := ""
need := CapiGetDetectionLabel(r.handle, i, 0, 0)
if need > 0 {
buf := make([]byte, need)
CapiGetDetectionLabel(r.handle, i, uintptr(unsafe.Pointer(&buf[0])), need)
label = string(buf[:need-1])
}
detections = append(detections, &pb.Detection{
X: xyxy[0],
Y: xyxy[1],
Width: xyxy[2] - xyxy[0],
Height: xyxy[3] - xyxy[1],
Confidence: 1.0,
ClassName: label,
})
}
return pb.DetectResponse{
Detections: detections,
}, nil
}

View File

@@ -0,0 +1,59 @@
package main
// main.go - entry point for the locate-anything-cpp gRPC backend.
//
// Dlopens liblocateanythingcpp-<variant>.so via purego at the path in
// LOCATEANYTHING_LIBRARY (set by run.sh based on /proc/cpuinfo), registers
// the la_capi_* C ABI symbols, then starts the gRPC server.
import (
"flag"
"os"
"github.com/ebitengine/purego"
grpc "github.com/mudler/LocalAI/pkg/grpc"
)
var (
addr = flag.String("addr", "localhost:50051", "the address to connect to")
)
type LibFuncs struct {
FuncPtr any
Name string
}
func main() {
// Get library name from environment variable, default to fallback
libName := os.Getenv("LOCATEANYTHING_LIBRARY")
if libName == "" {
libName = "./liblocateanythingcpp-fallback.so"
}
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
panic(err)
}
libFuncs := []LibFuncs{
{&CapiLoad, "la_capi_load"},
{&CapiFree, "la_capi_free"},
{&CapiLocatePath, "la_capi_locate_path"},
{&CapiLocateBuffer, "la_capi_locate_buffer"},
{&CapiGetNDetections, "la_capi_get_n_detections"},
{&CapiGetDetectionBox, "la_capi_get_detection_box"},
{&CapiGetDetectionLabel, "la_capi_get_detection_label"},
{&CapiFreeString, "la_capi_free_string"},
{&CapiLastError, "la_capi_last_error"},
}
for _, lf := range libFuncs {
purego.RegisterLibFunc(lf.FuncPtr, lib, lf.Name)
}
flag.Parse()
if err := grpc.StartServer(*addr, &LocateAnythingCpp{}); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,176 @@
package main
// main_test.go - end-to-end smoke test for the locate-anything-cpp gRPC backend.
//
// Spawns the compiled locate-anything-cpp binary on a free local port, dials it
// via gRPC, and exercises LoadModel + Detect against the test fixtures
// downloaded by test.sh: the q8_0 GGUF of nvidia/LocateAnything-3B and a real
// COCO image with people + cars. Asserts that open-vocabulary detection driven
// by a text prompt returns at least one detection, each carrying a non-empty
// class name and a bounding box of non-zero size.
//
// The spec Skip()s cleanly if its fixtures (the ~6.3 GB model, the test image,
// the built binary, or the fallback .so) are missing, so the test target stays
// usable on a fresh checkout / on CI runners where the large model hasn't been
// downloaded.
import (
"context"
"encoding/base64"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestDetect(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "locate-anything-cpp backend smoke suite")
}
// freePort grabs an ephemeral TCP port and immediately releases it so the
// spawned backend can bind to it. There is a tiny TOCTOU window here but in
// practice it's adequate for a smoke test on a quiet runner.
func freePort() int {
l, err := net.Listen("tcp", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred(), "freePort listen")
port := l.Addr().(*net.TCPAddr).Port
Expect(l.Close()).To(Succeed())
return port
}
// startBackend spawns the locate-anything-cpp binary on the given port and
// waits until it accepts TCP connections (up to 10s). It mirrors how main.go
// resolves the purego library: the LOCATEANYTHING_LIBRARY env var points the
// dlopen at the freshly built fallback .so, and the la_capi_* symbols are
// registered there. The returned cleanup func kills the process and reaps it.
func startBackend(port int) func() {
binary, err := filepath.Abs("./locate-anything-cpp")
Expect(err).ToNot(HaveOccurred())
if _, err := os.Stat(binary); err != nil {
Skip(fmt.Sprintf("backend binary not built: %s (run `make locate-anything-cpp` first)", binary))
}
libPath, err := filepath.Abs("./liblocateanythingcpp-fallback.so")
Expect(err).ToNot(HaveOccurred())
if _, err := os.Stat(libPath); err != nil {
Skip(fmt.Sprintf("fallback library not built: %s (run `make liblocateanythingcpp-fallback.so` first)", libPath))
}
addr := fmt.Sprintf("127.0.0.1:%d", port)
cmd := exec.Command(binary, "--addr", addr)
cmd.Env = append(os.Environ(), "LOCATEANYTHING_LIBRARY="+libPath)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
Expect(cmd.Start()).To(Succeed())
cleanup := func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
_ = c.Close()
return cleanup
}
time.Sleep(200 * time.Millisecond)
}
cleanup()
Fail(fmt.Sprintf("backend did not become ready on %s within 10s", addr))
return func() {}
}
// loadTestImage reads the COCO test image downloaded by test.sh and returns its
// base64-encoded content (the wire format accepted by the Detect RPC).
func loadTestImage() string {
imgPath, err := filepath.Abs("test-data/test.jpg")
Expect(err).ToNot(HaveOccurred())
imgBytes, err := os.ReadFile(imgPath)
if err != nil {
Skip(fmt.Sprintf("test image not present: %s (run test.sh first)", imgPath))
}
return base64.StdEncoding.EncodeToString(imgBytes)
}
// dialBackend opens a gRPC client connection to the spawned backend.
func dialBackend(port int) (pb.BackendClient, func()) {
addr := fmt.Sprintf("127.0.0.1:%d", port)
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
Expect(err).ToNot(HaveOccurred())
return pb.NewBackendClient(conn), func() { _ = conn.Close() }
}
// modelPathOrSkip resolves the model file under ./test-models/ and Skip()s the
// current spec if it's missing (the ~6.3 GB GGUF is not present on a fresh
// checkout / on CI runners without the download).
func modelPathOrSkip(name string) string {
modelDir, err := filepath.Abs("test-models")
Expect(err).ToNot(HaveOccurred())
modelPath := filepath.Join(modelDir, name)
if _, err := os.Stat(modelPath); err != nil {
Skip(fmt.Sprintf("model not present: %s (run test.sh first)", modelPath))
}
return modelPath
}
var _ = Describe("locate-anything-cpp backend", func() {
It("runs open-vocabulary detection against a known-good COCO image", func() {
modelPath := modelPathOrSkip("locate-anything-q8_0.gguf")
imgB64 := loadTestImage()
port := freePort()
cleanup := startBackend(port)
defer cleanup()
client, closeConn := dialBackend(port)
defer closeConn()
// The q8_0 model is ~6.3 GB and hybrid Parallel Box Decoding on CPU is
// not cheap, so give LoadModel + Detect a generous deadline.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
loadResp, err := client.LoadModel(ctx, &pb.ModelOptions{
Model: "locate-anything-q8_0.gguf",
ModelFile: modelPath,
Threads: 4,
})
Expect(err).ToNot(HaveOccurred(), "LoadModel")
Expect(loadResp.GetSuccess()).To(BeTrue(), "LoadModel reported failure: %s", loadResp.GetMessage())
// Open-vocabulary detection is prompt-driven; the prompt names the
// classes to locate (people + cars), separated by the </c> control token.
detResp, err := client.Detect(ctx, &pb.DetectOptions{
Src: imgB64,
Prompt: "Locate all the instances that matches the following description: person</c>car.",
})
Expect(err).ToNot(HaveOccurred(), "Detect")
Expect(detResp.GetDetections()).ToNot(BeEmpty(), "no detections returned on a known-good COCO image")
_, _ = fmt.Fprintf(GinkgoWriter, "detection OK: %d detections\n", len(detResp.GetDetections()))
for i, d := range detResp.GetDetections() {
Expect(d.GetClassName()).ToNot(BeEmpty(), "detection %d has empty class_name", i)
Expect(d.GetWidth()).To(BeNumerically(">", float32(0)),
"detection %d has non-positive width", i)
Expect(d.GetHeight()).To(BeNumerically(">", float32(0)),
"detection %d has non-positive height", i)
_, _ = fmt.Fprintf(GinkgoWriter, " [%d] %s box=(%.1f,%.1f,%.1fx%.1f)\n",
i, d.GetClassName(), d.GetX(), d.GetY(), d.GetWidth(), d.GetHeight())
}
})
})

View File

@@ -0,0 +1,59 @@
#!/bin/bash
# Script to copy the appropriate libraries based on architecture
set -e
CURDIR=$(dirname "$(realpath $0)")
REPO_ROOT="${CURDIR}/../../.."
# Create lib directory
mkdir -p $CURDIR/package/lib
cp -avf $CURDIR/liblocateanythingcpp-*.so $CURDIR/package/
cp -avf $CURDIR/locate-anything-cpp $CURDIR/package/
cp -fv $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
elif [ $(uname -s) = "Darwin" ]; then
echo "Detected Darwin"
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/

View File

@@ -0,0 +1,52 @@
#!/bin/bash
set -ex
# Get the absolute current dir where the script is located
CURDIR=$(dirname "$(realpath $0)")
cd /
echo "CPU info:"
if [ "$(uname)" != "Darwin" ]; then
grep -e "model\sname" /proc/cpuinfo | head -1
grep -e "flags" /proc/cpuinfo | head -1
fi
LIBRARY="$CURDIR/liblocateanythingcpp-fallback.so"
if [ "$(uname)" != "Darwin" ]; then
if grep -q -e "\savx\s" /proc/cpuinfo ; then
echo "CPU: AVX found OK"
if [ -e $CURDIR/liblocateanythingcpp-avx.so ]; then
LIBRARY="$CURDIR/liblocateanythingcpp-avx.so"
fi
fi
if grep -q -e "\savx2\s" /proc/cpuinfo ; then
echo "CPU: AVX2 found OK"
if [ -e $CURDIR/liblocateanythingcpp-avx2.so ]; then
LIBRARY="$CURDIR/liblocateanythingcpp-avx2.so"
fi
fi
# Check avx 512
if grep -q -e "\savx512f\s" /proc/cpuinfo ; then
echo "CPU: AVX512F found OK"
if [ -e $CURDIR/liblocateanythingcpp-avx512.so ]; then
LIBRARY="$CURDIR/liblocateanythingcpp-avx512.so"
fi
fi
fi
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
export LOCATEANYTHING_LIBRARY=$LIBRARY
# If there is a lib/ld.so, use it
if [ -f $CURDIR/lib/ld.so ]; then
echo "Using lib/ld.so"
echo "Using library: $LIBRARY"
exec $CURDIR/lib/ld.so $CURDIR/locate-anything-cpp "$@"
fi
echo "Using library: $LIBRARY"
exec $CURDIR/locate-anything-cpp "$@"

View File

@@ -0,0 +1,47 @@
#!/bin/bash
set -e
CURDIR=$(dirname "$(realpath $0)")
echo "Running locate-anything-cpp backend tests..."
# Test model from the mudler/locate-anything.cpp-gguf HuggingFace repo. This is
# the q8_0 quantization of nvidia/LocateAnything-3B (~6.3 GB), so the download
# is the slow step. It is resumed with `curl -C -` and skipped entirely if the
# file is already present.
LOCATEANYTHING_MODEL_DIR="${LOCATEANYTHING_MODEL_DIR:-$CURDIR/test-models}"
LOCATEANYTHING_MODEL_FILE="${LOCATEANYTHING_MODEL_FILE:-locate-anything-q8_0.gguf}"
LOCATEANYTHING_MODEL_URL="${LOCATEANYTHING_MODEL_URL:-https://huggingface.co/mudler/locate-anything.cpp-gguf/resolve/main/locate-anything-q8_0.gguf}"
mkdir -p "$LOCATEANYTHING_MODEL_DIR"
if [ ! -f "$LOCATEANYTHING_MODEL_DIR/$LOCATEANYTHING_MODEL_FILE" ]; then
echo "Downloading locate-anything q8_0 model (~6.3 GB, this is slow)..."
# -C - resumes a partial download so an interrupted run doesn't restart from 0.
curl -L -C - -o "$LOCATEANYTHING_MODEL_DIR/$LOCATEANYTHING_MODEL_FILE" "$LOCATEANYTHING_MODEL_URL" --progress-bar
fi
# Use a real COCO test image (people + cars) from the upstream rf-detr.cpp repo
# (~46 KB). Open-vocabulary detection needs real content to locate, so a
# synthetic image would trivially yield zero detections.
TEST_IMAGE_DIR="$CURDIR/test-data"
TEST_IMAGE_FILE="$TEST_IMAGE_DIR/test.jpg"
TEST_IMAGE_URL="${TEST_IMAGE_URL:-https://raw.githubusercontent.com/mudler/rf-detr.cpp/main/tests/fixtures/ci/test_image.jpg}"
mkdir -p "$TEST_IMAGE_DIR"
if [ ! -f "$TEST_IMAGE_FILE" ]; then
echo "Downloading COCO test image..."
curl -L -o "$TEST_IMAGE_FILE" "$TEST_IMAGE_URL" --progress-bar
fi
echo "locate-anything-cpp test setup complete."
echo " model: $LOCATEANYTHING_MODEL_DIR/$LOCATEANYTHING_MODEL_FILE"
echo " test image: $TEST_IMAGE_FILE"
# Run the Go smoke test: spawns the backend binary on a free port, calls
# LoadModel + Detect via gRPC against the downloaded GGUF + COCO image.
echo ""
echo "Running Go smoke test..."
cd "$CURDIR"
go test -v -timeout 30m ./...

View File

@@ -1,6 +1,6 @@
# parakeet-cpp backend Makefile.
#
# Upstream pin lives below as PARAKEET_VERSION?=e270af73b94c9a5c37ec516230219ed4580e1db6
# Upstream pin lives below as PARAKEET_VERSION?=b8012f11e5269126eddb7f4fd02f891a2ccc29b0
# (.github/bump_deps.sh) can find and update it - matches the
# whisper.cpp / ds4 / vibevoice-cpp convention.
#
@@ -15,7 +15,7 @@
# That's what the L0 smoke test uses. The default target below does the
# proper clone-at-pin + cmake build so CI doesn't need a side-checkout.
PARAKEET_VERSION?=e270af73b94c9a5c37ec516230219ed4580e1db6
PARAKEET_VERSION?=b8012f11e5269126eddb7f4fd02f891a2ccc29b0
PARAKEET_REPO?=https://github.com/mudler/parakeet.cpp
GOCMD?=go
@@ -39,7 +39,10 @@ endif
# is overwritten back to OFF and the build silently falls back to CPU. Forward the
# PARAKEET_GGML_* options instead. (openblas is not gated, so -DGGML_BLAS passes through.)
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS+=-DPARAKEET_GGML_CUDA=ON
# GGML_CUDA_GRAPHS is OFF by ggml default; enabling it gives a small free
# speedup (~1% measured on GB10, never negative) by capturing/replaying the
# CUDA graph. Not gated by parakeet.cpp, so it passes straight through to ggml.
CMAKE_ARGS+=-DPARAKEET_GGML_CUDA=ON -DGGML_CUDA_GRAPHS=ON
else ifeq ($(BUILD_TYPE),openblas)
CMAKE_ARGS+=-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS
else ifeq ($(BUILD_TYPE),hipblas)

View File

@@ -98,17 +98,21 @@ type transcriptJSON struct {
}
// streamFeedJSON mirrors the document returned by
// parakeet_capi_stream_feed_json / parakeet_capi_stream_finalize_json (ABI v4):
// parakeet_capi_stream_feed_json / parakeet_capi_stream_finalize_json (ABI v5):
//
// {"text":"...","eou":0,"frame_sec":0.080000,
// {"text":"...","eou":0,"eob":0,"frame_sec":0.080000,
// "words":[{"w":"...","start":0.480,"end":0.640,"conf":0.9100}, ...]}
//
// "text" is the newly-finalized text since the last call; "eou" is 1 when an
// <EOU>/<EOB> fired this feed; "words" are the words finalized this call with
// absolute (stream-relative) start/end seconds.
// <EOU> (end of utterance) fired this feed and "eob" is 1 when an <EOB>
// (backchannel) fired. ABI v4 conflated the two into "eou"; v5 split them, so
// we read both and treat either as an utterance boundary for segmentation.
// "words" are the words finalized this call with absolute (stream-relative)
// start/end seconds.
type streamFeedJSON struct {
Text string `json:"text"`
Eou int `json:"eou"`
Eob int `json:"eob"`
FrameSec float64 `json:"frame_sec"`
Words []transcriptWord `json:"words"`
}
@@ -483,7 +487,10 @@ type streamSegmenter struct {
func (s *streamSegmenter) add(doc streamFeedJSON) {
s.cur = append(s.cur, doc.Words...)
if doc.Eou != 0 {
// Close the segment on either turn signal: <EOU> (end of utterance) or
// <EOB> (backchannel). ABI v4 reported both via "eou"; v5 split them, so we
// OR them here to keep the v4 segmentation boundaries.
if doc.Eou != 0 || doc.Eob != 0 {
s.flush()
}
}
@@ -671,11 +678,12 @@ func (p *ParakeetCpp) AudioTranscriptionStream(ctx context.Context, opts *pb.Tra
return nil
}
// streamJSON drives the ABI v4 streaming JSON entry points: each feed/finalize
// returns a {text,eou,frame_sec,words} document. The newly-finalized text is
// emitted as a delta (unchanged streaming contract) while words are accumulated
// into per-utterance segments (closed on EOU) so the closing FinalResult carries
// timestamped segments. Runs under engineMu (already held by the caller).
// streamJSON drives the streaming JSON entry points (present since ABI v4): each
// feed/finalize returns a {text,eou,eob,frame_sec,words} document. The
// newly-finalized text is emitted as a delta (unchanged streaming contract)
// while words are accumulated into per-utterance segments (closed on <EOU> or
// <EOB>) so the closing FinalResult carries timestamped segments. Runs under
// engineMu (already held by the caller).
func (p *ParakeetCpp) streamJSON(ctx context.Context, stream uintptr, data []float32,
duration float32, results chan *pb.TranscriptStreamResponse) error {
var (

View File

@@ -124,4 +124,17 @@ var _ = Describe("streaming segment assembly", func() {
Expect(acc.segments()).To(HaveLen(1))
Expect(acc.segments()[0].Text).To(Equal("hi there"))
})
// ABI v5 split <EOB> (backchannel) out of the "eou" flag into its own "eob"
// field; a backchannel must still close the segment as it did in v4.
It("closes a segment on EOB (backchannel) too", func() {
acc := &streamSegmenter{}
acc.add(streamFeedJSON{Text: "uh huh", Eou: 0, Eob: 1, Words: []transcriptWord{
{W: "uh", Start: 0.0, End: 0.2}, {W: "huh", Start: 0.2, End: 0.5},
}})
segs := acc.segments()
Expect(segs).To(HaveLen(1))
Expect(segs[0].Text).To(Equal("uh huh"))
Expect(segs[0].End).To(Equal(secondsToNanos(0.5)))
})
})

View File

@@ -26,8 +26,16 @@ add_library(govibevoicecpp MODULE cpp/govibevoicecpp.cpp)
# vv_capi_* symbols (purego dlopens them by name, nothing in our
# translation unit references them). Force the static archive's
# entire contents into the MODULE so dlsym finds vv_capi_load etc.
#
# Link the `vibevoice` TARGET (not a bare archive path) so CMake builds
# libvibevoice.a first and tracks the dependency: the upstream project is added
# with EXCLUDE_FROM_ALL, so without a target-level link there is no rule to
# build it. Passing only $<TARGET_FILE:vibevoice> as a path on Apple left the
# build with "No rule to make target 'vibevoice/libvibevoice.a'" (issue #10267).
# force_load is then applied as a separate link option.
if(APPLE)
target_link_libraries(govibevoicecpp PRIVATE -Wl,-force_load $<TARGET_FILE:vibevoice>)
target_link_libraries(govibevoicecpp PRIVATE vibevoice)
target_link_options(govibevoicecpp PRIVATE "-Wl,-force_load,$<TARGET_FILE:vibevoice>")
elseif(MSVC)
target_link_libraries(govibevoicecpp PRIVATE vibevoice)
set_property(TARGET govibevoicecpp APPEND PROPERTY LINK_FLAGS "/WHOLEARCHIVE:vibevoice")

View File

@@ -94,26 +94,30 @@ purge:
# Build all variants (Linux only)
ifeq ($(UNAME_S),Linux)
libgovibevoicecpp-avx.so: sources/vibevoice.cpp
$(MAKE) purge
$(info ${GREEN}I vibevoice-cpp build info:avx${RESET})
SO_TARGET=libgovibevoicecpp-avx.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgovibevoicecpp-custom
rm -rf build-libgovibevoicecpp-avx.so
rm -rfv build*
libgovibevoicecpp-avx2.so: sources/vibevoice.cpp
$(MAKE) purge
$(info ${GREEN}I vibevoice-cpp build info:avx2${RESET})
SO_TARGET=libgovibevoicecpp-avx2.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on" $(MAKE) libgovibevoicecpp-custom
rm -rf build-libgovibevoicecpp-avx2.so
rm -rfv build*
libgovibevoicecpp-avx512.so: sources/vibevoice.cpp
$(MAKE) purge
$(info ${GREEN}I vibevoice-cpp build info:avx512${RESET})
SO_TARGET=libgovibevoicecpp-avx512.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on" $(MAKE) libgovibevoicecpp-custom
rm -rf build-libgovibevoicecpp-avx512.so
rm -rfv build*
endif
# Build fallback variant (all platforms)
libgovibevoicecpp-fallback.so: sources/vibevoice.cpp
$(MAKE) purge
$(info ${GREEN}I vibevoice-cpp build info:fallback${RESET})
SO_TARGET=libgovibevoicecpp-fallback.so CMAKE_ARGS="$(CMAKE_ARGS) -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off" $(MAKE) libgovibevoicecpp-custom
rm -rf build-libgovibevoicecpp-fallback.so
rm -rfv build*
libgovibevoicecpp-custom: CMakeLists.txt cpp/govibevoicecpp.cpp cpp/govibevoicecpp.h
mkdir -p build-$(SO_TARGET) && \

View File

@@ -337,6 +337,35 @@
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-rfdetr-cpp"
intel: "intel-sycl-f32-rfdetr-cpp"
vulkan: "vulkan-rfdetr-cpp"
- &locateanything
name: "locate-anything"
alias: "locate-anything"
license: apache-2.0
description: |
Open-vocabulary object detection and visual grounding (NVIDIA
LocateAnything-3B) in C/C++ using GGML. Loads pre-built GGUF weights
and, given an image and a free-form text prompt, returns bounding
boxes, class labels, and confidence scores for the referred objects.
urls:
- https://github.com/mudler/locate-anything.cpp
- https://huggingface.co/nvidia/LocateAnything-3B
tags:
- object-detection
- visual-grounding
- open-vocabulary
- locate-anything
- gpu
- cpu
capabilities:
default: "cpu-locate-anything-cpp"
nvidia: "cuda12-locate-anything-cpp"
nvidia-cuda-12: "cuda12-locate-anything-cpp"
nvidia-cuda-13: "cuda13-locate-anything-cpp"
nvidia-l4t: "nvidia-l4t-arm64-locate-anything-cpp"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-locate-anything-cpp"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-locate-anything-cpp"
intel: "intel-sycl-f32-locate-anything-cpp"
vulkan: "vulkan-locate-anything-cpp"
- &vllm
name: "vllm"
license: apache-2.0
@@ -1225,6 +1254,7 @@
default: "cpu-sherpa-onnx"
nvidia: "cuda12-sherpa-onnx"
nvidia-cuda-12: "cuda12-sherpa-onnx"
metal: "metal-sherpa-onnx"
- !!merge <<: *neutts
name: "neutts-development"
capabilities:
@@ -1557,6 +1587,7 @@
- localai/localai-backends:master-metal-darwin-arm64-kitten-tts
- !!merge <<: *local-store
name: "local-store-development"
alias: "local-store"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-local-store"
mirrors:
- localai/localai-backends:master-cpu-local-store
@@ -1567,6 +1598,7 @@
- localai/localai-backends:latest-metal-darwin-arm64-local-store
- !!merge <<: *local-store
name: "metal-local-store-development"
alias: "local-store"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-local-store"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-local-store
@@ -4685,12 +4717,14 @@
default: "cpu-speaker-recognition"
nvidia: "cuda12-speaker-recognition"
nvidia-cuda-12: "cuda12-speaker-recognition"
metal: "metal-speaker-recognition"
- !!merge <<: *speakerrecognition
name: "speaker-recognition-development"
capabilities:
default: "cpu-speaker-recognition-development"
nvidia: "cuda12-speaker-recognition-development"
nvidia-cuda-12: "cuda12-speaker-recognition-development"
metal: "metal-speaker-recognition-development"
- !!merge <<: *speakerrecognition
name: "cpu-speaker-recognition"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-speaker-recognition"
@@ -4711,6 +4745,16 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-speaker-recognition"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-speaker-recognition
- !!merge <<: *speakerrecognition
name: "metal-speaker-recognition"
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-speaker-recognition"
mirrors:
- localai/localai-backends:latest-metal-darwin-arm64-speaker-recognition
- !!merge <<: *speakerrecognition
name: "metal-speaker-recognition-development"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-speaker-recognition"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-speaker-recognition
## sherpa-onnx
- !!merge <<: *sherpa-onnx
name: "sherpa-onnx-development"
@@ -4718,6 +4762,7 @@
default: "cpu-sherpa-onnx-development"
nvidia: "cuda12-sherpa-onnx-development"
nvidia-cuda-12: "cuda12-sherpa-onnx-development"
metal: "metal-sherpa-onnx-development"
- !!merge <<: *sherpa-onnx
name: "cpu-sherpa-onnx"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-sherpa-onnx"
@@ -4738,3 +4783,13 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-sherpa-onnx"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-sherpa-onnx
- !!merge <<: *sherpa-onnx
name: "metal-sherpa-onnx"
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-sherpa-onnx"
mirrors:
- localai/localai-backends:latest-metal-darwin-arm64-sherpa-onnx
- !!merge <<: *sherpa-onnx
name: "metal-sherpa-onnx-development"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-sherpa-onnx"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-sherpa-onnx

View File

@@ -407,6 +407,24 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
if not request.Prompt and request.UseTokenizerTemplate and request.Messages:
messages = messages_to_dicts(request.Messages)
# The mlx-lm tokenizer only carries a text-LM chat template. A
# vision-language checkpoint (e.g. gemma-4 E4B) loaded here has no
# usable template, so apply_chat_template silently passes the raw
# text through and the model just echoes/loops (issue #10269).
# Warn loudly so the misroute is visible; such models belong on the
# mlx-vlm backend.
chat_template = getattr(self.tokenizer, "chat_template", None)
if not chat_template:
underlying = getattr(self.tokenizer, "_tokenizer", None)
chat_template = getattr(underlying, "chat_template", None)
if not chat_template:
print(
"WARNING: this model has no chat template; output may be "
"degenerate. Vision-language models (e.g. gemma-4 E4B) must "
"use the 'mlx-vlm' backend instead of 'mlx'.",
file=sys.stderr,
)
kwargs = {"tokenize": False, "add_generation_prompt": True}
if request.Tools:
try:

View File

@@ -0,0 +1,5 @@
torch
torchaudio
speechbrain
transformers
onnxruntime

View File

@@ -150,9 +150,24 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
d["reasoning_content"] = msg.reasoning_content
if msg.tool_calls:
try:
d["tool_calls"] = json.loads(msg.tool_calls)
tool_calls = json.loads(msg.tool_calls)
except json.JSONDecodeError:
pass
else:
# OpenAI wire format carries function.arguments as a
# JSON-encoded string, but chat templates (e.g. Qwen3)
# iterate over it as a mapping. vLLM's own OpenAI server
# parses arguments before applying the template, so do
# the same here.
if isinstance(tool_calls, list):
for tc in tool_calls:
func = tc.get("function") if isinstance(tc, dict) else None
if isinstance(func, dict) and isinstance(func.get("arguments"), str):
try:
func["arguments"] = json.loads(func["arguments"])
except json.JSONDecodeError:
pass
d["tool_calls"] = tool_calls
result.append(d)
return result

View File

@@ -11,6 +11,29 @@ import (
"github.com/mudler/xlog"
)
// startMITMIfConfigured brings up the cloudproxy MITM listener when an
// address is configured, treating any startup failure as non-fatal.
//
// The listener is opt-in middleware whose address is persisted in runtime
// settings (/api/settings → runtime_settings.json) and replayed on every
// boot. A bad value — e.g. a host the process can't bind, like a LAN IP
// inside a container — must NOT abort the whole server: doing so crash-loops
// with no way out, because the Settings UI used to correct the address can't
// load if startup never completes. So on failure we log loudly and carry on;
// the admin fixes the address via /api/settings, which calls RestartMITM.
func startMITMIfConfigured(app *Application, options *config.ApplicationConfig) {
if options.MITMListen == "" {
return
}
if err := startMITMProxy(app, options); err != nil {
xlog.Error("mitm: cloudproxy listener failed to start — continuing without it",
"listen", options.MITMListen,
"error", err,
"hint", "fix the address via Settings (e.g. \":8082\" to bind all interfaces) and the listener will restart",
)
}
}
func startMITMProxy(app *Application, options *config.ApplicationConfig) error {
app.mitmMutex.Lock()
defer app.mitmMutex.Unlock()

View File

@@ -0,0 +1,58 @@
package application
import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// minimal Application wired enough for startMITMProxy: an empty model
// config loader (no host claims), CA written under a temp DataPath.
func newMITMTestApp(dataPath string) (*Application, *config.ApplicationConfig) {
state, err := system.GetSystemState()
Expect(err).NotTo(HaveOccurred())
state.Model.ModelsPath = dataPath
opts := config.NewApplicationConfig(
config.WithSystemState(state),
config.WithDataPath(dataPath),
)
return newApplication(opts), opts
}
var _ = Describe("startMITMIfConfigured", func() {
It("does nothing when no listen address is configured", func() {
app, opts := newMITMTestApp(GinkgoT().TempDir())
opts.MITMListen = ""
Expect(func() { startMITMIfConfigured(app, opts) }).NotTo(Panic())
Expect(app.mitmServer.Load()).To(BeNil(), "no listener should be stored when disabled")
})
// Regression: a persisted-but-unbindable MITM address (e.g. a LAN host
// inside a container) must not abort startup. startMITMIfConfigured
// swallows the bind error so the rest of LocalAI still comes up and the
// admin can fix the address via the Settings UI.
It("logs and continues when the listen address cannot be bound", func() {
app, opts := newMITMTestApp(GinkgoT().TempDir())
// 192.0.2.1 is TEST-NET-1 (RFC 5737): guaranteed not assigned to any
// local interface, so bind fails deterministically without DNS.
opts.MITMListen = "192.0.2.1:8082"
Expect(func() { startMITMIfConfigured(app, opts) }).NotTo(Panic())
Expect(app.mitmServer.Load()).To(BeNil(), "failed listener must not be stored")
})
It("starts and stores the listener on a bindable address", func() {
app, opts := newMITMTestApp(GinkgoT().TempDir())
opts.MITMListen = "127.0.0.1:0" // OS-assigned free port
startMITMIfConfigured(app, opts)
srv := app.mitmServer.Load()
Expect(srv).NotTo(BeNil(), "listener should be stored on success")
DeferCleanup(srv.Stop)
Expect(srv.Addr()).NotTo(BeEmpty())
})
})

View File

@@ -1,63 +1,120 @@
package application
import (
"context"
"fmt"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
)
// adapterConfig resolves a model name to its runtime ModelConfig, or
// nil when the name is unknown. Shared by the router-facing factories
// below and by ModelConfigLookup.
// adapterConfig resolves a model name to its runtime ModelConfig, or nil when
// unknown. LoadModelConfigFileByNameDefaultOptions never returns nil — for an
// unknown name it returns a defaults-filled stub with an empty Name (the YAML
// `name:` field is required by Validate), which is how we tell the two apart.
func (a *Application) adapterConfig(modelName string) *config.ModelConfig {
cfg, err := a.backendLoader.LoadModelConfigFileByNameDefaultOptions(modelName, a.applicationConfig)
if err != nil || cfg == nil {
if err != nil || cfg == nil || cfg.Name == "" {
return nil
}
return cfg
}
// ModelConfigLookup is the lookup function the router middleware's
// classifier validator uses to confirm classifier_model declares
// FLAG_SCORE before binding it.
// ModelConfigLookup is the lookup the router middleware's classifier validator
// uses to confirm classifier_model declares FLAG_SCORE before binding it.
func (a *Application) ModelConfigLookup() func(modelName string) *config.ModelConfig {
return a.adapterConfig
}
// Scorer returns a backend.Scorer bound to the named model, or nil
// when the model is unknown. Used as a method value (app.Scorer) by
// router.ClassifierDeps — no factory-of-factory wrapper needed.
// The router-facing factories below (Scorer, Embedder, Reranker, TokenCounter)
// bind a model NAME at construction and re-resolve the CONFIG on every call.
// Capturing the config at construction would bake in whatever state
// adapterConfig saw first — including a stub returned before the YAML reached
// bcl.configs (e.g. /import-model or gallery install racing startup). The
// classifier registry caches factories by router-config fingerprint, so a
// once-stale capture stays stale until the router config is edited.
func (a *Application) Scorer(modelName string) backend.Scorer {
cfg := a.adapterConfig(modelName)
if cfg == nil {
if a.adapterConfig(modelName) == nil {
return nil
}
return backend.NewScorer(a.modelLoader, *cfg, a.applicationConfig)
return &lazyScorer{app: a, modelName: modelName}
}
type lazyScorer struct {
app *Application
modelName string
}
func (l *lazyScorer) Score(ctx context.Context, prompt string, candidates []string) ([]backend.CandidateScore, error) {
cfg := l.app.adapterConfig(l.modelName)
if cfg == nil {
return nil, fmt.Errorf("scorer: model %q no longer available", l.modelName)
}
return backend.NewScorer(l.app.modelLoader, *cfg, l.app.applicationConfig).Score(ctx, prompt, candidates)
}
// TokenCounter returns a func so the middleware's literal field type accepts
// it as a method value without importing core/http/middleware from here.
func (a *Application) TokenCounter(modelName string) func(string) (int, error) {
if a.adapterConfig(modelName) == nil {
return nil
}
return func(text string) (int, error) {
cfg := a.adapterConfig(modelName)
if cfg == nil {
return 0, fmt.Errorf("token counter: model %q no longer available", modelName)
}
resp, err := backend.ModelTokenize(text, a.modelLoader, *cfg, a.applicationConfig)
if err != nil {
return 0, err
}
return len(resp.Tokens), nil
}
}
// Reranker returns a backend.Reranker bound to the named model, or
// nil when unknown. The reranker model's `type:` (e.g. "colbert")
// selects the scoring head inside the rerankers backend.
func (a *Application) Reranker(modelName string) backend.Reranker {
cfg := a.adapterConfig(modelName)
if cfg == nil {
if a.adapterConfig(modelName) == nil {
return nil
}
return backend.NewReranker(a.modelLoader, *cfg, a.applicationConfig)
return &lazyReranker{app: a, modelName: modelName}
}
type lazyReranker struct {
app *Application
modelName string
}
func (l *lazyReranker) Rerank(ctx context.Context, query string, documents []string) ([]backend.RerankResult, error) {
cfg := l.app.adapterConfig(l.modelName)
if cfg == nil {
return nil, fmt.Errorf("reranker: model %q no longer available", l.modelName)
}
return backend.NewReranker(l.app.modelLoader, *cfg, l.app.applicationConfig).Rerank(ctx, query, documents)
}
// Embedder returns a backend.Embedder bound to the named model, or
// nil when unknown. Used by the router's L2 embedding cache.
func (a *Application) Embedder(modelName string) backend.Embedder {
cfg := a.adapterConfig(modelName)
if cfg == nil {
if a.adapterConfig(modelName) == nil {
return nil
}
return backend.NewEmbedder(a.modelLoader, *cfg, a.applicationConfig)
return &lazyEmbedder{app: a, modelName: modelName}
}
// VectorStore returns a backend.VectorStore for the named collection,
// or nil when the name is empty. Each router model gets its own
// backend process via the model loader's cache keyed by storeName.
type lazyEmbedder struct {
app *Application
modelName string
}
func (l *lazyEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
cfg := l.app.adapterConfig(l.modelName)
if cfg == nil {
return nil, fmt.Errorf("embedder: model %q no longer available", l.modelName)
}
return backend.NewEmbedder(l.app.modelLoader, *cfg, l.app.applicationConfig).Embed(ctx, text)
}
// VectorStore takes a store name, not a model name — no adapterConfig, no
// staleness to avoid.
func (a *Application) VectorStore(storeName string) backend.VectorStore {
return backend.NewVectorStore(a.modelLoader, a.applicationConfig, storeName)
}

View File

@@ -0,0 +1,155 @@
package application
import (
"context"
"os"
"path/filepath"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Regression: the router-facing factories used to capture
// *config.ModelConfig at construction. A gallery install that raced
// startup left a stub (Backend="") bound for the lifetime of the
// classifier registry's cache entry, bypassing the user's `backend:`
// config. These specs pin the lazy re-resolve.
var _ = Describe("router_factories lazy config resolution", func() {
var (
tmpDir string
app *Application
)
BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "router-factories-*")
Expect(err).NotTo(HaveOccurred())
appCfg := &config.ApplicationConfig{
Context: context.Background(),
SystemState: &system.SystemState{Model: system.Model{ModelsPath: tmpDir}},
}
app = &Application{
backendLoader: config.NewModelConfigLoader(tmpDir),
modelLoader: model.NewModelLoader(appCfg.SystemState),
applicationConfig: appCfg,
}
})
AfterEach(func() {
_ = os.RemoveAll(tmpDir)
})
// writeCfg seeds both the on-disk YAML and the in-memory cache —
// removing only the cache would fall through to file-read.
writeCfg := func(name, backend string) {
yaml := "name: " + name + "\nbackend: " + backend + "\nparameters:\n model: " + name + ".bin\n"
Expect(os.WriteFile(filepath.Join(tmpDir, name+".yaml"), []byte(yaml), 0644)).To(Succeed())
Expect(app.backendLoader.LoadModelConfigsFromPath(tmpDir)).To(Succeed())
cfg, ok := app.backendLoader.GetModelConfig(name)
Expect(ok).To(BeTrue(), "config must be loaded before the spec runs")
Expect(cfg.Backend).To(Equal(backend))
}
// removeCfg purges both the cache and the YAML so LoadModelConfigFileByName
// returns the empty-stub case and adapterConfig returns nil.
removeCfg := func(name string) {
app.backendLoader.RemoveModelConfig(name)
Expect(os.Remove(filepath.Join(tmpDir, name+".yaml"))).To(Succeed())
}
Context("Embedder", func() {
It("returns nil at construction for an unknown model", func() {
Expect(app.Embedder("missing")).To(BeNil())
})
It("re-resolves the model config on each Embed call", func() {
writeCfg("emb-test", "llama-cpp")
emb := app.Embedder("emb-test")
Expect(emb).NotTo(BeNil())
// The factory must hold the NAME, not a captured config —
// otherwise stale captures survive cache invalidation.
lazy, ok := emb.(*lazyEmbedder)
Expect(ok).To(BeTrue(), "Embedder must return *lazyEmbedder")
Expect(lazy.modelName).To(Equal("emb-test"))
// Mutate the cached config. A lazy implementation sees the
// update on the next adapterConfig call; a captured-at-
// construction implementation would still see "llama-cpp".
app.backendLoader.UpdateModelConfig("emb-test", func(c *config.ModelConfig) {
c.Backend = "rerankers"
})
Expect(lazy.app.adapterConfig("emb-test").Backend).To(Equal("rerankers"))
// Remove the config entirely → Embed must surface the disappearance.
removeCfg("emb-test")
_, err := emb.Embed(context.Background(), "anything")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no longer available"))
})
})
Context("Scorer", func() {
It("returns nil at construction for an unknown model", func() {
Expect(app.Scorer("missing")).To(BeNil())
})
It("re-resolves the model config on each Score call", func() {
writeCfg("score-test", "llama-cpp")
sc := app.Scorer("score-test")
Expect(sc).NotTo(BeNil())
lazy, ok := sc.(*lazyScorer)
Expect(ok).To(BeTrue(), "Scorer must return *lazyScorer")
Expect(lazy.modelName).To(Equal("score-test"))
removeCfg("score-test")
_, err := sc.Score(context.Background(), "prompt", []string{"a"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no longer available"))
})
})
Context("Reranker", func() {
It("returns nil at construction for an unknown model", func() {
Expect(app.Reranker("missing")).To(BeNil())
})
It("re-resolves the model config on each Rerank call", func() {
writeCfg("rerank-test", "rerankers")
rr := app.Reranker("rerank-test")
Expect(rr).NotTo(BeNil())
lazy, ok := rr.(*lazyReranker)
Expect(ok).To(BeTrue(), "Reranker must return *lazyReranker")
Expect(lazy.modelName).To(Equal("rerank-test"))
removeCfg("rerank-test")
_, err := rr.Rerank(context.Background(), "q", []string{"d"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no longer available"))
})
})
Context("TokenCounter", func() {
It("returns nil at construction for an unknown model", func() {
Expect(app.TokenCounter("missing")).To(BeNil())
})
It("re-resolves the model config on each call", func() {
writeCfg("tok-test", "llama-cpp")
tc := app.TokenCounter("tok-test")
Expect(tc).NotTo(BeNil())
removeCfg("tok-test")
_, err := tc("anything")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no longer available"))
})
})
})

View File

@@ -462,11 +462,7 @@ func New(opts ...config.AppOption) (*Application, error) {
// traffic doesn't need a parallel config for MITM traffic.
// Runs after loadRuntimeSettingsFromFile so a listener configured
// via /api/settings is brought back up across restarts.
if options.MITMListen != "" {
if err := startMITMProxy(application, options); err != nil {
return nil, fmt.Errorf("mitm: startup: %w", err)
}
}
startMITMIfConfigured(application, options)
application.ModelLoader().SetBackendLoggingEnabled(options.EnableBackendLogging)

View File

@@ -100,8 +100,13 @@ func ModelEmbedding(ctx context.Context, s string, tokens []int, loader *model.M
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
traceData := map[string]any{
"input_text": trace.TruncateString(s, 1000),
"input_tokens_count": len(tokens),
"input_text": trace.TruncateString(s, 1000),
}
// Only present for token-mode callers (pre-tokenized override);
// emitting "0" alongside input_text would read as "consumed zero
// tokens", which is wrong.
if len(tokens) > 0 {
traceData["input_tokens_count"] = len(tokens)
}
startTime := time.Now()

View File

@@ -87,11 +87,47 @@ func getSeed(c config.ModelConfig) int32 {
return seed
}
func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
b := 512
if c.Batch != 0 {
b = c.Batch
// DefaultContextSize and DefaultBatchSize are the backend's fallbacks when a
// model config leaves them unset. Exported so callers that must respect the
// effective decode window — notably the router's prompt trimmer — resolve the
// same numbers grpcModelOpts does instead of guessing.
const (
DefaultContextSize = 4096
DefaultBatchSize = 512
)
// EffectiveContextSize is the context window the backend will run with: the
// configured value, or DefaultContextSize when unset.
func EffectiveContextSize(c config.ModelConfig) int {
if c.ContextSize != nil {
return *c.ContextSize
}
return DefaultContextSize
}
// EffectiveBatchSize is the single-decode batch the backend will run with.
// Score, embedding and rerank all process the whole input in one pass: score
// decodes prompt+candidate (asserts n_tokens <= n_batch), and embedding/rerank
// pool over the full sequence in one physical batch (n_ubatch). So the batch
// is sized to the context — anything that fits the context fits one pass,
// avoiding both the GGML_ASSERT crash and the "input is too large to process"
// error. Explicit `batch:` always wins.
func EffectiveBatchSize(c config.ModelConfig) int {
if c.Batch != 0 {
return c.Batch
}
singlePass := c.HasUsecases(config.FLAG_SCORE) ||
c.HasUsecases(config.FLAG_EMBEDDINGS) ||
c.HasUsecases(config.FLAG_RERANK)
if ctx := EffectiveContextSize(c); singlePass && ctx > DefaultBatchSize {
return ctx
}
return DefaultBatchSize
}
func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
ctxSize := EffectiveContextSize(c)
b := EffectiveBatchSize(c)
flashAttention := "auto"
@@ -134,11 +170,6 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
}
}
ctxSize := 4096
if c.ContextSize != nil {
ctxSize = *c.ContextSize
}
mmlock := false
if c.MMlock != nil {
mmlock = *c.MMlock

View File

@@ -97,3 +97,67 @@ var _ = Describe("gRPCPredictOpts reasoning_effort metadata", func() {
Expect(opts.Metadata).ToNot(HaveKey("reasoning_effort"))
})
})
var _ = Describe("grpcModelOpts NBatch", func() {
scoreUsecase := config.FLAG_SCORE
threads := 1
ctx := 4096
It("defaults to 512 for an ordinary model", func() {
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}}
opts := grpcModelOpts(cfg, "/tmp/models")
Expect(opts.NBatch).To(BeEquivalentTo(512))
})
It("sizes the batch to the context window for score models", func() {
// Score models decode the whole prompt+candidate in one
// llama_decode; n_batch must cover it or the backend aborts.
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}, KnownUsecases: &scoreUsecase}
opts := grpcModelOpts(cfg, "/tmp/models")
Expect(opts.NBatch).To(BeEquivalentTo(4096))
})
It("keeps an explicit batch over the score default", func() {
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}, KnownUsecases: &scoreUsecase}
cfg.Batch = 1024
opts := grpcModelOpts(cfg, "/tmp/models")
Expect(opts.NBatch).To(BeEquivalentTo(1024))
})
It("sizes the batch to the context window for embedding models", func() {
// Embedding/rerank pool over the whole sequence in one physical batch
// (n_ubatch); without this the input is capped at the 512 default and
// the backend returns "input is too large to process".
embeddings := true
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}}
cfg.Embeddings = &embeddings
opts := grpcModelOpts(cfg, "/tmp/models")
Expect(opts.NBatch).To(BeEquivalentTo(4096))
})
It("sizes the batch to the context window for rerank models", func() {
reranking := true
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}}
cfg.Reranking = &reranking
opts := grpcModelOpts(cfg, "/tmp/models")
Expect(opts.NBatch).To(BeEquivalentTo(4096))
})
It("does not raise the batch when a score model's context is below the default", func() {
small := 256
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &small}, KnownUsecases: &scoreUsecase}
opts := grpcModelOpts(cfg, "/tmp/models")
Expect(opts.NBatch).To(BeEquivalentTo(512))
})
It("sizes the batch to the effective 4096 default for a score model with no explicit context_size", func() {
// The crash case: the backend defaults n_ctx to 4096, so n_batch must
// follow even when context_size is unset — otherwise n_batch stays 512
// against a 4096 window and the score decode hits the GGML_ASSERT.
cfg := config.ModelConfig{Threads: &threads, KnownUsecases: &scoreUsecase}
Expect(cfg.ContextSize).To(BeNil())
opts := grpcModelOpts(cfg, "/tmp/models")
Expect(opts.NBatch).To(BeEquivalentTo(4096))
Expect(opts.ContextSize).To(BeEquivalentTo(4096), "n_batch must match the effective n_ctx the backend receives")
})
})

View File

@@ -3,9 +3,10 @@ package backend
import (
"context"
"fmt"
"strings"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/LocalAI/pkg/model"
@@ -39,34 +40,85 @@ func (s *localVectorStore) backend(_ context.Context) (grpc.Backend, error) {
return StoreBackend(s.loader, s.appConfig, s.storeName, "")
}
func (s *localVectorStore) Search(ctx context.Context, vec []float32) (float64, []byte, bool, error) {
be, err := s.backend(ctx)
if err != nil {
return 0, nil, false, fmt.Errorf("vector store load: %w", err)
func (s *localVectorStore) Search(ctx context.Context, vec []float32) (sim float64, payload []byte, ok bool, err error) {
start := time.Now()
outcome := "hit"
defer func() {
s.recordTrace(start, "search", len(vec), sim, outcome, err)
}()
be, berr := s.backend(ctx)
if berr != nil {
outcome = "backend_load_error"
return 0, nil, false, fmt.Errorf("vector store load: %w", berr)
}
_, values, similarities, err := store.Find(ctx, be, vec, 1)
if err != nil {
// local-store's Find returns "existing length is -1" before
// any keys are inserted. Surface that as a clean miss so the
// cache layer treats it as an empty store and proceeds to
// Insert rather than skipping.
if strings.Contains(err.Error(), "existing length is -1") {
return 0, nil, false, nil
}
return 0, nil, false, fmt.Errorf("vector store find: %w", err)
_, values, similarities, ferr := store.Find(ctx, be, vec, 1)
if ferr != nil {
outcome = "find_error"
return 0, nil, false, fmt.Errorf("vector store find: %w", ferr)
}
if len(values) == 0 || len(similarities) == 0 {
outcome = "miss"
return 0, nil, false, nil
}
return float64(similarities[0]), values[0], true, nil
}
func (s *localVectorStore) Insert(ctx context.Context, vec []float32, payload []byte) error {
be, err := s.backend(ctx)
if err != nil {
return fmt.Errorf("vector store load: %w", err)
func (s *localVectorStore) Insert(ctx context.Context, vec []float32, payload []byte) (err error) {
start := time.Now()
outcome := "ok"
defer func() {
s.recordTrace(start, "insert", len(vec), 0, outcome, err)
}()
be, berr := s.backend(ctx)
if berr != nil {
outcome = "backend_load_error"
return fmt.Errorf("vector store load: %w", berr)
}
return store.SetSingle(ctx, be, vec, payload)
if serr := store.SetSingle(ctx, be, vec, payload); serr != nil {
outcome = "insert_error"
return serr
}
return nil
}
// recordTrace surfaces vector-store calls in /api/backend-traces, including
// the backend-load-failure path that otherwise vanishes into an xlog.Warn.
// modelName uses the store namespace (e.g. "router-cache-smart-router") so
// admins can tell which router's cache misbehaved; the backend is always
// "local-store" and can't disambiguate.
func (s *localVectorStore) recordTrace(start time.Time, op string, vecDim int, sim float64, outcome string, err error) {
if s.appConfig == nil || !s.appConfig.EnableTracing {
return
}
trace.InitBackendTracingIfEnabled(s.appConfig.TracingMaxItems, s.appConfig.TracingMaxBodyBytes)
errStr := ""
if err != nil {
errStr = err.Error()
}
summary := op + " " + outcome
if op == "search" && outcome == "hit" {
summary = fmt.Sprintf("search hit (sim=%.3f)", sim)
}
data := map[string]any{
"op": op,
"outcome": outcome,
"vector_dim": vecDim,
}
// Only include similarity for a real neighbor — miss/empty_store would
// otherwise render "similarity: 0" and read as a measured value.
if op == "search" && outcome == "hit" {
data["similarity"] = sim
}
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: start,
Duration: time.Since(start),
Type: trace.BackendTraceVectorStore,
ModelName: s.storeName,
Backend: model.LocalStoreBackend,
Summary: summary,
Error: errStr,
Data: data,
})
}
func StoreBackend(sl *model.ModelLoader, appConfig *config.ApplicationConfig, storeName string, backend string) (grpc.Backend, error) {

View File

@@ -0,0 +1,88 @@
package backend
import (
"context"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// findVectorStoreTrace returns the most recent vector_store trace whose
// model_name matches storeName, or nil if none was recorded. Used by
// the specs below to assert the trace landed without relying on
// ring-buffer ordering across other tests in the suite.
func findVectorStoreTrace(storeName string) *trace.BackendTrace {
traces := trace.GetBackendTraces()
for i := range traces {
bt := &traces[i]
if bt.Type == trace.BackendTraceVectorStore && bt.ModelName == storeName {
return bt
}
}
return nil
}
var _ = Describe("localVectorStore tracing", func() {
// Pin the trace surface admins read from /api/backend-traces.
// The original failure mode that motivated these specs — the
// local-store backend not installed — was silent on every surface
// except a per-call xlog.Warn. With tracing wired in, the row
// appears next to the embedder/score traces for the same request.
BeforeEach(func() {
trace.ClearBackendTraces()
})
It("records a vector_store trace with outcome=backend_load_error when the backend can't be loaded", func() {
// nil ModelLoader → s.backend → StoreBackend → panics on load.
// Use a real-but-empty loader so the failure surfaces as an
// error instead, exercising the load-failure trace path the
// admin would hit when local-store isn't installed.
appCfg := &config.ApplicationConfig{
EnableTracing: true,
TracingMaxItems: 16,
TracingMaxBodyBytes: 1024,
}
s := &localVectorStore{
loader: model.NewModelLoader(&system.SystemState{}),
appConfig: appCfg,
storeName: "router-cache-test",
}
// Search must surface the error AND record a trace describing it.
_, _, _, err := s.Search(context.Background(), []float32{0.1, 0.2, 0.3})
Expect(err).To(HaveOccurred())
Eventually(func() *trace.BackendTrace {
return findVectorStoreTrace("router-cache-test")
}).ShouldNot(BeNil())
bt := findVectorStoreTrace("router-cache-test")
Expect(bt.Backend).To(Equal(model.LocalStoreBackend))
Expect(bt.Data["op"]).To(Equal("search"))
Expect(bt.Data["outcome"]).To(Equal("backend_load_error"))
Expect(bt.Data["vector_dim"]).To(Equal(3))
// Error is the wrapped "vector store load: …" surfaced to the caller.
Expect(bt.Error).To(ContainSubstring("vector store load"))
})
It("does not record a trace when tracing is disabled", func() {
// Opt-out path: appConfig.EnableTracing=false must short-circuit
// before InitBackendTracingIfEnabled, so a workload with tracing
// turned off doesn't pay the channel-send cost per cache call.
appCfg := &config.ApplicationConfig{EnableTracing: false}
s := &localVectorStore{
loader: model.NewModelLoader(&system.SystemState{}),
appConfig: appCfg,
storeName: "router-cache-disabled",
}
_, _, _, _ = s.Search(context.Background(), []float32{1})
Consistently(func() *trace.BackendTrace {
return findVectorStoreTrace("router-cache-disabled")
}).Should(BeNil())
})
})

View File

@@ -7,9 +7,23 @@ import (
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/model"
)
// tokenizeTokenCount returns the number of tokens in a backend response,
// treating a nil response as zero. The gRPC client returns (nil, err) on
// failure, and the tracing block below runs before that error is returned —
// so the count must be read nil-safely here. Reading resp.Tokens on a nil
// resp previously panicked the whole HTTP handler when tracing was enabled
// (e.g. a transient tokenize failure during router probe-budget sizing).
func tokenizeTokenCount(resp *pb.TokenizationResponse) int {
if resp == nil {
return 0
}
return len(resp.Tokens)
}
func ModelTokenize(s string, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (schema.TokenizeResponse, error) {
var inferenceModel grpc.Backend
@@ -40,10 +54,7 @@ func ModelTokenize(s string, loader *model.ModelLoader, modelConfig config.Model
errStr = err.Error()
}
tokenCount := 0
if resp.Tokens != nil {
tokenCount = len(resp.Tokens)
}
tokenCount := tokenizeTokenCount(resp)
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: startTime,
@@ -64,8 +75,8 @@ func ModelTokenize(s string, loader *model.ModelLoader, modelConfig config.Model
return schema.TokenizeResponse{}, err
}
if resp.Tokens == nil {
resp.Tokens = make([]int32, 0)
if resp == nil || resp.Tokens == nil {
return schema.TokenizeResponse{Tokens: make([]int32, 0)}, nil
}
return schema.TokenizeResponse{

View File

@@ -0,0 +1,27 @@
package backend
import (
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("tokenizeTokenCount", func() {
// Regression: the gRPC client returns (nil, err) when a tokenize call
// fails, and ModelTokenize's tracing block reads the token count before
// the error is returned. Dereferencing a nil response there panicked the
// HTTP handler (nil pointer dereference) — e.g. a transient tokenize
// failure while the router sized its probe-token budget.
It("returns zero for a nil response instead of panicking", func() {
Expect(tokenizeTokenCount(nil)).To(Equal(0))
})
It("returns zero when the response carries no tokens", func() {
Expect(tokenizeTokenCount(&pb.TokenizationResponse{})).To(Equal(0))
})
It("counts the tokens present on the response", func() {
Expect(tokenizeTokenCount(&pb.TokenizationResponse{Tokens: []int32{1, 2, 3}})).To(Equal(3))
})
})

View File

@@ -65,7 +65,7 @@ type ApplicationConfig struct {
//
// patterns:
// - id: email
// action: route_local # downgrade default mask -> route_local
// action: allow # downgrade default mask -> allow (log only)
// - id: ssn
// action: block # upgrade default mask -> block
//
@@ -488,6 +488,16 @@ func (o *ApplicationConfig) GetEffectiveMaxActiveBackends() int {
return 0
}
// WatchdogShouldRun reports whether the live watchdog process should be
// running for the current config. It mirrors the gating in
// (*Application).startWatchdog so the /api/settings start/stop decision and
// the startup path agree on a single source of truth: the watchdog runs when
// idle/busy checks are enabled (WatchDog), when LRU eviction is active
// (effective max active backends > 0), or when the memory reclaimer is on.
func (o *ApplicationConfig) WatchdogShouldRun() bool {
return o.WatchDog || o.GetEffectiveMaxActiveBackends() > 0 || o.MemoryReclaimerEnabled
}
// WithForceEvictionWhenBusy sets whether to force eviction even when models have active API calls
func WithForceEvictionWhenBusy(enabled bool) AppOption {
return func(o *ApplicationConfig) {
@@ -1198,18 +1208,22 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
}
if settings.WatchdogIdleEnabled != nil {
o.WatchDogIdle = *settings.WatchdogIdleEnabled
if o.WatchDogIdle {
o.WatchDog = true
}
requireRestart = true
}
if settings.WatchdogBusyEnabled != nil {
o.WatchDogBusy = *settings.WatchdogBusyEnabled
if o.WatchDogBusy {
o.WatchDog = true
}
requireRestart = true
}
// The React Settings "Enable Watchdog" master toggle manages only the
// idle/busy checks — watchdog_enabled is vestigial in that UI. Whenever
// either idle/busy field is present in the body, derive the run-state from
// idle||busy so a cold enable starts the watchdog and a full disable stops
// it, instead of trusting the stale watchdog_enabled the UI never updates.
// This mirrors the startup invariant in startup.go. An API client posting
// only watchdog_enabled (idle/busy absent) keeps its explicit value.
if settings.WatchdogIdleEnabled != nil || settings.WatchdogBusyEnabled != nil {
o.WatchDog = o.WatchDogIdle || o.WatchDogBusy
}
if settings.WatchdogIdleTimeout != nil {
if dur, err := time.ParseDuration(*settings.WatchdogIdleTimeout); err == nil {
o.WatchDogIdleTimeout = dur

View File

@@ -223,6 +223,69 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
Expect(appConfig.WatchDogBusy).To(BeTrue())
})
// Residual #9125: the React Settings "Enable Watchdog" master toggle
// manages only watchdog_idle_enabled / watchdog_busy_enabled — it never
// touches the vestigial watchdog_enabled field. On a cold enable the
// body therefore carries watchdog_enabled=false alongside idle/busy=true.
// The derived run-state (WatchDog) must follow idle||busy so the live
// watchdog actually starts, not the stale watchdog_enabled=false.
It("should derive WatchDog from idle||busy on a cold enable even when watchdog_enabled=false", func() {
appConfig := &ApplicationConfig{WatchDog: false}
watchdogEnabled := false
watchdogIdle := true
watchdogBusy := true
rs := &RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
WatchdogIdleEnabled: &watchdogIdle,
WatchdogBusyEnabled: &watchdogBusy,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.WatchDog).To(BeTrue())
Expect(appConfig.WatchdogShouldRun()).To(BeTrue())
})
// The disable direction: the master toggle off sends idle=false,
// busy=false, but watchdog_enabled may still be the stale true loaded
// before the change. WatchDog must follow idle||busy down to false so
// the live watchdog is stopped (it stays stopped unless LRU / memory
// reclaimer keep it alive, which is gated by WatchdogShouldRun).
It("should disable WatchDog when both idle and busy are turned off", func() {
appConfig := &ApplicationConfig{WatchDog: true, WatchDogIdle: true, WatchDogBusy: true}
watchdogEnabled := true
watchdogIdle := false
watchdogBusy := false
rs := &RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
WatchdogIdleEnabled: &watchdogIdle,
WatchdogBusyEnabled: &watchdogBusy,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.WatchDog).To(BeFalse())
Expect(appConfig.WatchdogShouldRun()).To(BeFalse())
})
// Backward compatibility: an API client that posts only watchdog_enabled
// (idle/busy nil) keeps the explicit value — the idle/busy derivation
// only kicks in when those fields are actually present in the body.
It("should preserve explicit watchdog_enabled when idle/busy are absent", func() {
appConfig := &ApplicationConfig{WatchDog: false}
watchdogEnabled := true
rs := &RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.WatchDog).To(BeTrue())
})
It("should handle MaxActiveBackends and update SingleBackend accordingly", func() {
appConfig := &ApplicationConfig{}

View File

@@ -93,6 +93,9 @@ func applyOverride(f *FieldMeta, o FieldMetaOverride) {
if o.Component != "" {
f.Component = o.Component
}
if o.Language != "" {
f.Language = o.Language
}
if o.Placeholder != "" {
f.Placeholder = o.Placeholder
}

View File

@@ -8,6 +8,7 @@ const (
ProviderModelsTTS = "models:tts"
ProviderModelsTranscript = "models:transcript"
ProviderModelsVAD = "models:vad"
ProviderModelsScore = "models:score"
)
// Static option lists embedded directly in field metadata.

View File

@@ -226,6 +226,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Label: "Chat Template",
Description: "Go template for chat completion requests",
Component: "code-editor",
Language: "gotemplate",
Order: 40,
},
"template.chat_message": {
@@ -233,6 +234,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Label: "Chat Message Template",
Description: "Go template for individual chat messages",
Component: "code-editor",
Language: "gotemplate",
Order: 41,
},
"template.completion": {
@@ -240,13 +242,22 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Label: "Completion Template",
Description: "Go template for completion requests",
Component: "code-editor",
Language: "gotemplate",
Order: 42,
},
"template.function": {
Section: "templates",
Label: "Functions Template",
Description: "Go template applied when tools/functions are present in the request",
Component: "code-editor",
Language: "gotemplate",
Order: 43,
},
"template.use_tokenizer_template": {
Section: "templates",
Label: "Use Tokenizer Template",
Description: "Use the chat template from the model's tokenizer config",
Order: 43,
Order: 44,
},
// Router section template — kept in the templates UI section
// (rather than the router section under "other") so operators
@@ -257,7 +268,8 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Label: "Router Classifier System Prompt",
Description: "Go text/template (with sprig functions) for the routing system prompt the score classifier feeds to its classifier_model. Executed with `.Policies` ([]{Label, Description}). Empty falls back to the built-in Arch-Router-shaped prompt (route-listing block + JSON output schema). Override when the classifier model was trained on a different schema or you need the routing instructions in a different language. The candidate format scored against the model is fixed at `{\"route\": \"<label>\"}` — keep your override's output schema instruction matching that.",
Component: "code-editor",
Order: 44,
Language: "gotemplate",
Order: 45,
},
// --- Pipeline ---
@@ -400,14 +412,14 @@ func DefaultRegistry() map[string]FieldMetaOverride {
// --- PII filtering (per-model) ---
"pii.enabled": {
Section: "other",
Section: "pii",
Label: "PII Filtering Enabled",
Description: "Enable PII redaction middleware for this model. Unset means use the default (off for local backends, on for proxy-* / cloud-hosted backends).",
Component: "toggle",
Order: 200,
},
"pii.patterns": {
Section: "other",
Section: "pii",
Label: "PII Pattern Overrides",
Description: "Override the global default action for specific patterns on this model. Patterns not listed here inherit the global action (Settings → Middleware → Filtering).",
Component: "pii-pattern-list",
@@ -420,7 +432,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
// fails closed — the chat handler does NOT silently fall back
// to the local gRPC pipeline.
"proxy.mode": {
Section: "other",
Section: "proxy",
Label: "Proxy Mode",
Description: "passthrough forwards the client's OpenAI body verbatim — point upstream_url at an OpenAI-compatible endpoint (incl. Anthropic's /v1/chat/completions compat layer). translate converts OpenAI ↔ Anthropic Messages so you can target a native API (/v1/messages); tool_calls and usage tokens survive the round-trip.",
Component: "select",
@@ -432,7 +444,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 208,
},
"proxy.provider": {
Section: "other",
Section: "proxy",
Label: "Proxy Provider",
Description: "Upstream API family. Drives auth header shape (Bearer vs x-api-key + anthropic-version) and, in translate mode, which request/response codec is used.",
Component: "select",
@@ -444,28 +456,28 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 209,
},
"proxy.upstream_url": {
Section: "other",
Section: "proxy",
Label: "Proxy Upstream URL",
Description: "Full POST endpoint of the upstream provider (e.g. https://api.openai.com/v1/chat/completions). Only used when Backend is cloud-proxy.",
Component: "input",
Order: 210,
},
"proxy.api_key_env": {
Section: "other",
Section: "proxy",
Label: "Proxy API Key Env Var",
Description: "Name of the environment variable holding the upstream API key. Reading from env keeps the secret out of the YAML and the admin UI.",
Component: "input",
Order: 211,
},
"proxy.upstream_model": {
Section: "other",
Section: "proxy",
Label: "Proxy Upstream Model",
Description: "Model name sent to the upstream. Leave empty to forward the client's model field unchanged. Useful when the LocalAI alias differs from the upstream's canonical name.",
Component: "input",
Order: 212,
},
"proxy.request_timeout_seconds": {
Section: "other",
Section: "proxy",
Label: "Proxy Request Timeout (seconds)",
Description: "Caps the upstream HTTP request duration. 0 disables the deadline; the request still ends when the client disconnects.",
Component: "number",
@@ -480,7 +492,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
// A host claimed by two configs is a critical error — the
// listener refuses to start until resolved.
"mitm.hosts": {
Section: "other",
Section: "mitm",
Label: "MITM Intercept Hosts",
Description: "Hostnames the cloudproxy MITM proxy terminates TLS for on behalf of this model config. PII filtering and pattern overrides flow from this model when the host is intercepted. Each host must be unique across all configs.",
Component: "string-list",
@@ -495,7 +507,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
// the middleware admin page surfaces every model with a router
// block.
"router.classifier": {
Section: "other",
Section: "router",
Label: "Classifier",
Description: "Picks a candidate by scoring every policy label against the prompt. Only \"score\" is shipped today; it asks the classifier_model to rank each label and reads off the softmax. Empty defaults to \"score\".",
Component: "select",
@@ -505,15 +517,15 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 230,
},
"router.classifier_model": {
Section: "other",
Section: "router",
Label: "Classifier Model",
Description: "Loaded LocalAI model the score classifier asks to rank each policy label as a continuation. Must support the Score gRPC primitive (today: llama-cpp, vLLM) and use the ChatML template. Arch-Router-1.5B Q4_K_M is the canonical choice; any small ChatML instruct model also works at a higher activation_threshold.",
Component: "model-select",
AutocompleteProvider: ProviderModelsChat,
AutocompleteProvider: ProviderModelsScore,
Order: 231,
},
"router.fallback": {
Section: "other",
Section: "router",
Label: "Fallback Model",
Description: "Model used when no candidate's labels cover the classifier's active label set, or when the classifier errors. Empty means router failures bubble up as HTTP 500 — fail-fast, not silent-bypass.",
Component: "model-select",
@@ -521,7 +533,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 232,
},
"router.activation_threshold": {
Section: "other",
Section: "router",
Label: "Activation Threshold",
Description: "Softmax-probability floor a policy must clear to join the active label set for a request. Higher → single-label dominant routes; lower → more multi-label activations. 0 picks the package default (0.15). On Arch-Router-1.5B a value around 0.40 keeps the dominant label clean without losing genuine compound activations.",
Component: "slider",
@@ -531,7 +543,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 233,
},
"router.classifier_cache_size": {
Section: "other",
Section: "router",
Label: "Classifier L1 Cache Size",
Description: "Bounded LRU keyed on (case-folded, whitespace-trimmed) prompt — amortises the classifier round-trip across verbatim repeats common in agent loops. 0 here means \"use the default\" (1024); the cache cannot be disabled from YAML.",
Component: "number",
@@ -539,21 +551,21 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 234,
},
"router.policies": {
Section: "other",
Section: "router",
Label: "Policies",
Description: "Label vocabulary the classifier scores over. Each policy has a label and a short natural-language description fed verbatim to the classifier model. Short action-oriented sentences work best (\"writing or debugging code\"; \"small talk\").",
Component: "router-policies",
Order: 235,
},
"router.candidates": {
Section: "other",
Section: "router",
Label: "Candidates",
Description: "Routing table: each entry binds a downstream model to a set of policy labels it can serve. Order matters — the middleware picks the FIRST candidate whose labels are a superset of the active set, so list candidates smallest → largest.",
Component: "router-candidates",
Order: 236,
},
"router.score_normalization": {
Section: "other",
Section: "router",
Label: "Score Normalization",
Description: "How the score classifier collapses per-candidate joint log-probs into the softmax input. \"raw\" (default) feeds joint log-prob as-is — on-distribution for Arch-Router (the route the model would actually emit if decoded freely). \"mean\" divides by candidate token count — fairer to long labels but off-distribution for models trained to emit fixed-format outputs.",
Component: "select",
@@ -565,7 +577,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 240,
},
"router.embedding_cache.embedding_model": {
Section: "other",
Section: "router",
Label: "L2 Cache: Embedding Model",
Description: "Embedding model used by the L2 decision cache. Embeds incoming probes and looks them up in the per-router local-store collection. Empty disables the cache entirely. nomic-embed-text-v1.5 is the recommended default.",
Component: "model-select",
@@ -573,7 +585,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 237,
},
"router.embedding_cache.similarity_threshold": {
Section: "other",
Section: "router",
Label: "L2 Cache: Similarity Threshold",
Description: "Cosine-similarity floor a cache candidate must clear to count as a hit. 0 picks the package default (0.80). Re-tune per embedding model — the histogram on the Routing tab shows where the cosine distribution actually sits.",
Component: "slider",
@@ -583,7 +595,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 238,
},
"router.embedding_cache.confidence_threshold": {
Section: "other",
Section: "router",
Label: "L2 Cache: Confidence Threshold",
Description: "Minimum top-label probability a classifier decision must have to be inserted into the cache. 0 picks the package default (0.60). Uncertain decisions are skipped so they can't poison future paraphrases.",
Component: "slider",
@@ -593,7 +605,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 239,
},
"router.embedding_cache.store_name": {
Section: "other",
Section: "router",
Label: "L2 Cache: Store Name",
Description: "Optional override for the local-store collection used by this router's cache. Empty defaults to \"router-cache-<router-model-name>\". Two routers sharing a store_name share their cache (rare).",
Component: "input",

View File

@@ -240,7 +240,6 @@ var grandfatheredUnregistered = []string{
"swap_space",
"system_prompt",
"template.edit",
"template.function",
"template.join_chat_messages_by_character",
"template.multimodal",
"template.reply_prefix",

View File

@@ -11,6 +11,7 @@ type FieldMeta struct {
Label string `json:"label"` // human-readable label
Description string `json:"description,omitempty"` // help text
Component string `json:"component"` // "input", "number", "toggle", "select", "slider", etc.
Language string `json:"language,omitempty"` // syntax mode for code-editor fields: "yaml" (default), "gotemplate"
Placeholder string `json:"placeholder,omitempty"`
Default any `json:"default,omitempty"`
Min *float64 `json:"min,omitempty"`
@@ -51,6 +52,7 @@ type FieldMetaOverride struct {
Label string
Description string
Component string
Language string
Placeholder string
Default any
Min *float64
@@ -78,6 +80,10 @@ func DefaultSections() []Section {
{ID: "grpc", Label: "gRPC", Icon: "server", Order: 65},
{ID: "agent", Label: "Agent", Icon: "bot", Order: 70},
{ID: "mcp", Label: "MCP", Icon: "plug", Order: 75},
{ID: "router", Label: "Router", Icon: "git-merge", Order: 78},
{ID: "proxy", Label: "Proxy", Icon: "cloud", Order: 80},
{ID: "mitm", Label: "MITM Proxy", Icon: "shield", Order: 82},
{ID: "pii", Label: "PII", Icon: "shield", Order: 84},
{ID: "other", Label: "Other", Icon: "more-horizontal", Order: 100},
}
}

View File

@@ -385,7 +385,7 @@ type PIIConfig struct {
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// Patterns lets a model upgrade or downgrade individual pattern
// actions (mask | block | route_local) relative to the global
// actions (mask | block | allow) relative to the global
// defaults loaded from --pii-config / DefaultPatterns. Pattern IDs
// not listed inherit the global action. The regex itself stays
// global — only the action is settable per-model.
@@ -1274,14 +1274,20 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
}
if (u & FLAG_CHAT) == FLAG_CHAT {
if c.TemplateConfig.Chat == "" && c.TemplateConfig.ChatMessage == "" && !c.TemplateConfig.UseTokenizerTemplate {
return false
}
if slices.Contains(nonTextGenBackends, c.Backend) {
return false
}
if c.Embeddings != nil && *c.Embeddings {
return false
// A router model is a chat dispatcher: it carries no chat
// template of its own (those live on the candidates it routes
// to) and is invoked through the chat endpoint, so the router
// block stands in for chat capability.
if !c.HasRouter() {
if c.TemplateConfig.Chat == "" && c.TemplateConfig.ChatMessage == "" && !c.TemplateConfig.UseTokenizerTemplate {
return false
}
if slices.Contains(nonTextGenBackends, c.Backend) {
return false
}
if c.Embeddings != nil && *c.Embeddings {
return false
}
}
}
if (u & FLAG_COMPLETION) == FLAG_COMPLETION {

View File

@@ -283,6 +283,18 @@ parameters:
Expect(e.HasUsecases(FLAG_CHAT)).To(BeFalse())
Expect(e.HasUsecases(FLAG_EMBEDDINGS)).To(BeTrue())
// Router models are chat dispatchers: no chat template of their
// own, but invoked through the chat endpoint, so they default to
// chat-capable.
r := ModelConfig{
Name: "r",
Router: RouterConfig{
Candidates: []RouterCandidate{{Model: "downstream", Labels: []string{"general"}}},
},
}
Expect(r.HasUsecases(FLAG_ANY)).To(BeTrue())
Expect(r.HasUsecases(FLAG_CHAT)).To(BeTrue())
f := ModelConfig{
Name: "f",
Backend: "piper",

View File

@@ -50,7 +50,14 @@ var _ = Describe("Runtime capability-based backend selection", func() {
must(os.WriteFile(filepath.Join(cudaDir, "metadata.json"), b, 0o644))
must(os.WriteFile(filepath.Join(cudaDir, "run.sh"), []byte(""), 0o755))
// Default system: alias should point to CPU
// Default system: alias should point to CPU. Force the capability to
// "cpu" so this is hermetic on hosts that actually have a GPU: backend
// preference keys off getSystemCapabilities() (env → real nvidia-smi
// detection), not GPUVendor, so without this a GPU dev box reports
// "nvidia" and the cuda alias wins. The NVIDIA case below overrides it.
must(os.Setenv("LOCALAI_FORCE_META_BACKEND_CAPABILITY", "cpu"))
defer func() { _ = os.Unsetenv("LOCALAI_FORCE_META_BACKEND_CAPABILITY") }()
sysDefault, err := system.GetSystemState(
system.WithBackendPath(tempDir),
)

View File

@@ -158,6 +158,11 @@ var defaultImporters = []Importer{
// RFDetrImporter must run before TransformersImporter — RF-DETR
// checkpoints may carry tokenizer-adjacent artefacts.
&RFDetrImporter{},
// LocateAnythingImporter (NVIDIA LocateAnything open-vocab detection,
// native C++/ggml port) must run before LlamaCPPImporter so its GGUF
// bundles aren't claimed by the generic .gguf importer; kept next to
// RFDetrImporter as both are detection models.
&LocateAnythingImporter{},
// Existing
// DS4Importer must precede LlamaCPPImporter - ds4 weights are GGUFs and
// would otherwise be claimed by the generic .gguf-handling llama-cpp

View File

@@ -0,0 +1,137 @@
package importers
import (
"encoding/json"
"path/filepath"
"strings"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/schema"
"go.yaml.in/yaml/v2"
)
var _ Importer = &LocateAnythingImporter{}
// LocateAnythingImporter routes NVIDIA LocateAnything open-vocabulary
// object-detection / visual-grounding repositories to the
// "locate-anything-cpp" backend (a native C++/ggml port). It must be
// registered BEFORE the generic GGUF matchers (LlamaCPPImporter) so its
// GGUF bundles aren't swallowed by the generic .gguf-handling importer,
// and alongside RFDetrImporter since both are detection models that may
// carry tokenizer-adjacent artefacts.
//
// Detection signals:
// - preferences.backend="locate-anything-cpp" (explicit override);
// - repo name contains "locate-anything" or "locateanything"
// (case-insensitive).
type LocateAnythingImporter struct{}
func (i *LocateAnythingImporter) Name() string { return "locate-anything-cpp" }
func (i *LocateAnythingImporter) Modality() string { return "detection" }
func (i *LocateAnythingImporter) AutoDetects() bool { return true }
func repoLooksLikeLocateAnything(repo string) bool {
lower := strings.ToLower(repo)
return strings.Contains(lower, "locate-anything") ||
strings.Contains(lower, "locateanything") ||
strings.Contains(lower, "locate-anything.cpp") ||
strings.Contains(lower, "locate-anything-cpp")
}
func (i *LocateAnythingImporter) Match(details Details) bool {
preferences, err := details.Preferences.MarshalJSON()
if err != nil {
return false
}
preferencesMap := make(map[string]any)
if len(preferences) > 0 {
if err := json.Unmarshal(preferences, &preferencesMap); err != nil {
return false
}
}
if b, ok := preferencesMap["backend"].(string); ok && b == "locate-anything-cpp" {
return true
}
if details.HuggingFace != nil {
repoName := details.HuggingFace.ModelID
if idx := strings.Index(repoName, "/"); idx >= 0 {
repoName = repoName[idx+1:]
}
if repoLooksLikeLocateAnything(repoName) {
return true
}
}
// Fallback: hfapi recursion bug may leave HuggingFace nil — decide
// from the URI owner/repo.
if _, repo, ok := HFOwnerRepoFromURI(details.URI); ok {
if repoLooksLikeLocateAnything(repo) {
return true
}
}
return false
}
func (i *LocateAnythingImporter) Import(details Details) (gallery.ModelConfig, error) {
preferences, err := details.Preferences.MarshalJSON()
if err != nil {
return gallery.ModelConfig{}, err
}
preferencesMap := make(map[string]any)
if len(preferences) > 0 {
if err := json.Unmarshal(preferences, &preferencesMap); err != nil {
return gallery.ModelConfig{}, err
}
}
name, ok := preferencesMap["name"].(string)
if !ok {
name = filepath.Base(details.URI)
}
description, ok := preferencesMap["description"].(string)
if !ok {
description = "Imported from " + details.URI
}
// Prefer the canonical HF "owner/repo" identifier so the emitted
// YAML mirrors gallery locate-anything entries.
model := details.URI
if details.HuggingFace != nil && details.HuggingFace.ModelID != "" {
model = details.HuggingFace.ModelID
} else if owner, repo, ok := HFOwnerRepoFromURI(details.URI); ok {
model = owner + "/" + repo
}
// Always the native C++/ggml backend; explicit preferences.backend
// overrides the default.
backend := "locate-anything-cpp"
if b, ok := preferencesMap["backend"].(string); ok && b != "" {
backend = b
}
modelConfig := config.ModelConfig{
Name: name,
Description: description,
Backend: backend,
KnownUsecaseStrings: []string{"detection"},
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: model},
},
}
data, err := yaml.Marshal(modelConfig)
if err != nil {
return gallery.ModelConfig{}, err
}
return gallery.ModelConfig{
Name: name,
Description: description,
ConfigFile: string(data),
}, nil
}

View File

@@ -0,0 +1,218 @@
package importers_test
import (
"encoding/json"
"fmt"
"github.com/mudler/LocalAI/core/gallery/importers"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LocateAnythingImporter", func() {
Context("Importer interface metadata", func() {
It("exposes name/modality/autodetect", func() {
imp := &importers.LocateAnythingImporter{}
Expect(imp.Name()).To(Equal("locate-anything-cpp"))
Expect(imp.Modality()).To(Equal("detection"))
Expect(imp.AutoDetects()).To(BeTrue())
})
})
Context("Match", func() {
It("matches when backend preference is locate-anything-cpp", func() {
imp := &importers.LocateAnythingImporter{}
preferences := json.RawMessage(`{"backend": "locate-anything-cpp"}`)
details := importers.Details{
URI: "https://example.com/some-model",
Preferences: preferences,
}
Expect(imp.Match(details)).To(BeTrue())
})
It("matches when the repo name contains 'locate-anything' (case-insensitive)", func() {
imp := &importers.LocateAnythingImporter{}
details := importers.Details{
URI: "https://huggingface.co/mudler/locate-anything-cpp-3b",
HuggingFace: &hfapi.ModelDetails{
ModelID: "mudler/Locate-Anything-CPP-3B",
Author: "mudler",
},
}
Expect(imp.Match(details)).To(BeTrue())
})
It("matches when the repo name contains 'locateanything' (case-insensitive)", func() {
imp := &importers.LocateAnythingImporter{}
details := importers.Details{
URI: "https://huggingface.co/nvidia/LocateAnything-3B",
HuggingFace: &hfapi.ModelDetails{
ModelID: "nvidia/LocateAnything-3B",
Author: "nvidia",
},
}
Expect(imp.Match(details)).To(BeTrue())
})
It("matches via URI fallback when HuggingFace details are missing", func() {
imp := &importers.LocateAnythingImporter{}
details := importers.Details{
URI: "https://huggingface.co/nvidia/LocateAnything-3B",
}
Expect(imp.Match(details)).To(BeTrue())
})
It("does not match unrelated repos without locate-anything signals", func() {
imp := &importers.LocateAnythingImporter{}
details := importers.Details{
URI: "https://huggingface.co/meta-llama/Llama-3-8B",
HuggingFace: &hfapi.ModelDetails{
ModelID: "meta-llama/Llama-3-8B",
Author: "meta-llama",
},
}
Expect(imp.Match(details)).To(BeFalse())
})
It("does not match an rfdetr repo", func() {
imp := &importers.LocateAnythingImporter{}
details := importers.Details{
URI: "https://huggingface.co/mudler/rfdetr-cpp-nano",
HuggingFace: &hfapi.ModelDetails{
ModelID: "mudler/rfdetr-cpp-nano",
Author: "mudler",
},
}
Expect(imp.Match(details)).To(BeFalse())
})
It("returns false for invalid preferences JSON", func() {
imp := &importers.LocateAnythingImporter{}
preferences := json.RawMessage(`not valid json`)
details := importers.Details{
URI: "https://example.com/model",
Preferences: preferences,
}
Expect(imp.Match(details)).To(BeFalse())
})
})
Context("Import", func() {
It("produces a YAML with backend locate-anything-cpp and the repo as the model", func() {
imp := &importers.LocateAnythingImporter{}
details := importers.Details{
URI: "https://huggingface.co/nvidia/LocateAnything-3B",
HuggingFace: &hfapi.ModelDetails{
ModelID: "nvidia/LocateAnything-3B",
Author: "nvidia",
},
}
modelConfig, err := imp.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: locate-anything-cpp"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(modelConfig.ConfigFile).To(ContainSubstring("nvidia/LocateAnything-3B"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(modelConfig.ConfigFile).To(ContainSubstring("detection"), fmt.Sprintf("Model config: %+v", modelConfig))
})
It("respects custom name and description from preferences", func() {
imp := &importers.LocateAnythingImporter{}
preferences := json.RawMessage(`{"name": "my-locate", "description": "Custom"}`)
details := importers.Details{
URI: "https://huggingface.co/nvidia/LocateAnything-3B",
Preferences: preferences,
HuggingFace: &hfapi.ModelDetails{
ModelID: "nvidia/LocateAnything-3B",
Author: "nvidia",
},
}
modelConfig, err := imp.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.Name).To(Equal("my-locate"))
Expect(modelConfig.Description).To(Equal("Custom"))
})
})
// Table-driven coverage of the backend routing: locate-anything repos
// always route to the native locate-anything-cpp backend, with an
// explicit preferences.backend override honoured.
//
// Cases are kept offline-deterministic by injecting Details directly
// rather than going through DiscoverModelConfig (which would hit live HF).
Context("backend routing (offline)", func() {
hfFile := func(path string) hfapi.ModelFile {
return hfapi.ModelFile{Path: path}
}
type tc struct {
name string
uri string
modelID string
files []hfapi.ModelFile
prefs string
expectBackend string // expected `backend:` line content
}
entries := []tc{
{
name: "canonical NVIDIA repo routes to locate-anything-cpp",
uri: "https://huggingface.co/nvidia/LocateAnything-3B",
modelID: "nvidia/LocateAnything-3B",
files: []hfapi.ModelFile{hfFile("locate-anything-3b-q8_0.gguf"), hfFile("README.md")},
prefs: "",
expectBackend: "backend: locate-anything-cpp",
},
{
name: "GGUF bundle with locate-anything name routes to locate-anything-cpp",
uri: "https://huggingface.co/mudler/locate-anything.cpp-3b",
modelID: "mudler/locate-anything.cpp-3b",
files: []hfapi.ModelFile{hfFile("model-f16.gguf")},
prefs: "",
expectBackend: "backend: locate-anything-cpp",
},
{
name: "explicit preferences.backend override is honoured",
uri: "https://huggingface.co/nvidia/LocateAnything-3B",
modelID: "nvidia/LocateAnything-3B",
files: nil,
prefs: `{"backend": "locate-anything-cpp"}`,
expectBackend: "backend: locate-anything-cpp",
},
}
for _, e := range entries {
e := e // capture for closure
It(e.name, func() {
imp := &importers.LocateAnythingImporter{}
details := importers.Details{
URI: e.uri,
HuggingFace: &hfapi.ModelDetails{
ModelID: e.modelID,
Files: e.files,
},
}
if e.prefs != "" {
details.Preferences = json.RawMessage(e.prefs)
}
Expect(imp.Match(details)).To(BeTrue(), fmt.Sprintf("Match should fire for %+v", details))
modelConfig, err := imp.Import(details)
Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("Import error: %v", err))
Expect(modelConfig.ConfigFile).To(ContainSubstring(e.expectBackend),
fmt.Sprintf("Model config: %+v", modelConfig))
})
}
})
})

View File

@@ -64,7 +64,17 @@ func (i *MLXImporter) Import(details Details) (gallery.ModelConfig, error) {
description = "Imported from " + details.URI
}
// Vision-language checkpoints (e.g. gemma-4 E4B) declare the
// "image-text-to-text" pipeline tag on HuggingFace. The text-only mlx-lm
// tokenizer does not carry their processor chat template, so routing them
// through the plain mlx backend yields degenerate looping output
// (issue #10269). Send them to the mlx-vlm backend, which applies the
// processor-aware chat template.
backend := "mlx"
if details.HuggingFace != nil && details.HuggingFace.PipelineTag == "image-text-to-text" {
backend = "mlx-vlm"
}
// An explicit backend preference always wins.
b, ok := preferencesMap["backend"].(string)
if ok {
backend = b

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"github.com/mudler/LocalAI/core/gallery/importers"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -122,6 +123,60 @@ var _ = Describe("MLXImporter", func() {
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
})
It("should auto-route vision-language models to the mlx-vlm backend", func() {
// gemma-4 E4B and similar VLMs declare pipeline_tag
// "image-text-to-text" on HuggingFace. The text-only mlx-lm
// tokenizer does not carry their processor chat template, so
// routing them through the plain mlx backend produces degenerate
// looping output (issue #10269). They must go to mlx-vlm.
details := importers.Details{
URI: "https://huggingface.co/mlx-community/gemma-4-E4B-it-qat-4bit",
HuggingFace: &hfapi.ModelDetails{
ModelID: "mlx-community/gemma-4-E4B-it-qat-4bit",
PipelineTag: "image-text-to-text",
},
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
})
It("should keep text-only models on the plain mlx backend", func() {
details := importers.Details{
URI: "https://huggingface.co/mlx-community/Llama-3.2-1B-Instruct-4bit",
HuggingFace: &hfapi.ModelDetails{
ModelID: "mlx-community/Llama-3.2-1B-Instruct-4bit",
PipelineTag: "text-generation",
},
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx"))
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("backend: mlx-vlm"))
})
It("should honor an explicit backend preference even for a VLM", func() {
preferences := json.RawMessage(`{"backend": "mlx"}`)
details := importers.Details{
URI: "https://huggingface.co/mlx-community/gemma-4-E4B-it-qat-4bit",
Preferences: preferences,
HuggingFace: &hfapi.ModelDetails{
ModelID: "mlx-community/gemma-4-E4B-it-qat-4bit",
PipelineTag: "image-text-to-text",
},
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx"))
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("backend: mlx-vlm"))
})
It("should handle invalid JSON preferences", func() {
preferences := json.RawMessage(`invalid json`)
details := importers.Details{

View File

@@ -353,7 +353,7 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
overrides = make(map[string]pii.Action, len(raw))
for ovid, action := range raw {
switch pii.Action(action) {
case pii.ActionMask, pii.ActionBlock, pii.ActionRouteLocal:
case pii.ActionMask, pii.ActionBlock, pii.ActionAllow:
overrides[ovid] = pii.Action(action)
}
}

View File

@@ -102,7 +102,7 @@ var instructionDefs = []instructionDef{
Name: "pii-filtering",
Description: "Inspect and tune the regex PII filter applied to chat requests",
Tags: []string{"pii"},
Intro: "GET /api/pii/patterns lists the active pattern set with each one's action (mask, block, route_local). GET /api/pii/events returns recent redaction events filtered by correlation_id / user_id / pattern_id (admin or local-user only). POST /api/pii/test dry-runs the redactor against an admin-supplied string. POST /api/pii/decide is the programmatic decision oracle for external routers: send `{text}`, receive `{findings, suggested_action, redacted_preview}` without LocalAI mutating, recording, or acting on the call — caller composes the action with its own policy. Default patterns: email, phone, SSN, credit card (Luhn), IPv4, common API key prefixes (sk-, pk-, ghp_, github_pat_). PII is per-model: by default it is OFF for non-proxy backends and ON for backends starting with proxy-* (cloud passthroughs). Opt in with `pii: { enabled: true }` in a model's YAML; use `pii: { patterns: [{id, action}] }` to upgrade or downgrade individual actions for that model. Override global default actions via --pii-config pii.yaml; --disable-pii turns the filter off entirely.",
Intro: "GET /api/pii/patterns lists the active pattern set with each one's action (mask, block, allow). GET /api/pii/events returns recent redaction events filtered by correlation_id / user_id / pattern_id (admin or local-user only). POST /api/pii/test dry-runs the redactor against an admin-supplied string. POST /api/pii/decide is the programmatic decision oracle for external routers: send `{text}`, receive `{findings, suggested_action, redacted_preview}` without LocalAI mutating, recording, or acting on the call — caller composes the action with its own policy. Default patterns: email, phone, SSN, credit card (Luhn), IPv4, common API key prefixes (sk-, pk-, ghp_, github_pat_). PII is per-model: by default it is OFF for non-proxy backends and ON for backends starting with proxy-* (cloud passthroughs). Opt in with `pii: { enabled: true }` in a model's YAML; use `pii: { patterns: [{id, action}] }` to upgrade or downgrade individual actions for that model. Override global default actions via --pii-config pii.yaml; --disable-pii turns the filter off entirely.",
},
{
Name: "middleware-admin",

View File

@@ -124,6 +124,8 @@ func AutocompleteEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, a
filterFn = config.BuildUsecaseFilterFn(config.FLAG_VAD)
case config.UsecaseTranscript:
filterFn = config.BuildUsecaseFilterFn(config.FLAG_TRANSCRIPT)
case "score": // router classifier usecase (FLAG_SCORE); not in UsecaseInfoMap
filterFn = config.BuildUsecaseFilterFn(config.FLAG_SCORE)
default:
filterFn = config.NoFilterFn
}

View File

@@ -15,9 +15,9 @@ import (
//
// External routers (e.g. the localai-org/platform router) call this
// before dispatching to learn whether to mask the prompt in place,
// route to a local-only backend, block the request, or pass it
// through. LocalAI's in-band PII middleware is the alternative path
// for direct-to-LocalAI clients — same Redactor, different framing.
// block the request, or pass it through. LocalAI's in-band PII
// middleware is the alternative path for direct-to-LocalAI clients —
// same Redactor, different framing.
//
// Takes the *pii.Redactor directly rather than the whole
// *application.Application so the handler stays unit-testable with a
@@ -62,24 +62,18 @@ func PIIDecideEndpoint(redactor *pii.Redactor) echo.HandlerFunc {
}
}
// actionAllow is the wire-only value for "no findings". The other
// three map to existing pii.Action* constants; allow has no in-band
// counterpart because the in-band middleware simply passes through.
const actionAllow = "allow"
// suggestedAction collapses the Redactor's Result flags onto a single
// wire-format action using the in-band ordering (block > route_local
// > mask > allow). Spans-without-Blocked-or-LocalOnly means every
// match resolved to ActionMask.
// wire-format action using the in-band ordering (block > mask >
// allow). "allow" covers both "nothing matched" and "matched but every
// span resolved to the allow action" — in both cases the caller may
// dispatch unchanged, with the Findings list reporting what was seen.
func suggestedAction(res pii.Result) string {
switch {
case res.Blocked:
return string(pii.ActionBlock)
case res.LocalOnly:
return string(pii.ActionRouteLocal)
case len(res.Spans) > 0:
case res.Masked:
return string(pii.ActionMask)
default:
return actionAllow
return string(pii.ActionAllow)
}
}

View File

@@ -16,8 +16,8 @@ import (
// PIIDecideEndpoint exposes the redactor as a decision oracle. These
// specs pin the validation surface and the suggested_action mapping
// across all four actions (allow/mask/route_local/block). The redactor
// itself is covered in core/services/routing/pii/redactor_test.go.
// across the three actions (allow/mask/block). The redactor itself is
// covered in core/services/routing/pii/redactor_test.go.
var _ = Describe("PIIDecideEndpoint", func() {
var redactor *pii.Redactor
@@ -68,16 +68,17 @@ var _ = Describe("PIIDecideEndpoint", func() {
Expect(len(body.Findings)).To(BeNumerically(">=", 1))
})
It("returns route_local when an override sets that action", func() {
// Promote the email pattern to route_local for this test —
// exercises the route_local branch of suggestedAction without
// needing a custom pattern set.
Expect(redactor.SetAction("email", pii.ActionRouteLocal)).To(Succeed())
It("returns allow when a matched pattern's action is allow", func() {
// Downgrade the email pattern to allow for this test —
// exercises the allow branch of suggestedAction: a match is
// found, but the strongest action is allow so the suggestion
// is "allow" and the text is left intact.
Expect(redactor.SetAction("email", pii.ActionAllow)).To(Succeed())
rec, body := invokePIIDecide(redactor, `{"text":"contact alice@example.com"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(body.SuggestedAction).To(Equal("route_local"))
// route_local leaves the original text intact — caller decides
// whether to forward it to a local-only backend.
Expect(body.SuggestedAction).To(Equal("allow"))
Expect(body.Findings).To(HaveLen(1), "allow still reports the finding")
// allow leaves the original text intact.
Expect(body.RedactedPreview).To(ContainSubstring("alice@example.com"))
})

View File

@@ -221,9 +221,18 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
// Check if agent job retention changed
agentJobChanged := settings.AgentJobRetentionDays != nil
// Restart watchdog if settings changed
// Restart watchdog if settings changed.
//
// The live start/stop decision derives from the post-apply config
// (WatchdogShouldRun) rather than the raw watchdog_enabled request
// field: the React master toggle only ever writes the idle/busy flags,
// so keying off watchdog_enabled left the live watchdog stopped on a
// cold enable until the next restart (#9125). WatchdogShouldRun mirrors
// the gating in startWatchdog, so a cold enable starts it immediately
// and a full disable (both checks off, no LRU / memory reclaimer) stops
// it.
if watchdogChanged {
if settings.WatchdogEnabled != nil && !*settings.WatchdogEnabled {
if !appConfig.WatchdogShouldRun() {
if err := app.StopWatchdog(); err != nil {
xlog.Error("Failed to stop watchdog", "error", err)
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{

View File

@@ -108,4 +108,20 @@ var _ = Describe("Settings endpoints", func() {
_, err := os.Stat(filepath.Join(tmp, "runtime_settings.json"))
Expect(err).ToNot(HaveOccurred())
})
// Residual #9125: enabling the watchdog from a cold (off) state via the
// React master toggle must start the live watchdog immediately, without a
// restart. The toggle posts watchdog_idle_enabled/busy_enabled=true while
// the vestigial watchdog_enabled stays false (it was loaded false). The
// old handler keyed its stop decision off that raw watchdog_enabled=false
// and called StopWatchdog(), so the watchdog never started until restart.
It("starts the live watchdog on a cold enable even when watchdog_enabled=false", func() {
Expect(app.ModelLoader().GetWatchDog()).To(BeNil(), "precondition: watchdog should be off")
rec := post(`{"watchdog_enabled":false,"watchdog_idle_enabled":true,"watchdog_busy_enabled":true,"watchdog_idle_timeout":"15m","watchdog_busy_timeout":"5m","watchdog_interval":"1s"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(app.ModelLoader().GetWatchDog()).ToNot(BeNil(),
"watchdog should be running after a cold enable, without waiting for a restart")
})
})

View File

@@ -130,7 +130,7 @@ func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eva
overrides = make(map[string]pii.Action, len(raw))
for ovid, action := range raw {
switch pii.Action(action) {
case pii.ActionMask, pii.ActionBlock, pii.ActionRouteLocal:
case pii.ActionMask, pii.ActionBlock, pii.ActionAllow:
overrides[ovid] = pii.Action(action)
}
}

View File

@@ -451,13 +451,14 @@ func buildRealtimeRoutingContext(a *application.Application, sessionID string) *
return nil
}
deps := &middleware.ClassifierDeps{
Scorer: a.Scorer,
Embedder: a.Embedder,
VectorStore: a.VectorStore,
Reranker: a.Reranker,
ModelLookup: a.ModelConfigLookup(),
Registry: a.RouterClassifierRegistry(),
Evaluator: a.TemplatesEvaluator(),
Scorer: a.Scorer,
TokenCounter: a.TokenCounter,
Embedder: a.Embedder,
VectorStore: a.VectorStore,
Reranker: a.Reranker,
ModelLookup: a.ModelConfigLookup(),
Registry: a.RouterClassifierRegistry(),
Evaluator: a.TemplatesEvaluator(),
}
userID := ""
if u := a.FallbackUser(); u != nil {

View File

@@ -0,0 +1,139 @@
package middleware
import (
"strings"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("routerConfigFingerprint", func() {
rc := config.RouterConfig{Classifier: "score", ClassifierModel: "arch-router"}
ctx4096 := 4096
ctx8192 := 8192
// Regression: the score classifier bakes context_size into its token
// budget at build time, and the built classifier is cached by this
// fingerprint. If context_size weren't hashed, editing it and reloading
// would return a classifier carrying the stale budget.
It("changes when the classifier model's context_size changes", func() {
cfgA := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
cfgB := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx8192}}
Expect(routerConfigFingerprint(rc, cfgA)).NotTo(Equal(routerConfigFingerprint(rc, cfgB)))
})
It("is stable for identical classifier configs", func() {
cfgA := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
cfgB := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
Expect(routerConfigFingerprint(rc, cfgA)).To(Equal(routerConfigFingerprint(rc, cfgB)))
})
})
var _ = Describe("routing probe extraction and trimming", func() {
Describe("OpenAIProbeFromRequest", func() {
It("keeps a short conversation intact, newline-terminated per message", func() {
req := &schema.OpenAIRequest{Messages: []schema.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "second"},
{Role: "user", Content: "third"},
}}
Expect(OpenAIProbeFromRequest(req).Prompt).To(Equal("first\nsecond\nthird\n"))
})
It("flattens text blocks and skips image-only messages", func() {
req := &schema.OpenAIRequest{Messages: []schema.Message{
{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "describe this"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:..."}},
}},
{Role: "user", Content: []any{
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:..."}},
}},
}}
// Second message contributes no text, so it neither adds a blank
// line nor a stray newline.
Expect(OpenAIProbeFromRequest(req).Prompt).To(Equal("describe this\n"))
})
It("carries the full conversation untrimmed — trimming is each classifier's job", func() {
// The middleware no longer caps the probe by a fixed rune budget;
// every turn reaches the Probe and each classifier trims to its own
// model's context (see modelTokenTrim / promptTrimmer).
block := strings.Repeat("x", 999)
msgs := make([]schema.Message, 0, 20)
msgs = append(msgs, schema.Message{Role: "user", Content: "OLDEST" + strings.Repeat("o", 994)})
for range 18 {
msgs = append(msgs, schema.Message{Role: "user", Content: block})
}
msgs = append(msgs, schema.Message{Role: "user", Content: "NEWEST" + strings.Repeat("n", 994)})
probe := OpenAIProbeFromRequest(&schema.OpenAIRequest{Messages: msgs})
Expect(probe.Prompt).To(ContainSubstring("OLDEST"), "no turn is dropped at probe-build time")
Expect(probe.Prompt).To(ContainSubstring("NEWEST"))
// Messages preserves the per-turn split the classifier trims from.
Expect(probe.Messages).To(HaveLen(20))
Expect(probe.Messages[0]).To(ContainSubstring("OLDEST"))
Expect(probe.Messages[19]).To(ContainSubstring("NEWEST"))
})
})
Describe("AnthropicProbe", func() {
It("extracts and trims the same way as the OpenAI path", func() {
req := &schema.AnthropicRequest{Messages: []schema.AnthropicMessage{
{Role: "user", Content: "alpha"},
{Role: "assistant", Content: []any{
map[string]any{"type": "text", "text": "beta"},
}},
}}
probe, ok := AnthropicProbe(req)
Expect(ok).To(BeTrue())
Expect(probe.Prompt).To(Equal("alpha\nbeta\n"))
})
It("returns ok=false for a non-Anthropic payload", func() {
_, ok := AnthropicProbe(&schema.OpenAIRequest{})
Expect(ok).To(BeFalse())
})
})
Describe("modelTokenTrim", func() {
tok := func(string) (int, error) { return 1, nil }
depsFor := func(cfg *config.ModelConfig) ClassifierDeps {
return ClassifierDeps{
ModelLookup: func(string) *config.ModelConfig { return cfg },
TokenCounter: func(string) func(string) (int, error) { return tok },
}
}
It("still trims to the backend default when context_size is unset", func() {
// Regression: with the fixed middleware rune cap gone, an unset
// context_size must NOT disable trimming — otherwise a non-trivial
// prompt overflows the default 4096 window and every score fails.
score := config.FLAG_SCORE
cfg := &config.ModelConfig{KnownUsecases: &score} // FLAG_SCORE → batch follows context
count, ceiling := modelTokenTrim("classifier", depsFor(cfg))
Expect(count).NotTo(BeNil())
Expect(ceiling).To(Equal(4096), "unset context_size falls back to the backend default, not 0")
})
It("is bounded by the batch when the batch is smaller than the context", func() {
// The probe is one decode (n_tokens <= n_batch). A model with a
// large context but a small batch can only process the batch — the
// ceiling must follow it, not the context.
ctx8k := 8192
cfg := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx8k}}
cfg.Batch = 512
_, ceiling := modelTokenTrim("embedder", depsFor(cfg))
Expect(ceiling).To(Equal(512), "batch is the binding single-decode limit")
})
It("disables trimming only when no tokenizer is available", func() {
count, ceiling := modelTokenTrim("x", ClassifierDeps{ModelLookup: func(string) *config.ModelConfig { return &config.ModelConfig{} }})
Expect(count).To(BeNil())
Expect(ceiling).To(Equal(0))
})
})
})

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"hash/fnv"
"strconv"
"strings"
"time"
@@ -86,6 +87,12 @@ type ClassifierDeps struct {
// templates.Evaluator so any model the operator points at gets
// its own chat template applied.
Evaluator *templates.Evaluator
// TokenCounter binds the classifier model's tokenizer for the score
// classifier's token-trim path. Optional; nil falls back to the
// backend's n_ctx guard. Plain func type so core/application supplies
// it as a method value without importing this package.
TokenCounter func(modelName string) func(text string) (int, error)
}
// ProbeExtractor pulls the prompt content out of a parsed request so
@@ -212,7 +219,6 @@ func recordHTTPDecision(c echo.Context, store router.DecisionStore, result *rout
_ = store.Record(context.Background(), result.ToDecisionRecord(newDecisionID(), correlationID, userID, source))
}
// GetOrBuildClassifier looks up a built Classifier for the named router
// model in the registry and builds it on miss. Exported so the
// /api/router/decide decision-oracle endpoint can share the same
@@ -262,9 +268,10 @@ func routerConfigFingerprint(rc config.RouterConfig, classifierCfg *config.Model
h := fnv.New64a()
h.Write(bytes)
if classifierCfg != nil {
// Narrow projection: only the fields newTemplateRenderer and
// firstStopWord actually read. Hashing the whole ModelConfig
// would invalidate the cache on irrelevant parameter changes.
// Narrow projection: only the fields buildClassifier reads (renderer,
// stop tokens, context_size → MaxContextTokens). Hashing the whole
// ModelConfig would invalidate the cache on irrelevant changes;
// omitting context_size would let a reload leave a stale token budget.
h.Write([]byte{0}) // separator so empty fields don't collide
h.Write([]byte(classifierCfg.TemplateConfig.Chat))
h.Write([]byte{0})
@@ -274,6 +281,10 @@ func routerConfigFingerprint(rc config.RouterConfig, classifierCfg *config.Model
h.Write([]byte(sw))
h.Write([]byte{0})
}
h.Write([]byte{0})
if classifierCfg.ContextSize != nil {
h.Write([]byte(strconv.Itoa(*classifierCfg.ContextSize)))
}
}
return h.Sum64()
}
@@ -319,11 +330,30 @@ func buildClassifier(cfg *config.ModelConfig, deps ClassifierDeps) (router.Class
if deps.ModelLookup != nil {
if classifierCfg := deps.ModelLookup(rc.ClassifierModel); classifierCfg != nil {
if deps.Evaluator != nil {
opts.PromptRenderer = newTemplateRenderer(deps.Evaluator, classifierCfg)
// The router renders the scoring prompt client-side, so the
// classifier model MUST carry a chat template — refusing
// here beats silently falling back to a generic ChatML
// envelope the model may not have been trained on.
renderer := newTemplateRenderer(deps.Evaluator, classifierCfg)
if renderer == nil {
return nil, fmt.Errorf(
"router classifier score: classifier_model %q has no chat template "+
"(set template.chat and template.chat_message in its config). The router "+
"renders the scoring prompt with the classifier model's own template; "+
"without it the prompt format would not match the model",
rc.ClassifierModel)
}
opts.PromptRenderer = renderer
}
if st := pickAssistantTurnEnd(classifierCfg.StopWords, classifierCfg.TemplateConfig.ChatMessage); st != "" {
opts.StopToken = st
}
// Token-exact conversation trim — score classifier drops the
// oldest turns using the model's own tokenizer.
if count, ctxTokens := modelTokenTrim(rc.ClassifierModel, deps); count != nil {
opts.TokenCounter = count
opts.MaxContextTokens = ctxTokens
}
}
}
inner = router.NewScoreClassifier(policies, scorer, opts)
@@ -335,7 +365,11 @@ func buildClassifier(cfg *config.ModelConfig, deps ClassifierDeps) (router.Class
if reranker == nil {
return nil, fmt.Errorf("router classifier colbert: classifier_model %q not loadable", rc.ClassifierModel)
}
inner = router.NewRerankClassifier(policies, reranker, cacheCap, rc.ActivationThreshold)
rerankClassifier := router.NewRerankClassifier(policies, reranker, cacheCap, rc.ActivationThreshold)
if count, ctxTokens := modelTokenTrim(rc.ClassifierModel, deps); count != nil {
rerankClassifier = rerankClassifier.WithTokenTrim(count, ctxTokens)
}
inner = rerankClassifier
default:
return nil, fmt.Errorf("router: unknown classifier %q (supported: %s)", name, strings.Join([]string{router.ClassifierScore, router.ClassifierColbert}, ", "))
}
@@ -523,7 +557,41 @@ func wrapWithEmbeddingCache(cfg *config.ModelConfig, inner router.Classifier, de
if vstore == nil {
return nil, fmt.Errorf("vector store %q not loadable", storeName)
}
return router.NewEmbeddingCacheClassifier(inner, embedder, vstore, ec.SimilarityThreshold, ec.ConfidenceThreshold), nil
cache := router.NewEmbeddingCacheClassifier(inner, embedder, vstore, ec.SimilarityThreshold, ec.ConfidenceThreshold)
// Trim the probe to the embedder model's own context (e.g. nomic-embed at
// 8k) rather than a fixed guess — otherwise the cache key is an embedding
// of a silently-truncated conversation.
if count, ctxTokens := modelTokenTrim(ec.EmbeddingModel, deps); count != nil {
cache = cache.WithTokenTrim(count, ctxTokens)
}
return cache, nil
}
// modelTokenTrim returns a model's own tokenizer and the token ceiling its
// probe must fit, or (nil, 0) when no tokenizer is available (only then can we
// not trim exactly). The ceiling is min(effective context, effective batch):
// score/embed/rerank all decode the whole prompt in one pass, so it must fit
// both the context window and a single batch. Using the backend's *effective*
// values — not the raw config fields — means trimming still works when
// context_size and batch are unset; otherwise a non-trivial prompt overflows
// the default window and every classification fails.
func modelTokenTrim(modelName string, deps ClassifierDeps) (func(string) (int, error), int) {
if deps.TokenCounter == nil || deps.ModelLookup == nil {
return nil, 0
}
cfg := deps.ModelLookup(modelName)
if cfg == nil {
return nil, 0
}
count := deps.TokenCounter(modelName)
if count == nil {
return nil, 0
}
ceiling := backend.EffectiveContextSize(*cfg)
if b := backend.EffectiveBatchSize(*cfg); b < ceiling {
ceiling = b
}
return count, ceiling
}
func newDecisionID() string {
@@ -545,6 +613,41 @@ func OpenAIProbe(parsed any) (router.Probe, bool) {
return OpenAIProbeFromRequest(req), true
}
// messageText flattens a chat message's Content to plain text: string content
// verbatim; []any structured content contributes only its "text" blocks.
func messageText(content any) string {
switch ct := content.(type) {
case string:
return ct
case []any:
var b strings.Builder
for _, block := range ct {
if bm, ok := block.(map[string]any); ok && bm["type"] == "text" {
if t, ok := bm["text"].(string); ok {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(t)
}
}
}
return b.String()
}
return ""
}
// messageProbeParts drops empty (e.g. image-only) messages so they don't
// consume budget or emit blank lines.
func messageProbeParts(texts []string) []string {
parts := make([]string, 0, len(texts))
for _, t := range texts {
if t != "" {
parts = append(parts, t)
}
}
return parts
}
// OpenAIProbeFromRequest is the typed counterpart of OpenAIProbe — same
// extraction logic, but takes the request struct directly. Realtime and
// other non-HTTP callers use it to feed a probe to router.Resolve
@@ -553,24 +656,15 @@ func OpenAIProbeFromRequest(req *schema.OpenAIRequest) router.Probe {
if req == nil {
return router.Probe{}
}
var b strings.Builder
texts := make([]string, len(req.Messages))
for i := range req.Messages {
switch ct := req.Messages[i].Content.(type) {
case string:
b.WriteString(ct)
b.WriteByte('\n')
case []any:
for _, block := range ct {
if bm, ok := block.(map[string]any); ok && bm["type"] == "text" {
if t, ok := bm["text"].(string); ok {
b.WriteString(t)
b.WriteByte('\n')
}
}
}
}
texts[i] = messageText(req.Messages[i].Content)
}
return router.Probe{Prompt: b.String()}
parts := messageProbeParts(texts)
// Prompt carries the full conversation; each classifier trims it to its own
// model's context (see modelTokenTrim). Messages preserves the per-turn
// split the trimmer drops oldest-first.
return router.Probe{Prompt: router.JoinTurns(parts), Messages: parts}
}
// AnthropicProbe is the AnthropicRequest analogue of OpenAIProbe.
@@ -579,25 +673,10 @@ func AnthropicProbe(parsed any) (router.Probe, bool) {
if !ok || req == nil {
return router.Probe{}, false
}
var b strings.Builder
texts := make([]string, len(req.Messages))
for i := range req.Messages {
switch ct := req.Messages[i].Content.(type) {
case string:
b.WriteString(ct)
b.WriteByte('\n')
case []any:
for _, block := range ct {
if bm, ok := block.(map[string]any); ok && bm["type"] == "text" {
if t, ok := bm["text"].(string); ok {
b.WriteString(t)
b.WriteByte('\n')
}
}
}
}
texts[i] = messageText(req.Messages[i].Content)
}
return router.Probe{
Prompt: b.String(),
}, true
parts := messageProbeParts(texts)
return router.Probe{Prompt: router.JoinTurns(parts), Messages: parts}, true
}

View File

@@ -246,11 +246,12 @@ var _ = Describe("RouteModel rendered classifier prompt", func() {
"rendered prompt must end at assistant-open marker. got: %q", s.lastPrompt)
})
It("falls back to chatMLRenderer when the classifier model has no chat_message template", func() {
// Partial template config: only outer Chat, no per-role
// piece. The renderer must refuse rather than emit a prompt
// that drops the system turn, so the score classifier's
// built-in ChatML default takes over.
It("refuses to build the router when the classifier model has no chat_message template", func() {
// Partial template config: only the outer Chat, no per-role piece.
// The router renders the scoring prompt client-side from the
// classifier model's own template, so a missing template is a hard
// error rather than a silent fall back to a generic ChatML envelope
// the model may not have been trained on.
writePartialClassifierModel(modelDir, "arch-router")
routerCfg := newScoreRouterModel(modelDir, "smart-router")
@@ -266,19 +267,9 @@ var _ = Describe("RouteModel rendered classifier prompt", func() {
ModelLookup: loaderLookup(loader, appConfig),
Evaluator: eval,
})
Expect(err).NotTo(HaveOccurred())
// chatMLRenderer fallback emits its own envelope and still
// embeds the routing system prompt. OpenAIProbeFromRequest
// appends "\n" after each message body, so the user content
// reaches the renderer as "hello world\n" — the substring
// match accounts for that.
Expect(s.lastPrompt).To(ContainSubstring("<routes>"),
"fallback renderer also dropped the system prompt")
Expect(s.lastPrompt).To(ContainSubstring("<|im_start|>system\n"))
Expect(s.lastPrompt).To(ContainSubstring("<|im_start|>user\nhello world\n<|im_end|>"))
Expect(strings.HasSuffix(s.lastPrompt, "<|im_start|>assistant\n")).To(BeTrue(),
"chatMLRenderer fallback must end at assistant-open marker. got: %q", s.lastPrompt)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no chat template"),
"missing classifier template must surface as a clear config error. got: %v", err)
})
It("uses the classifier model's first stopword as the candidate suffix", func() {
@@ -533,8 +524,8 @@ template:
// writePartialClassifierModel writes a classifier model that has the
// outer Chat template but no ChatMessage — exercises the
// newTemplateRenderer "refuse partial templating" branch that hands
// off to chatMLRenderer.
// newTemplateRenderer "refuse partial templating" branch, which makes
// buildClassifier reject the router with a missing-template error.
func writePartialClassifierModel(modelDir, name string) {
body := `name: ` + name + `
backend: llama-cpp

View File

@@ -224,4 +224,38 @@ test.describe('Model Editor - Interactive Tab', () => {
expect(estimateCalled).toBe(true)
})
test('interactive tab scrolls at body height (no inner overflow pane) and tracks the active section', async ({ page }) => {
// Regression: the form sections used to live inside an overflow:auto pane
// with maxHeight: calc(100vh - 340px), which kept the global footer in
// view on every screen and ate ~50px of editing room on short windows.
// Pin two pieces of the fix:
// 1. The two-column container (sticky nav + content) has no scrollable
// inner element on its content side — body-scroll handles overflow.
// 2. The active-section tracker now listens to window scroll. Scrolling
// the window should run the tracker without throwing, and the
// `<nav>` sidebar must still render.
const contentOverflowY = await page.evaluate(() => {
const sidebar = document.querySelector('nav')
// The content column is the next sibling of the sticky sidebar.
const content = sidebar?.nextElementSibling
return content ? getComputedStyle(content).overflowY : 'no-content'
})
expect(['visible', 'normal', 'auto', 'scroll', 'no-content']).toContain(contentOverflowY)
expect(contentOverflowY).not.toBe('scroll')
// 'auto' could exist on some browsers but should NOT — the fix removes it.
// We assert the strong invariant separately.
expect(['auto']).not.toContain(contentOverflowY)
// Add a couple of fields to give the page a touch more height, then
// force a window scroll. The tracker should run; the sidebar should
// remain visible.
const searchInput = page.locator('input[placeholder="Search fields to add..."]')
await searchInput.fill('Temperature')
const dropdown = searchInput.locator('..').locator('..')
await dropdown.locator('div', { hasText: 'Temperature' }).first().click()
await page.evaluate(() => window.scrollTo(0, 200))
await page.waitForTimeout(50)
await expect(page.locator('nav').first()).toBeVisible()
})
})

View File

@@ -0,0 +1,94 @@
import { test, expect } from './coverage-fixtures.js'
// Exercises the "Back to <page>" navigation convention: whichever page links
// into the Model Editor stamps its origin as react-router location state, and
// the editor's Back button returns there (captioned with the origin) instead
// of a hardcoded route. Also covers the Middleware page's ?tab= persistence,
// which is what lets the editor return you to the exact tab you came from.
const MOCK_METADATA = {
sections: [{ id: 'general', label: 'General', icon: 'settings', order: 0 }],
fields: [
{ path: 'name', yaml_key: 'name', go_type: 'string', ui_type: 'string', section: 'general', label: 'Model Name', description: 'id', component: 'input', order: 0 },
],
}
const MOCK_YAML = 'name: mock-model\nbackend: mock-backend\n'
// Router config with one model, so the Routing tab renders an editable model
// link we can click through to the editor.
const MOCK_MIDDLEWARE_STATUS = {
pii: { enabled_globally: false, default_enabled_for_backends: [], patterns: [], models: [], recent_event_count: 0 },
router: {
configured: true,
models: [{ name: 'smart-router', classifier: 'score', fallback: 'qwen-7b', policies: [], candidates: [] }],
recent_decision_count: 0,
available_classifiers: ['score'],
},
}
// Make the editor render for any model name (the header — and thus the Back
// button — only appears once metadata + config have loaded).
async function mockEditorEndpoints(page) {
await page.route('**/api/models/config-metadata*', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK_METADATA) }))
await page.route('**/api/models/edit/**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ config: MOCK_YAML, name: 'mock-model' }) }))
await page.route('**/api/models/config-json/**', (route) =>
route.fulfill({ contentType: 'application/json', body: '{}' }))
}
test.describe('Model Editor — Back navigation', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/auth/status', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ authEnabled: false, staticApiKeyRequired: false, providers: [] }) }))
await mockEditorEndpoints(page)
})
test('Back returns to Manage with a "Back to Manage" caption', async ({ page }) => {
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
// Open the first row's action menu and pick "Edit configuration".
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.click()
await page.getByRole('menuitem', { name: 'Edit configuration' }).click()
await expect(page).toHaveURL(/\/app\/model-editor\//)
const back = page.getByRole('button', { name: /Back to Manage/ })
await expect(back).toBeVisible({ timeout: 10_000 })
await back.click()
await expect(page).toHaveURL(/\/app\/manage/)
})
test('returns to the originating Middleware tab (?tab=routing) it was opened from', async ({ page }) => {
await page.route('**/api/middleware/status', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK_MIDDLEWARE_STATUS) }))
await page.route('**/api/pii/events?**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ events: [] }) }))
await page.route('**/api/router/decisions?**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ decisions: [] }) }))
await page.goto('/app/middleware')
// Switching to Routing must push the tab into the URL.
await page.getByRole('button', { name: /Routing/i }).click()
await expect(page).toHaveURL(/[?&]tab=routing/)
// Click through to the router model's config, then back.
await page.getByRole('link', { name: 'smart-router' }).click()
await expect(page).toHaveURL(/\/app\/model-editor\/smart-router/)
const back = page.getByRole('button', { name: /Back to Middleware/ })
await expect(back).toBeVisible({ timeout: 10_000 })
await back.click()
// Returns to the exact tab, not the default Filtering tab.
await expect(page).toHaveURL(/\/app\/middleware\?tab=routing/)
await expect(page.getByText('smart-router').first()).toBeVisible()
})
test('falls back to "Back to Manage" on a direct visit with no origin state', async ({ page }) => {
await page.goto('/app/model-editor/mock-model')
await expect(page.getByRole('button', { name: /Back to Manage/ })).toBeVisible({ timeout: 10_000 })
})
})

View File

@@ -48,3 +48,77 @@ test.describe('Traces - Error Display', () => {
await expect(page.locator('th', { hasText: 'Type' })).toBeVisible()
})
})
// Pin the BackendTraceDetail expansion path for a vector_store trace —
// the type that surfaces the router's embedding-cache plumbing. The
// row click triggers the detail render, which exercises typeBadgeStyle
// (with the new vector_store badge color), the DataFields component
// (op / outcome / vector_dim / similarity), and the "View backend
// logs" link that resolves to the store namespace. Without this spec
// the new color entry plus the data-field render branches stay
// uncovered, dragging UI line coverage below the regression gate.
test.describe('Traces - vector_store backend trace detail', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/traces', (route) => {
route.fulfill({ contentType: 'application/json', body: '[]' })
})
await page.route('**/api/backend-traces', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
{
type: 'vector_store',
timestamp: '2026-05-28T13:56:25.558Z',
model_name: 'router-cache-smart-router',
backend: 'local-store',
summary: 'search hit (sim=0.989)',
duration: 160_000_000,
error: '',
data: {
op: 'search',
outcome: 'hit',
vector_dim: 768,
similarity: 0.9899752140045166,
},
},
{
type: 'vector_store',
timestamp: '2026-05-28T13:49:07.545Z',
model_name: 'router-cache-smart-router',
backend: 'local-store',
summary: 'search miss',
duration: 100_000_000,
error: '',
data: {
op: 'search',
outcome: 'miss',
vector_dim: 768,
},
},
]),
})
})
await page.goto('/app/traces')
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
await page.locator('button', { hasText: 'Backend Traces' }).click()
})
test('renders type badge and expands data fields on row click', async ({ page }) => {
// The vector_store badge appears in the type column.
await expect(page.locator('td span', { hasText: 'vector_store' }).first()).toBeVisible()
// Clicking the first row expands BackendTraceDetail, which renders
// the four data fields. Use the first row's "search hit" summary
// as the anchor to disambiguate from the miss row below.
await page.locator('tr', { hasText: 'search hit' }).first().click()
// DataFields renders op/outcome/vector_dim/similarity as label/value pairs.
// 'hit' appears as the rendered outcome value.
await expect(page.locator('text=outcome').first()).toBeVisible()
await expect(page.locator('text=hit').first()).toBeVisible()
// The model_name → /app/backend-logs link is the BackendTraceDetail
// affordance for jumping to logs for the store namespace.
await expect(page.locator('a', { hasText: 'View backend logs' })).toBeVisible()
})
})

View File

@@ -1,5 +1,5 @@
export default {
locales: ['en', 'it', 'es', 'de', 'zh-CN'],
locales: ['en', 'it', 'es', 'de', 'zh-CN', 'id'],
defaultNamespace: 'common',
output: 'public/locales/$LOCALE/$NAMESPACE.json',
input: ['src/**/*.{js,jsx}'],

View File

@@ -4,6 +4,12 @@ export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
// TEMPORARY: cap parallelism. Playwright's default (cores/2) oversubscribes
// high-core dev machines and intermittently starves the page-teardown
// coverage harvest past the 30s test timeout (flaky "Tearing down page"
// failures, different specs each run). Capped at 8 pending a proper
// root-cause fix; override with PW_WORKERS.
workers: process.env.PW_WORKERS ? Number(process.env.PW_WORKERS) : 8,
reporter: process.env.CI ? 'html' : 'list',
use: {
baseURL: 'http://127.0.0.1:8089',

View File

@@ -0,0 +1,85 @@
{
"manage": {
"title": "Sistem",
"subtitle": "Kelola model dan backend yang terinstal"
},
"settings": {
"title": "Pengaturan",
"subtitle": "Konfigurasi pengaturan runtime LocalAI",
"saved": "Pengaturan berhasil disimpan",
"saveFailed": "Gagal menyimpan: {{message}}",
"loadFailed": "Gagal memuat pengaturan: {{message}}",
"sections": {
"branding": "Branding",
"watchdog": "Watchdog",
"memory": "Memori",
"backends": "Backend",
"performance": "Performa",
"tracing": "Tracing",
"api": "API & CORS",
"p2p": "P2P",
"galleries": "Galeri",
"apikeys": "API Key",
"agents": "Agent Job",
"agentpool": "Agent Pool",
"assistant": "Asisten LocalAI",
"responses": "Respons"
}
},
"backends": {
"title": "Manajemen Backend",
"subtitle": "Temukan dan instal backend AI untuk mendukung model Anda"
},
"backendLogs": {
"title": "Log Backend",
"subtitle": "Lihat log dari backend yang sedang berjalan",
"empty": "Tidak ada log yang tersedia"
},
"traces": {
"title": "Trace",
"subtitle": "Lihat log permintaan API, respons, dan operasi backend"
},
"nodes": {
"title": "Node Terdistribusi",
"subtitle": "Kelola node backend dan node worker"
},
"p2p": {
"title": "Komputasi AI Terdistribusi",
"subtitle": "Skalakan beban kerja AI Anda ke beberapa perangkat dengan distribusi peer-to-peer"
},
"users": {
"title": "Pengguna",
"subtitle": "Kelola pengguna terdaftar, peran, dan undangan"
},
"usage": {
"title": "Penggunaan",
"subtitle": "Statistik penggunaan token API",
"sources": {
"tab": "Sumber",
"mixTitle": "Campuran sumber",
"ribbonAria": "{{apikey}}% API Key, {{web}}% Web UI, {{legacy}}% Legasi",
"topSources": "Sumber teratas dari waktu ke waktu",
"searchPlaceholder": "Cari berdasarkan nama atau awalan",
"sortBy": "Urutkan",
"sortTokens": "Token",
"sortRequests": "Permintaan",
"sortLastUsed": "Terakhir digunakan",
"sortName": "Nama",
"sortUser": "Pengguna",
"webUI": "Web UI",
"legacy": "Legasi",
"revoked": " dicabut",
"filteredTo": "Difilter ke: {{name}}",
"clearFilter": "Hapus filter",
"other": "Lainnya ({{count}})",
"noTrafficShort": "Tidak ada permintaan dalam periode ini.",
"noKeysYet": "Setelah permintaan masuk, Anda akan melihat rinciannya di sini.",
"createKey": "Buat API Key pertama Anda",
"truncatedWarning": "Menampilkan 200 key teratas. Terapkan filter untuk mempersempit pencarian."
}
},
"explorer": {
"title": "Penjelajah",
"subtitle": "Jelajahi file dan konfigurasi"
}
}

View File

@@ -0,0 +1,55 @@
{
"title": "Agen",
"subtitle": "Kelola agen AI otonom",
"actions": {
"agentHub": "Pusat Agen",
"import": "Impor",
"createAgent": "Buat Agen",
"edit": "Edit",
"chat": "Obrolan",
"export": "Ekspor",
"delete": "Hapus",
"pause": "Jeda",
"resume": "Lanjutkan"
},
"table": {
"name": "Nama",
"status": "Status",
"events": "Event",
"actions": "Aksi",
"eventsTooltip": "{{count}} event - Klik untuk melihat"
},
"search": {
"placeholder": "Cari agen...",
"summary_one": "{{shown}} dari {{total}} agen",
"summary_other": "{{shown}} dari {{total}} agen"
},
"empty": {
"noConfigured": "Belum ada agen yang dikonfigurasi",
"noConfiguredText": "Buat agen untuk memulai alur kerja AI otonom.",
"browseHub": "Tidak tahu harus mulai dari mana? Jelajahi <1>Pusat Agen</1> untuk menemukan konfigurasi agen siap pakai yang bisa Anda impor.",
"noMatching": "Tidak ada agen yang cocok",
"noMatchingText": "Tidak ada agen yang cocok dengan \"{{query}}\""
},
"sections": {
"yourAgents": "Agent Anda",
"otherUsersAgents": "Agent Pengguna Lain"
},
"deleteDialog": {
"title": "Hapus Agen",
"message": "Hapus agen \"{{name}}\"? Tindakan ini tidak dapat dibatalkan.",
"confirm": "Hapus"
},
"toasts": {
"loadFailed": "Gagal memuat agen: {{message}}",
"deleted": "Agen \"{{name}}\" berhasil dihapus",
"deleteFailed": "Gagal menghapus agen: {{message}}",
"paused": "Agen \"{{name}}\" dijeda",
"resumed": "Agen \"{{name}}\" dilanjutkan",
"pauseFailed": "Gagal menjeda agen: {{message}}",
"resumeFailed": "Gagal melanjutkan agen: {{message}}",
"exported": "Agen \"{{name}}\" berhasil diekspor",
"exportFailed": "Gagal mengekspor agen: {{message}}",
"parseFailed": "Gagal melakukan parse file agen: {{message}}"
}
}

View File

@@ -0,0 +1,112 @@
{
"login": {
"subtitle": "Masuk untuk melanjutkan",
"registerSubtitle": "Buat akun",
"createAdminSubtitle": "Buat akun admin Anda",
"tokenSubtitle": "Masukkan API key Anda untuk melanjutkan",
"email": "Email",
"emailPlaceholder": "anda@example.com",
"name": "Nama",
"namePlaceholder": "Nama Anda (opsional)",
"password": "Kata Sandi",
"passwordPlaceholder": "Masukkan kata sandi...",
"newPasswordPlaceholder": "Minimal 12 karakter",
"confirmPassword": "Konfirmasi Kata Sandi",
"confirmPasswordPlaceholder": "Ulangi kata sandi",
"inviteCodeLabel": "Kode Undangan",
"inviteCodeOptional": " (opsional — lewati waktu tunggu persetujuan)",
"inviteCodePlaceholder": "Tempel kode undangan Anda...",
"tokenPlaceholder": "Masukkan API key...",
"tokenAltPlaceholder": "Masukkan token API...",
"signIn": "Masuk",
"signingIn": "Sedang masuk...",
"register": "Daftar",
"creatingAccount": "Membuat akun...",
"createAdminAccount": "Buat Akun Admin",
"signInWithGitHub": "Masuk dengan GitHub",
"signInWithSSO": "Masuk dengan SSO",
"loginWithToken": "Masuk dengan Token",
"showTokenLogin": "Masuk dengan Token API",
"hideTokenLogin": "Sembunyikan Token API",
"noAccount": "Belum punya akun?",
"hasAccount": "Sudah punya akun?",
"or": "atau",
"errors": {
"loginFailed": "Gagal masuk",
"registrationFailed": "Gagal mendaftar",
"invalidToken": "Token tidak valid",
"passwordsDoNotMatch": "Kata sandi tidak cocok",
"enterToken": "Silahkan masukkan token",
"networkError": "Eror jaringan",
"inviteRequired": "Kode undangan yang valid diperlukan untuk mendaftar"
},
"messages": {
"registrationPending": "Pendaftaran berhasil, menunggu persetujuan."
}
},
"account": {
"title": "Akun",
"subtitle": "Profil, kredensial, dan API key",
"unavailable": "Akun tidak tersedia",
"unavailableText": "Autentikasi harus diaktifkan untuk mengelola akun Anda.",
"tabs": {
"profile": "Profil",
"security": "Keamanan",
"apiKeys": "API Key"
},
"profile": {
"displayName": "Nama tampilan",
"displayNameDescription": "Nama tampilan publik Anda",
"avatarUrl": "URL Avatar",
"avatarUrlDescription": "URL ke gambar profil Anda",
"avatarUrlPlaceholder": "https://example.com/avatar.png",
"save": "Simpan",
"saving": "Menyimpan...",
"updated": "Profil berhasil diperbarui",
"updateFailed": "Gagal memperbarui profil: {{message}}"
},
"security": {
"currentPassword": "Kata sandi saat ini",
"currentPasswordDescription": "Masukkan kata sandi Anda saat ini untuk memverifikasi identitas Anda",
"currentPasswordPlaceholder": "Kata sandi saat ini",
"newPassword": "Kata sandi baru",
"newPasswordDescription": "Minimal harus 12 karakter",
"newPasswordPlaceholder": "Kata sandi baru",
"confirmPassword": "Konfirmasi kata sandi",
"confirmPasswordDescription": "Masukkan kembali kata sandi baru Anda",
"confirmPasswordPlaceholder": "Konfirmasi kata sandi baru",
"changePassword": "Ubah kata sandi",
"changing": "Mengubah...",
"changed": "Kata sandi berhasil diubah",
"passwordsDoNotMatch": "Kata sandi tidak cocok",
"tooShort": "Kata sandi baru minimal harus 12 karakter",
"oauthOnly": "Manajemen kata sandi tidak tersedia untuk akun {{provider}}."
},
"apiKeys": {
"create": "Buat API key",
"createDescription": "Buat key untuk akses terprogram",
"namePlaceholder": "Nama key (misal: my-app)",
"createButton": "Buat",
"creating": "Membuat...",
"createdToast": "API key berhasil dibuat",
"createFailed": "Gagal membuat API key: {{message}}",
"loadFailed": "Failed to load API keys: {{message}}",
"revoke": "Cabut",
"revokeKey": "Cabut key",
"revokeTitle": "Cabut API Key",
"revokeMessage": "Cabut API key \"{{name}}\"? Tindakan ini tidak dapat dibatalkan.",
"revoked": "API key dicabut",
"revokeFailed": "Gagal mencabut API key: {{message}}",
"copyNow": "Salin sekarang — key ini tidak akan ditampilkan lagi",
"copiedToast": "Berhasil disalin ke papan klip",
"copyFailed": "Gagal menyalin",
"empty": "Belum ada API key. Buat satu di atas untuk akses terprogram.",
"lastUsed": "terakhir digunakan {{date}}"
}
},
"notFound": {
"title": "Halaman Tidak Ditemukan",
"text": "Sepertinya halaman yang Anda cari tidak ditemukan. Mari kembalikan ke halaman sebelumnya.",
"goHome": "Kembali ke Beranda"
}
}

View File

@@ -0,0 +1,117 @@
{
"activity": {
"thought": "Penalaran",
"tool": "Alat",
"result": "Hasil",
"toolResult": "Hasil {{name}}",
"thinking": "Berpikir..."
},
"header": {
"manageModeTooltip": "Obrolan ini dapat menginstal model, mengedit konfigurasi, dan mengelola backend dengan berbicara melalui LocalAI.",
"modelInfo": "Info model",
"chatSettings": "Pengaturan Obrolan",
"modelInfoTitle": "Info model: {{model}}",
"editConfig": "Edit konfigurasi",
"close": "Tutup"
},
"modelInfo": {
"backend": "Backend",
"modelFile": "File model",
"contextSize": "Ukuran konteks",
"threads": "Thread",
"mcp": "MCP",
"configured": "Dikonfigurasi",
"chatTemplate": "Templat Obrolan",
"yes": "Ya",
"gpuLayers": "Layer GPU"
},
"context": {
"label": "Konteks: {{percent}}%",
"labelWithTokens": "Konteks: {{percent}}% ({{tokens}} tokens)"
},
"settings": {
"title": "Pengaturan Obrolan",
"manageMode": "Mode Manajemen",
"manageModeDesc": "Izinkan obrolan ini menginstal model, mengganti backend, dan mengedit konfigurasi dengan berbicara melalui LocalAI.",
"systemPrompt": "System Prompt",
"systemPromptPlaceholder": "Anda adalah asisten yang membantu...",
"temperature": "Temperatur",
"topP": "Top P",
"topK": "Top K",
"contextSize": "Ukuran Konteks",
"contextSizePlaceholder": "2048",
"clearHistory": "Hapus riwayat obrolan"
},
"empty": {
"manageTitle": "Kelola LocalAI dengan obrolan",
"manageText": "Izinkan untuk menginstal model, mengganti backend, mengedit konfigurasi, atau memeriksa status. Asisten akan merangkum tindakan dan menunggu konfirmasi Anda sebelum mengubah apa pun.",
"startTitle": "Mulai percakapan",
"readyText": "Siap untuk mengobrol dengan {{model}}",
"selectModelText": "Pilih model di atas untuk memulai",
"suggestionsManage": [
"Apa saja yang terinstal?",
"Instal model obrolan",
"Tampilkan status sistem",
"Perbarui backend"
],
"suggestionsChat": [
"Jelaskan cara kerjanya",
"Bantu saya menulis kode",
"Rangkum dokumen",
"Gali ide"
],
"recent": "Terbaru",
"noMessages": "Belum ada pesan",
"hintEnter": "Enter untuk mengirim",
"hintShiftEnter": "Shift+Enter untuk baris baru",
"hintAttach": "Lampirkan file"
},
"errors": {
"viewTraces": "Lihat trace untuk detailnya"
},
"actions": {
"copy": "Salin",
"regenerate": "Hasilkan ulang"
},
"streaming": {
"transferring": "Mentransfer model...",
"transferringTo": "Mentransfer model ke {{node}}..."
},
"tokens": {
"perSec": "{{count}} tok/s",
"peak": "Puncak: {{count}} tok/s",
"usage": "{{prompt}}p + {{completion}}c = {{total}}"
},
"input": {
"placeholder": "Pesan...",
"attachFile": "Lampirkan file",
"stopGenerating": "Hentikan pembuatan",
"canvasTitle": "Canvas — ekstrak blok kode dan media ke panel samping untuk pratinjau, salin, dan unduh",
"canvasLabel": "Canvas",
"openCanvas": "Buka panel canvas"
},
"deleteAllDialog": {
"title": "Hapus Semua Obrolan",
"message": "Hapus semua obrolan? Tindakan ini tidak dapat dibatalkan.",
"confirm": "Hapus semua"
},
"toasts": {
"selectModel": "Silahkan pilih model",
"copied": "Berhasil disalin ke papan klip",
"copyFailed": "Gagal menyalin ke papan klip"
},
"menu": {
"trigger": "Obrolan",
"triggerTitle": "Percakapan (Ctrl/Cmd+K)",
"search": "Cari percakapan...",
"clearSearch": "Hapus pencarian",
"noMatch": "Tidak ada percakapan yang cocok dengan pencarian Anda",
"noConversations": "Belum ada percakapan",
"rename": "Ubah nama",
"exportMarkdown": "Ekspor sebagai Markdown",
"deleteChat": "Hapus obrolan",
"newChat": "Obrolan baru",
"clearAll": "Hapus semua",
"deleteAllTitle": "Hapus semua percakapan"
}
}

View File

@@ -0,0 +1,43 @@
{
"title": "Basis Pengetahuan",
"subtitle": "Kelola koleksi dokumen untuk agen RAG",
"newPlaceholder": "Nama koleksi baru...",
"actions": {
"create": "Buat",
"creating": "Membuat...",
"details": "Detail",
"reset": "Reset",
"delete": "Hapus",
"viewDetails": "Lihat detail",
"resetCollection": "Reset koleksi",
"deleteCollection": "Hapus koleksi"
},
"sections": {
"yourCollections": "Koleksi Anda",
"otherUsersCollections": "Koleksi Pengguna Lain"
},
"empty": {
"title": "Belum ada koleksi",
"text": "Koleksi memungkinkan Anda mengatur dokumen ke dalam basis pengetahuan yang dapat dicari oleh agen menggunakan RAG (Retrieval-Augmented Generation). Buat koleksi di atas untuk memulai.",
"noPersonal": "Anda belum memiliki koleksi."
},
"deleteDialog": {
"title": "Hapus koleksi",
"message": "Hapus koleksi \"{{name}}\"? Tindakan ini akan menghapus semua entri dan tidak dapat dibatalkan.",
"confirm": "Hapus"
},
"resetDialog": {
"title": "Reset koleksi",
"message": "Reset koleksi \"{{name}}\"? Tindakan ini akan menghapus semua entri tetapi mempertahankan koleksinya.",
"confirm": "Reset"
},
"toasts": {
"loadFailed": "Gagal memuat koleksi: {{message}}",
"created": "Koleksi \"{{name}}\" berhasil dibuat",
"createFailed": "Gagal membuat koleksi: {{message}}",
"deleted": "Koleksi \"{{name}}\" berhasil dihapus",
"deleteFailed": "Gagal menghapus koleksi: {{message}}",
"reset": "Koleksi \"{{name}}\" berhasil direset",
"resetFailed": "Gagal mereset koleksi: {{message}}"
}
}

View File

@@ -0,0 +1,109 @@
{
"actions": {
"save": "Simpan",
"saving": "Menyimpan...",
"cancel": "Batal",
"close": "Tutup",
"confirm": "Konfirmasi",
"delete": "Hapus",
"edit": "Edit",
"add": "Tambah",
"remove": "Hapus",
"create": "Buat",
"update": "Perbarui",
"refresh": "Segarkan",
"reload": "Muat Ulang",
"retry": "Coba Lagi",
"search": "Cari",
"filter": "Filter",
"clear": "Hapus",
"reset": "Reset",
"apply": "Terapkan",
"back": "Kembali",
"next": "Berikutnya",
"previous": "Sebelumnya",
"open": "Buka",
"submit": "Kirim",
"select": "Pilih",
"selectAll": "Pilih semua",
"copy": "Salin",
"copied": "Disalin",
"download": "Unduh",
"upload": "Unggah",
"import": "Impor",
"export": "Ekspor",
"view": "Lihat",
"details": "Detail",
"settings": "Pengaturan",
"help": "Bantuan",
"yes": "Ya",
"no": "Tidak",
"loading": "Memuat..."
},
"status": {
"loading": "Memuat...",
"saving": "Menyimpan...",
"saved": "Tersimpan",
"ready": "Siap",
"running": "Berjalan",
"stopped": "Berhenti",
"starting": "Memulai...",
"stopping": "Menghentikan...",
"pending": "Pending",
"active": "Aktif",
"inactive": "Tidak aktif",
"enabled": "Diaktifkan",
"disabled": "Dinonaktifkan",
"online": "Online",
"offline": "Offline",
"error": "Eror",
"success": "Sukses",
"warning": "Peringatan",
"info": "Info",
"empty": "Tidak ada item",
"none": "Tidak ada",
"unknown": "Tidak diketahui"
},
"dialogs": {
"confirmDelete": {
"title": "Konfirmasi penghapusan",
"message": "Apakah Anda yakin ingin menghapus ini? Tindakan ini tidak dapat dibatalkan.",
"confirm": "Hapus",
"cancel": "Batal"
},
"unsavedChanges": {
"title": "Perubahan belum disimpan",
"message": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?",
"discard": "Buang",
"keepEditing": "Tetap mengedit"
}
},
"forms": {
"required": "Wajib",
"optional": "Opsional",
"name": "Nama",
"description": "Deskripsi",
"type": "Tipe",
"value": "Nilai",
"search": "Cari...",
"selectPlaceholder": "Pilih opsi..."
},
"time": {
"now": "baru saja",
"secondsAgo_one": "{{count}} detik yang lalu",
"secondsAgo_other": "{{count}} detik yang lalu",
"minutesAgo_one": "{{count}} menit yang lalu",
"minutesAgo_other": "{{count}} menit yang lalu",
"hoursAgo_one": "{{count}} jam yang lalu",
"hoursAgo_other": "{{count}} jam yang lalu",
"daysAgo_one": "{{count}} hari yang lalu",
"daysAgo_other": "{{count}} hari yang lalu"
},
"units": {
"bytes": "B",
"kilobytes": "KB",
"megabytes": "MB",
"gigabytes": "GB",
"terabytes": "TB"
}
}

View File

@@ -0,0 +1,17 @@
{
"generic": "Terjadi kesalahan",
"network": "Eror jaringan. Periksa koneksi Anda dan coba lagi.",
"unauthorized": "Anda tidak memiliki izin untuk melakukan tindakan ini.",
"forbidden": "Akses ditolak.",
"notFound": "Sumber daya yang diminta tidak ditemukan.",
"serverError": "Eror server. Silahkan coba lagi nanti.",
"loadFailed": "Gagal memuat: {{message}}",
"saveFailed": "Gagal menyimpan: {{message}}",
"deleteFailed": "Gagal menghapus: {{message}}",
"updateFailed": "Gagal memperbarui: {{message}}",
"createFailed": "Gagal membuat: {{message}}",
"operationFailed": "Operasi gagal: {{message}}",
"invalidInput": "Input tidak valid. Silahkan periksa formulir dan coba lagi.",
"tryAgain": "Silahkan coba lagi.",
"contactAdmin": "Jika masalah terus berlanjut, hubungi administrator Anda."
}

View File

@@ -0,0 +1,66 @@
{
"cluster": {
"vram": "VRAM Kluster",
"ram": "RAM Kluster",
"nodesOnline": "{{healthy}}/{{total}} node online"
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"assistant": {
"title": "Kelola LocalAI melalui obrolan",
"description": "Instal model, ganti backend, edit konfigurasi dan periksa status dengan berbicara pada LocalAI.",
"open": "Buka asisten",
"tooltip": "Kelola LocalAI melalui obrolan"
},
"input": {
"placeholder": "Pesan...",
"attachImage": "Lampirkan gambar",
"attachAudio": "Lampirkan audio",
"attachFile": "Lampirkan file",
"enterToSend": "Enter untuk mengirim",
"selectModelFirst": "Pilih model terlebih dahulu",
"sendMessage": "Kirim pesan",
"selectModelToast": "Silahkan pilih model terlebih dahulu"
},
"quickLinks": {
"manageByChat": "Kelola melalui obrolan",
"installedModels": "Model Terinstal",
"browseGallery": "Jelajahi Galeri",
"importModel": "Impor Model",
"documentation": "Dokumentasi"
},
"loadedModels": {
"count_one": "{{count}} model dimuat",
"count_other": "{{count}} model dimuat",
"stop": "Hentikan model",
"stopAll": "Hentikan semua"
},
"stopDialog": {
"title": "Hentikan Model",
"message": "Hentikan model {{model}}?",
"confirm": "Hentikan {{model}}",
"stopAllTitle": "Hentikan Semua model",
"stopAllMessage": "Hentikan semua {{count}} model yang dimuat?",
"stopAllConfirm": "Hentikan semua",
"stoppedToast": "{{model}} berhasil dihentikan",
"allStoppedToast": "Semua model berhasil dihentikan",
"stopFailed": "Gagal menghentikan: {{message}}"
},
"wizard": {
"getStarted": "Memulai dengan {{name}}",
"intro": "Instal model pertama Anda untuk memulai. Jelajahi galeri atau impor punya Anda sendiri.",
"steps": {
"step1Title": "Jelajahi Galeri Model",
"step1Body": "Temukan model yang tepat untuk kebutuhan Anda dari koleksi pilihan kami.",
"step2Title": "Instal Model",
"step2Body": "Klik instal untuk mengunduh dan mengonfigurasinya secara otomatis.",
"step3Title": "Mulai Mengobrol",
"step3Body": "Mengobrol dengan model Anda langsung dari peramban atau gunakan API."
},
"browseGallery": "Jelajahi Galeri Model",
"importModel": "Impor Model",
"docs": "Dokumentasi",
"noModelsTitle": "Tidak Ada Model yang Tersedia",
"noModelsBody": "Belum ada model yang terinstal. Hubungi administrator Anda untuk menyiapkan model agar Anda dapat mulai mengobrol."
}
}

View File

@@ -0,0 +1,142 @@
{
"title": "Impor Model Baru",
"subtitle": {
"simple": "Import model dari URI — deteksi otomatis memilih backend.",
"powerYaml": "Tulis konfigurasi YAML lengkap untuk model.",
"powerPrefs": "Preferensi impor tingkat lanjut."
},
"actions": {
"import": "Impor Model",
"importing": "Mengimpor...",
"create": "Buat",
"saving": "Menyimpan...",
"browseHF": "Jelajahi model di HF",
"addCustom": "Tambah Tersuai",
"copy": "Salin"
},
"form": {
"modelUri": "URI Model",
"uriPlaceholder": "huggingface://TheBloke/Llama-2-7B-Chat-GGUF atau https://example.com/model.gguf",
"uriHint": "Masukkan URI atau path ke file model yang ingin Anda impor",
"supportedFormats": "Format URI yang Didukung",
"options": "Opsi",
"preferences": "Preferensi (Opsional)",
"commonPreferences": "Preferensi Umum",
"customPreferences": "Preferensi Tersuai",
"customKeyValueHint": "Tambahkan pasangan key-value tersuai untuk konfigurasi tingkat lanjutan.",
"preferenceKey": "Key preferensi untuk baris {{index}}",
"preferenceValue": "Nilai preferensi untuk baris {{index}}",
"removePref": "Hapus preferensi ini",
"key": "Key",
"value": "Value",
"backend": "Backend",
"backendAuto": "Deteksi otomatis (berdasarkan URI)",
"backendLoading": "Memuat backend…",
"backendSearch": "Cari backend...",
"backendHint": "Paksa backend tertentu. Biarkan kosong untuk deteksi otomatis dari URI. Item yang ditandai \"pilih manual\" tidak dapat dideteksi otomatis — pilih sendiri jika Anda tahu apa yang dibutuhkan model.",
"backendErrorHint": "Tidak dapat memuat daftar backend — hanya menggunakan deteksi otomatis.",
"backendNotInstalled": "Backend ini belum terinstal. Proses impor akan mengunduhnya terlebih dahulu.",
"modelName": "Nama Model",
"modelNamePlaceholder": "Kosongkan untuk menggunakan nama file",
"modelNameHint": "Nama tersuai untuk model. Jika kosong, nama file akan digunakan.",
"description": "Deskripsi",
"descriptionPlaceholder": "Kosongkan untuk menggunakan deskripsi bawaan",
"descriptionHint": "Deskripsi tersuai untuk model.",
"quantizations": "Kuantisasi",
"quantizationsPlaceholder": "q4_k_m,q4_k_s,q3_k_m (dipisahkan koma)",
"quantizationsHint": "Kuantisasi pilihan (dipisahkan koma). Kosongkan untuk nilai bawaan (q4_k_m).",
"mmprojQuantizations": "Kuantisasi MMProj",
"mmprojQuantizationsPlaceholder": "fp16,fp32 (dipisahkan koma)",
"mmprojQuantizationsHint": "Kuantisasi MMProj pilihan. Kosongkan untuk nilai bawaan (fp16).",
"embeddings": "Embedding",
"embeddingsHint": "Aktifkan dukungan embedding untuk model ini.",
"modelType": "Tipe Model",
"modelTypePlaceholder": "AutoModelForCausalLM (untuk backend transformers)",
"modelTypeHint": "Tipe model untuk backend transformers. Contoh: AutoModelForCausalLM, SentenceTransformer, Mamba.",
"pipelineType": "Tipe Pipeline",
"pipelineTypeHint": "Tipe pipeline untuk backend diffusers.",
"schedulerType": "Tipe Scheduler",
"schedulerTypePlaceholder": "k_dpmpp_2m (optional)",
"schedulerTypeHint": "Tipe scheduler untuk backend diffusers. Contoh: k_dpmpp_2m, euler_a, ddim.",
"enableParameters": "Aktifkan parameter",
"enableParametersPlaceholder": "negative_prompt,num_inference_steps (dipisahkan koma)",
"enableParametersHint": "Parameter aktif untuk backend diffusers (dipisahkan koma).",
"cuda": "CUDA",
"cudaHint": "Aktifkan dukungan CUDA untuk akselerasi GPU.",
"yamlEditor": "Editor Konfigurasi YAML",
"manualPick": "pilih manual",
"manualPickTooltip": "Deteksi otomatis tidak akan merutekan ke backend ini. Pilih di sini jika Anda tahu bahwa ini yang Anda inginkan."
},
"modality": {
"text": "LLM Teks",
"asr": "Pengenalan suara",
"tts": "Text-to-speech",
"image": "Gambar / Video",
"embeddings": "Embedding",
"reranker": "Reranker",
"detection": "Deteksi object",
"vad": "Deteksi aktivitas suara",
"other": "Lainnya"
},
"powerTabs": {
"ariaLabel": "Tab mode tingkat lanjutan",
"preferences": "Preferensi",
"yaml": "YAML"
},
"switchDialog": {
"title": "Pertahankan preferensi tersuai Anda?",
"body": "Beralih ke mode Sederhana akan menyembunyikan preferensi selain backend, nama, dan deskripsi. Preferensi tersebut tetap akan dikirim saat mengimpor.",
"cancel": "Batal",
"discard": "Buang & beralih",
"keep": "Pertahankan & beralih"
},
"estimate": {
"title": "Estimasi kebutuhan",
"download": "Unduhan: {{size}}",
"vram": "VRAM: {{vram}}"
},
"toasts": {
"noUri": "Silahkan masukkan URI model",
"noYaml": "Silahkan masukkan konfigurasi YAML",
"started": "Impor dimulai! Melacak progress...",
"startedWithMeta": "Impor dimulai! Melacak progress... ({{meta}})",
"imported": "Model berhasil diimpor!",
"importedYaml": "Konfigurasi model berhasil diimpor!",
"importFailed": "Gagal mengimpor: {{message}}",
"startImportFailed": "Gagal memulai impor: {{message}}",
"backendsLoadFailed": "Tidak dapat memuat daftar backend — hanya menggunakan deteksi otomatis.",
"modalityClearedBackend": "Pilihan backend dikosongkan — tidak termasuk dalam grup {{label}}.",
"copied": "Disalin ke papan klip"
},
"uriFormats": {
"huggingface": {
"title": "HuggingFace",
"standard": "Format standar HuggingFace",
"short": "Format ringkas HuggingFace",
"fullUrl": "URL lengkap HuggingFace"
},
"http": {
"title": "URL HTTP/HTTPS",
"direct": "Unduh langsung dari URL HTTPS mana pun"
},
"local": {
"title": "File Lokal",
"filePath": "Path file lokal (absolut)",
"directYaml": "File konfigurasi YAML lokal langsung"
},
"oci": {
"title": "Registri OCI",
"registry": "Registri kontainer OCI",
"tarball": "File tarball OCI lokal"
},
"ollama": {
"title": "Ollama",
"model": "Format model Ollama"
},
"yaml": {
"title": "File Konfigurasi YAML",
"remote": "File konfigurasi YAML jarak jauh",
"local": "File konfigurasi YAML lokal"
}
}
}

View File

@@ -0,0 +1,154 @@
{
"studio": {
"tabs": {
"images": "Gambar",
"video": "Video",
"tts": "TTS",
"sound": "Suara"
}
},
"image": {
"title": "Pembuatan Gambar",
"labels": {
"model": "Model",
"prompt": "Prompt",
"promptPlaceholder": "Deskripsikan gambar yang ingin Anda buat...",
"negativePrompt": "Prompt Negatif",
"negativePromptPlaceholder": "Apa yang harus dihindari...",
"size": "Ukuran",
"count": "Jumlah (1-4)",
"advanced": "Pengaturan Tingkat Lanjutan",
"imageInputs": "Input Gambar",
"steps": "Steps",
"stepsPlaceholder": "20",
"seed": "Seed",
"seedPlaceholder": "Acak",
"sourceImage": "Gambar Sumber (img2img)",
"refImages": "Gambar Referensi",
"refImagesAdded_one": "{{count}} gambar ditambahkan",
"refImagesAdded_other": "{{count}} gambar ditambahkan"
},
"actions": {
"generate": "Hasilkan",
"generating": "Menghasilkan..."
},
"empty": "Gambar yang dihasilkan akan muncul di sini",
"toasts": {
"noPrompt": "Silahkan masukkan prompt",
"noModel": "Silahkan pilih model",
"noResults": "Tidak ada gambar yang dihasilkan"
}
},
"video": {
"title": "Pembuatan Video",
"labels": {
"model": "Model",
"prompt": "Prompt",
"promptPlaceholder": "Deskripsikan video yang ingin Anda buat...",
"duration": "Durasi (detik)",
"fps": "FPS",
"size": "Ukuran",
"advanced": "Pengaturan Tingkat Lanjutan",
"seed": "Seed",
"seedPlaceholder": "Acak"
},
"actions": {
"generate": "Hasilkan",
"generating": "Menghasilkan..."
},
"empty": "Video yang dihasilkan akan muncul di sini",
"toasts": {
"noPrompt": "Silahkan masukkan prompt",
"noModel": "Silahkan pilih model",
"noResults": "Tidak ada video yang dihasilkan"
}
},
"tts": {
"title": "Teks ke Suara",
"labels": {
"model": "Model",
"voice": "Suara",
"voicePlaceholder": "ID suara opsional",
"input": "Teks",
"inputPlaceholder": "Masukkan teks untuk disintesis..."
},
"actions": {
"generate": "Hasilkan",
"generating": "Menghasilkan..."
},
"empty": "Audio yang dihasilkan akan muncul di sini",
"toasts": {
"noText": "Silahkan masukkan teks",
"noModel": "Silahkan pilih model",
"generateFailed": "Gagal menghasilkan"
}
},
"sound": {
"title": "Pembuatan Suara",
"labels": {
"model": "Model",
"prompt": "Prompt",
"promptPlaceholder": "Deskripsikan suara yang ingin Anda buat...",
"duration": "Durasi (detik)",
"language": "Bahasa",
"vocalLanguage": "Bahasa vokal",
"lyrics": "Lirik (opsional)",
"lyricsPlaceholder": "Lirik untuk generasi vokal",
"advanced": "Pengaturan Tingkat Lanjutan",
"seed": "Seed",
"seedPlaceholder": "Acak"
},
"actions": {
"generate": "Hasilkan",
"generating": "Menghasilkan..."
},
"empty": "Audio yang dihasilkan akan muncul di sini",
"toasts": {
"noPrompt": "Silahkan masukkan prompt",
"noModel": "Silahkan pilih model",
"generateFailed": "Gagal menghasilkan"
}
},
"talk": {
"title": "Percakapan",
"subtitle": "Percakapan suara realtime",
"actions": {
"start": "Mulai sesi",
"stop": "Akhiri sesi",
"connecting": "Menghubungkan...",
"muted": "Disenyapkan",
"mute": "Senyapkan",
"unmute": "Aktifkan Suara"
},
"labels": {
"model": "Model",
"voice": "Suara",
"voicePlaceholder": "alloy",
"language": "Bahasa",
"languagePlaceholder": "en",
"instructions": "Instruksi",
"instructionsPlaceholder": "Atur persona asisten..."
},
"status": {
"idle": "Idle",
"connecting": "Menghubungkan...",
"listening": "Mendengarkan...",
"speaking": "Berbicara...",
"ended": "Sesi berakhir"
},
"toasts": {
"noModel": "Silahkan pilih model terlebih dahulu",
"connectFailed": "Gagal terhubung: {{message}}"
}
},
"history": {
"title": "Riwayat",
"empty": "Tidak ada riwayat",
"deleteEntry": "Hapus entri",
"clear": "Hapus riwayat",
"clearTitle": "Hapus semua riwayat",
"clearMessage": "Hapus semua entri riwayat? Tindakan ini tidak dapat dibatalkan.",
"clearConfirm": "Hapus",
"cleared": "Riwayat dihapus"
}
}

View File

@@ -0,0 +1,93 @@
{
"title": "Instal Model",
"subtitle": "Telusuri dan instal model AI dari galeri",
"stats": {
"available": "Tersedia",
"installed": "Terinstal"
},
"actions": {
"addModel": "Tambah Model",
"importModel": "Impor Model",
"install": "Instal",
"reinstall": "Instal Ulang",
"delete": "Hapus"
},
"filters": {
"all": "Semua",
"llm": "Obrolan",
"image": "Gambar",
"video": "Video",
"multimodal": "Multimodal",
"vision": "Vision",
"tts": "TTS",
"stt": "STT",
"diarization": "Diarization",
"soundGen": "Suara",
"audioTransform": "Efek Audio",
"realtimeAudio": "Audio Realtime",
"embedding": "Embedding",
"rerank": "Rerank",
"detection": "Deteksi",
"vad": "VAD",
"fitsGpu": "Muat di GPU",
"allBackends": "Semua Backend",
"searchBackends": "Cari backends..."
},
"search": {
"placeholder": "Cari model...",
"clearFilters": "Hapus filter"
},
"table": {
"modelName": "Nama Model",
"description": "Deskripsi",
"backend": "Backend",
"sizeVram": "Ukuran / VRAM",
"status": "Status",
"actions": "Aksi",
"size": "Ukuran: {{size}}",
"vram": "VRAM: {{vram}}",
"fits": "Muat",
"mayNotFit": "Mungkin tidak muat",
"trustRemoteCode": "Trust Remote Code",
"installing": "Menginstal",
"installingPct": "Menginstal · {{percent}}%",
"installed": "Terinstal",
"notInstalled": "Belum Terinstal"
},
"detail": {
"description": "Deskripsi",
"gallery": "Galeri",
"backend": "Backend",
"size": "Ukuran",
"vram": "VRAM",
"license": "Lisensi",
"tags": "Tag",
"links": "Tautan",
"warning": "Peringatan",
"files": "File",
"fitsGpu": "Muat di GPU",
"mayNotFitGpu": "Mungkin tidak muat di GPU",
"requiresTrustRemoteCode": "Memerlukan Trust Remote Code",
"fileCount_one": "{{count}} file",
"fileCount_other": "{{count}} file",
"filename": "Nama file",
"uri": "URI",
"sha256": "SHA256"
},
"empty": {
"title": "Model tidak ditemukan",
"withFilters": "Tidak ada model yang cocok dengan pencarian atau filter Anda.",
"noFilters": "Galeri model kosong."
},
"deleteDialog": {
"title": "Hapus Model",
"message": "Hapus model {{model}}?",
"confirm": "Hapus {{model}}",
"deletingToast": "Menghapus {{model}}..."
},
"errors": {
"loadFailed": "Gagal memuat model: {{message}}",
"installFailed": "Gagal menginstal: {{message}}",
"deleteFailed": "Gagal menghapus: {{message}}"
}
}

View File

@@ -0,0 +1,54 @@
{
"appName": "LocalAI",
"openMenu": "Buka menu",
"closeMenu": "Tutup menu",
"primaryNavigation": "Navigasi utama",
"switchToLightMode": "Ganti ke mode terang",
"switchToDarkMode": "Ganti ke mode gelap",
"expandSidebar": "Perluas sidebar",
"collapseSidebar": "Ciutkan sidebar",
"changeLanguage": "Ubah bahasa",
"logout": "Keluar",
"accountSettings": "Pengaturan akun",
"account": "Akun",
"accountFor": "Akun: {{name}}",
"sections": {
"tools": "Peralatan",
"enhance": "Peningkatan",
"biometrics": "Biometrik",
"agents": "Agen",
"system": "Sistem"
},
"items": {
"home": "Beranda",
"installModels": "Instal Model",
"chat": "Obrolan",
"studio": "Studio",
"talk": "Bicara",
"fineTune": "Fine-Tune (Eksperimental)",
"quantize": "Kuantisasi (Eksperimental)",
"audioTransform": "Transformasi Audio",
"faceRecognition": "Pengenalan Wajah",
"voiceRecognition": "Pengenalan Suara",
"agents": "Agen",
"skills": "Skill",
"memory": "Memori",
"mcpJobs": "MCP CI Jobs",
"usage": "Penggunaan",
"users": "Pengguna",
"middleware": "Middleware",
"backends": "Backend",
"traces": "Trace",
"nodes": "Node",
"swarm": "Swarm",
"system": "Sistem",
"settings": "Pengaturan",
"api": "API"
},
"footer": {
"github": "GitHub",
"documentation": "Dokumentasi",
"author": "Penulis",
"copyright": "© 2023-{{year}} {{author}}"
}
}

View File

@@ -0,0 +1,79 @@
{
"title": "Skill",
"subtitle": "Kelola skill agen (instruksi dan sumber daya yang dapat digunakan kembali)",
"unavailable": {
"subtitle": "Layanan Skill tidak tersedia atau indeks sedang dibangun ulang. Coba beberapa saat lagi.",
"retry": "Coba lagi"
},
"actions": {
"newSkill": "Skill baru",
"createSkill": "Buat skill",
"import": "Impor",
"importing": "Mengimpor...",
"gitRepos": "Repo Git",
"edit": "Edit",
"delete": "Hapus",
"export": "Ekspor",
"sync": "Sinkronisasi",
"addRepo": "Tambah repo",
"adding": "Menambahkan...",
"remove": "Hapus",
"enable": "Aktifkan",
"disable": "Nonaktifkan"
},
"search": {
"placeholder": "Cari skill..."
},
"git": {
"title": "Repositori git",
"description": "Tambah repositori Git untuk mengambil skill. Skill akan muncul di daftar setelah sinkronisasi.",
"urlPlaceholder": "https://github.com/user/repo atau git@github.com:user/repo.git",
"noRepos": "Tidak ada repositori Git yang dikonfigurasi. Tambahkan satu di atas.",
"disabled": "Dinonaktifkan",
"removeRepo": "Hapus repo"
},
"card": {
"noDescription": "Tidak ada deskripsi",
"readOnly": "Hanya baca",
"editTitle": "Edit skill",
"deleteTitle": "Hapus skill",
"exportTitle": "Ekspor sebagai .tar.gz"
},
"empty": {
"title": "Tidak ada skill yang ditemukan",
"text": "Buat atau impor skill untuk memulai.",
"noPersonal": "Anda belum memiliki skill."
},
"sections": {
"yourSkills": "Skill Anda",
"otherUsersSkills": "Skill Pengguna Lain"
},
"deleteDialog": {
"title": "Hapus Skill",
"message": "Hapus skill \"{{name}}\"? Tindakan ini tidak dapat dibatalkan.",
"confirm": "Hapus"
},
"removeRepoDialog": {
"title": "Hapus Repositori Git",
"message": "Hapus repositori Git ini? Skill dari repo ini tidak akan tersedia lagi.",
"confirm": "Hapus"
},
"toasts": {
"loadFailed": "Gagal memuat skill",
"deleted": "Skill \"{{name}}\" berhasil dihapus",
"deleteFailed": "Gagal menghapus skill",
"exported": "Skill \"{{name}}\" berhasil diekspor",
"exportFailed": "Gagal mengekspor",
"imported": "Skill berhasil diimpor dari \"{{file}}\" ",
"importFailed": "Gagal mengimpor",
"loadReposFailed": "Gagal memuat repositori Git",
"repoAdded": "Repo Git berhasil ditambahkan dan sedang sinkronisasi",
"addRepoFailed": "Gagal menambahkan repo",
"synced": "Repo berhasil disinkronisasi",
"syncFailed": "Gagal sinkronisasi",
"toggled": "Berhasil mengubah status repo",
"toggleFailed": "Gagal mengubah status repo",
"removed": "Repo berhasil dihapus",
"removeFailed": "Gagal menghapus repo"
}
}

View File

@@ -17,7 +17,7 @@
//
// Behavior:
// - Reads public/locales/en/*.json as source of truth.
// - For each other locale (it, es, de, zh-CN), opens the matching file
// - For each other locale (it, es, de, zh-CN, id), opens the matching file
// (or creates it). Walks the source object; for each leaf string:
// * If the target already has a non-empty translation, leave it.
// * If --copy mode, fill with the English value.
@@ -36,13 +36,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = join(__dirname, '..')
const LOCALES_DIR = join(ROOT, 'public', 'locales')
const SOURCE_LOCALE = 'en'
const TARGET_LOCALES = ['it', 'es', 'de', 'zh-CN']
const TARGET_LOCALES = ['it', 'es', 'de', 'zh-CN', 'id']
const LANGUAGE_NAMES = {
it: 'Italian',
es: 'Spanish',
de: 'German',
'zh-CN': 'Simplified Chinese',
id: 'Indonesian',
}
const argv = process.argv.slice(2)

View File

@@ -13,6 +13,7 @@ import { useCodeMirror } from '../hooks/useCodeMirror'
import { useTheme } from '../contexts/ThemeContext'
import { getThemeExtension } from '../utils/cmTheme'
import { createYamlCompletionSource } from '../utils/cmYamlComplete'
import { goTemplate } from '../utils/cmGoTemplate'
function yamlIssueToDiagnostic(issue, cmDoc, severity) {
const len = cmDoc.length
@@ -43,14 +44,17 @@ const yamlLinter = linter(view => {
return diagnostics
})
export default function CodeEditor({ value, onChange, disabled, minHeight = '500px', fields }) {
export default function CodeEditor({ value, onChange, disabled, minHeight = '500px', fields, language = 'yaml' }) {
const containerRef = useRef(null)
const { theme } = useTheme()
const isGoTemplate = language === 'gotemplate'
// Static extensions — only recreate when fields change
// Static extensions — only recreate when fields/language change
const extensions = useMemo(() => {
const exts = [
yaml(),
// Go templates aren't YAML — skip the YAML mode/linter so valid
// `{{ ... }}` syntax isn't flagged as a YAML parse error.
isGoTemplate ? goTemplate : yaml(),
lineNumbers(),
highlightActiveLineGutter(),
highlightActiveLine(),
@@ -59,8 +63,6 @@ export default function CodeEditor({ value, onChange, disabled, minHeight = '500
indentOnInput(),
bracketMatching(),
highlightSelectionMatches(),
yamlLinter,
lintGutter(),
history(),
indentUnit.of(' '),
EditorState.tabSize.of(2),
@@ -77,15 +79,18 @@ export default function CodeEditor({ value, onChange, disabled, minHeight = '500
}),
]
if (fields && fields.length > 0) {
exts.push(autocompletion({
override: [createYamlCompletionSource(fields)],
activateOnTyping: true,
}))
if (!isGoTemplate) {
exts.push(yamlLinter, lintGutter())
if (fields && fields.length > 0) {
exts.push(autocompletion({
override: [createYamlCompletionSource(fields)],
activateOnTyping: true,
}))
}
}
return exts
}, [minHeight, fields])
}, [minHeight, fields, isGoTemplate])
// Dynamic extensions — reconfigured via Compartments (preserves undo/cursor/scroll)
const dynamicExtensions = useMemo(() => ({

View File

@@ -16,6 +16,7 @@ const PROVIDER_TO_CAPABILITY = {
'models:tts': 'FLAG_TTS',
'models:transcript': 'FLAG_TRANSCRIPT',
'models:vad': 'FLAG_VAD',
'models:score': 'FLAG_SCORE',
}
function coerceValue(raw, uiType) {
@@ -325,7 +326,7 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
</div>
{isStructured
? <StructuredCodeEditor value={value} onChange={handleChange} minHeight="80px" />
: <CodeEditor value={value || ''} onChange={handleChange} minHeight="80px" />}
: <CodeEditor value={value || ''} onChange={handleChange} minHeight="80px" language={field.language} />}
</div>
)
}

View File

@@ -5,7 +5,7 @@ import SearchableSelect from './SearchableSelect'
const ACTION_OPTIONS = [
{ value: 'mask', label: 'Mask — replace with a [REDACTED:id] placeholder' },
{ value: 'block', label: 'Block — reject the request (request side) / mask in stream' },
{ value: 'route_local', label: 'Route local — keep text, force local-only routing' },
{ value: 'allow', label: 'Allow — detect & log, leave text unchanged' },
]
export default function PIIPatternListEditor({ value, onChange }) {

View File

@@ -10,6 +10,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: 'es', name: 'Español', flag: 'ES' },
{ code: 'de', name: 'Deutsch', flag: 'DE' },
{ code: 'zh-CN', name: '简体中文', flag: 'ZH' },
{ code: 'id', name: 'Bahasa Indonesia', flag: 'ID' },
]
export const NAMESPACES = [

View File

@@ -139,7 +139,8 @@ export default function AgentChat() {
id: nextId(),
sender,
content: data.content || data.message || '',
timestamp: data.timestamp ? Math.floor(data.timestamp / 1e6) : Date.now(),
// Backend sends Unix milliseconds (see core/services/agents events).
timestamp: data.timestamp || Date.now(),
}
if (data.metadata && Object.keys(data.metadata).length > 0) {
msg.metadata = data.metadata

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom'
import { agentJobsApi, modelsApi } from '../utils/api'
import { fromState } from '../utils/editorNav'
import { useModels } from '../hooks/useModels'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
@@ -13,6 +14,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
export default function AgentJobs() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const location = useLocation()
const { models } = useModels()
const { isAdmin, authEnabled, user } = useAuth()
const userMap = useUserMap()
@@ -338,7 +340,7 @@ export default function AgentJobs() {
</td>
<td>
{task.model ? (
<a onClick={() => navigate(`/app/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
<a onClick={() => navigate(`/app/model-editor/${encodeURIComponent(task.model)}`, { state: fromState(location, 'Agent Jobs') })} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
{task.model}
</a>
) : '-'}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useOutletContext, useNavigate } from 'react-router-dom'
import { useParams, useOutletContext, useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import { useChat } from '../hooks/useChat'
import ModelSelector from '../components/ModelSelector'
import { renderMarkdown, highlightAll } from '../utils/markdown'
@@ -285,6 +286,7 @@ export default function Chat() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation('chat')
const { isAdmin } = useAuth()
const { operations } = useOperations()
@@ -904,7 +906,7 @@ export default function Chat() {
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`)}
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`, { state: fromState(location, 'Chat') })}
title={t('header.editConfig')}
>
<i className="fas fa-pen-to-square" /> {t('header.editConfig')}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import ResourceMonitor from '../components/ResourceMonitor'
import ConfirmDialog from '../components/ConfirmDialog'
import NodeDistributionChip from '../components/NodeDistributionChip'
@@ -121,6 +122,7 @@ function formatBackendVersion(metadata) {
export default function Manage() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation('admin')
const [searchParams, setSearchParams] = useSearchParams()
const initialTab = searchParams.get('tab') || localStorage.getItem('manage-tab') || 'models'
@@ -673,7 +675,7 @@ export default function Manage() {
onClick: () => handleTogglePinned(model.id, model.pinned),
disabled: pinningModels.has(model.id) || !!model.disabled },
{ key: 'edit', icon: 'fa-pen-to-square', label: 'Edit configuration',
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) },
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`, { state: fromState(location, 'Manage') }) },
{ key: 'logs', icon: 'fa-terminal', label: 'Backend logs',
onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) },
{ divider: true },

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef, useMemo, Fragment } from 'react'
import { useOutletContext, Link, useNavigate } from 'react-router-dom'
import { useOutletContext, Link, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
import { apiUrl } from '../utils/basePath'
import { fromState } from '../utils/editorNav'
import { settingsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
@@ -26,13 +27,13 @@ const TABS = [
{ id: 'events', label: 'Events', icon: 'fa-list-ul' },
]
const ACTIONS = ['mask', 'block', 'route_local']
const ACTIONS = ['mask', 'block', 'allow']
function actionBadge(action) {
const colors = {
mask: 'var(--color-primary)',
block: 'var(--color-error)',
route_local: 'var(--color-warning)',
allow: 'var(--color-warning)',
}
return (
<span style={{
@@ -75,9 +76,20 @@ export default function Middleware() {
const [events, setEvents] = useState([])
const [decisions, setDecisions] = useState([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('filtering')
// The active tab lives in the URL (?tab=) so deep links and the model-editor
// Back button (which captures location.search) return to the same tab; a
// localStorage fallback restores it on a bare visit. Mirrors the Manage page.
const [searchParams, setSearchParams] = useSearchParams()
const initialTab = searchParams.get('tab') || localStorage.getItem('middleware-tab') || 'filtering'
const [activeTab, setActiveTab] = useState(TABS.some(t => t.id === initialTab) ? initialTab : 'filtering')
const [pendingPattern, setPendingPattern] = useState(null) // id while a PUT is in flight
const selectTab = (id) => {
setActiveTab(id)
localStorage.setItem('middleware-tab', id)
setSearchParams({ tab: id })
}
// silent=true on background polls: skips the loading spinner and
// suppresses toast spam if the server is briefly unreachable.
const fetchAll = useCallback(async (silent = false) => {
@@ -178,7 +190,7 @@ export default function Middleware() {
<button
key={tab.id}
className={`btn btn-sm ${activeTab === tab.id ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab(tab.id)}
onClick={() => selectTab(tab.id)}
>
<i className={`fas ${tab.icon}`} style={{ marginRight: 4 }} />
{tab.label}
@@ -215,6 +227,7 @@ export default function Middleware() {
}
function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPersist, persisting }) {
const location = useLocation()
if (!status?.pii) return null
const pii = status.pii
@@ -353,6 +366,7 @@ function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPe
<td>
<Link
to={`/app/model-editor/${encodeURIComponent(m.name)}`}
state={fromState(location, 'Middleware')}
className="btn btn-secondary btn-sm"
style={{ fontSize: '0.6875rem', padding: '2px 8px' }}
title={`Edit ${m.name}.yaml`}
@@ -485,6 +499,7 @@ function DecisionDetail({ d }) {
function RoutingTab({ status, decisions }) {
const navigate = useNavigate()
const location = useLocation()
const router = status?.router || { configured: false }
const [expanded, setExpanded] = useState(() => new Set())
@@ -519,7 +534,7 @@ function RoutingTab({ status, decisions }) {
<button
className="btn btn-primary"
style={{ marginTop: 'var(--spacing-md)' }}
onClick={() => navigate('/app/model-editor?template=router')}
onClick={() => navigate('/app/model-editor?template=router', { state: fromState(location, 'Middleware') })}
>
<i className="fas fa-plus" /> Create routing model
</button>
@@ -539,7 +554,7 @@ function RoutingTab({ status, decisions }) {
</span>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate('/app/model-editor?template=router')}
onClick={() => navigate('/app/model-editor?template=router', { state: fromState(location, 'Middleware') })}
title="Open the model editor with the Routing Model template pre-selected"
>
<i className="fas fa-plus" /> Add routing model
@@ -560,7 +575,9 @@ function RoutingTab({ status, decisions }) {
<tbody>
{router.models.map(m => (
<tr key={m.name}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem', fontWeight: 600 }}>{m.name}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem', fontWeight: 600 }}>
<Link to={`/app/model-editor/${encodeURIComponent(m.name)}`} state={fromState(location, 'Middleware')} title="Edit this router model's config">{m.name}</Link>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>{m.classifier}</td>
<td style={{ fontSize: '0.75rem' }}>
{(m.candidates || []).map((c, i) => (
@@ -657,6 +674,7 @@ function RoutingTab({ status, decisions }) {
function ProxyTab({ status, addToast, onChanged }) {
const navigate = useNavigate()
const location = useLocation()
const mitm = status?.mitm
const serverListen = mitm?.configured_addr || ''
@@ -722,7 +740,7 @@ function ProxyTab({ status, addToast, onChanged }) {
<code style={{ fontFamily: 'var(--font-mono)' }}>{h}</code>
{' claimed by: '}
{(conflicts[h] || []).map(name => (
<Link key={name} to={`/app/model-editor/${encodeURIComponent(name)}`} style={{ marginRight: 6, fontFamily: 'var(--font-mono)' }}>
<Link key={name} to={`/app/model-editor/${encodeURIComponent(name)}`} state={fromState(location, 'Middleware')} style={{ marginRight: 6, fontFamily: 'var(--font-mono)' }}>
{name}
</Link>
))}
@@ -754,7 +772,7 @@ function ProxyTab({ status, addToast, onChanged }) {
<ul style={{ margin: 0, paddingLeft: 20, fontFamily: 'var(--font-mono)' }}>
{ownerEntries.map(([host, name]) => (
<li key={host}>
{host} <Link to={`/app/model-editor/${encodeURIComponent(name)}`}>{name}</Link>
{host} <Link to={`/app/model-editor/${encodeURIComponent(name)}`} state={fromState(location, 'Middleware')}>{name}</Link>
</li>
))}
</ul>
@@ -784,7 +802,7 @@ function ProxyTab({ status, addToast, onChanged }) {
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>MITM Models</h2>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate('/app/model-editor?template=mitm')}
onClick={() => navigate('/app/model-editor?template=mitm', { state: fromState(location, 'Middleware') })}
title="Open the model editor with the MITM Intercept template pre-selected"
>
<i className="fas fa-plus" /> Add MITM model
@@ -815,6 +833,7 @@ function ProxyTab({ status, addToast, onChanged }) {
<td>
<Link
to={`/app/model-editor/${encodeURIComponent(m.name)}`}
state={fromState(location, 'Middleware')}
className="btn btn-secondary btn-sm"
style={{ fontSize: '0.6875rem', padding: '2px 8px' }}
>

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