Compare commits

...

61 Commits

Author SHA1 Message Date
Ettore Di Giacinto
6715d75f22 feat(config): default concurrent serving (n_parallel) by GPU VRAM
The llama.cpp backend defaults n_parallel=1, which serializes multi-user requests
and leaves continuous batching off (it auto-enables only at n_parallel>1). Fold a
VRAM-scaled parallel-slot default into the hardware-config path so multi-user
serving works out of the box: >=32GiB->8, >=8GiB->4, >=4GiB->2, else unchanged.
With the backend's unified KV the slots SHARE the context budget, so this adds
concurrency without multiplying KV memory. Explicit parallel/n_parallel always
wins. EnsureParallelOption is shared by the single-host path (ApplyHardwareDefaults
with the local GPU) and the distributed router (per selected node's reported VRAM,
since the frontend may have no GPU). LocalGPU now also reports VRAM.

Assisted-by: Claude:opus-4.8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-20 09:35:04 +00:00
Ettore Di Giacinto
2f7e76f0f3 test(config): injectable local-GPU seam + single-instance coverage
Make local GPU detection an injectable package var (localGPU) so the
single-instance path (SetDefaults -> ApplyHardwareDefaults) is deterministically
testable without a real GPU, mirroring the distributed override's coverage.
Adds specs asserting SetDefaults sets the Blackwell physical batch, leaves it
unset on non-Blackwell, and never overrides an explicit batch.

Assisted-by: Claude:opus-4.8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-19 22:18:27 +00:00
Ettore Di Giacinto
bca250e2bd feat(config): node-aware hardware defaults — larger physical batch on Blackwell
A larger physical batch (n_batch/n_ubatch) materially lifts MoE prefill on
NVIDIA Blackwell consumer GPUs (sm_120/121, incl. GB10 / DGX Spark) — measured
on a GB10 with Qwen3-Coder-30B-A3B, the prefill ceiling rises (ub512 ~2994 ->
ub2048 ~3316 t/s) and saturates around 2048.

The heuristic lives in core/config alongside the other config overriders
(ApplyInferenceDefaults, guessDefaultsFromFile/NGPULayers) — they all fill the
ModelConfig from heuristics, so hardware tuning is the same domain and stays in
one place. It is parameterized on a GPU descriptor (not direct detection) so it
works in both deployment shapes:

- Single host: SetDefaults applies it with the LocalGPU.
- Distributed: only the worker sees the GPU, so the worker reports its compute
  capability on registration (gpu_compute_capability -> BackendNode), and the
  router re-applies the SAME core/config heuristic for the SELECTED node before
  loading — fixing the case where the frontend has no GPU at all.

Explicit `batch:` always wins (only managed default values are touched).
xsysinfo gains NVIDIAComputeCapability() (detection only); all interpretation
lives in core/config. Tests: core/config, pkg/xsysinfo, core/services/nodes.

Assisted-by: Claude:opus-4.8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-19 22:02:14 +00:00
LocalAI [bot]
079ac0e15a fix(realtime): raise WebRTC data-channel max-message-size + keep sendLoop alive (#10407)
* fix(realtime): raise WebRTC data-channel max-message-size for large events

Browsers advertise a conservative SCTP max-message-size in their SDP offer
(Chrome uses 256 KiB). pion enforces the remote's advertised value on send, so
a single realtime event larger than it cannot be sent over the "oai-events"
data channel: SendText fails, the event is dropped, and the turn silently
yields no response. Some turns legitimately produce a >256 KiB JSON event —
notably tool calls with sizeable schemas or results.

Browsers advertise the value conservatively but their SCTP stacks reassemble
much larger messages, so raise the max-message-size honored for our own
server-generated events by rewriting the attribute in the offer before
SetRemoteDescription.

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

* fix(realtime): keep the WebRTC sendLoop alive when one event send fails

A failed SendText on the oai-events data channel exited the sender goroutine,
so a single dropped event (e.g. one over the negotiated SCTP max-message-size)
tore down the session and silently dropped every subsequent event. Log and skip
the offending event instead and keep draining; a genuinely dead transport is
still handled by the closed / connection-state path.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-19 21:36:25 +02:00
LocalAI [bot]
2e734bf560 fix(downloader): stall timeout, resume-safe cancel, and stale-partial reaping (#10406)
* fix(downloader): stall timeout, resume-safe cancel, and stale-partial reaping

Large model installs would hang forever or never finish. Three defects in
the HTTP download path, all hit by big GGUF pulls over a slow or flaky link:

1. No stall timeout. The shared download client sets no body deadline
   (correct for streaming) but also no read-idle timeout, and the
   transport's IdleConnTimeout does not cover an in-flight body read. A
   silently-dropped TCP connection (no FIN/RST) blocked the body Read
   forever, freezing an install at N bytes until an external reaper killed
   it. Add an idle-timeout reader that closes the body after a window of
   zero progress (DownloadStallTimeout, default 60s), turning an indefinite
   hang into a fast, retryable error. A read that returns data resets the
   clock, so a slow-but-steady transfer is unaffected.

2. Cancellation deleted the partial. On context.Canceled the code removed
   the .partial file, so any frontend restart (deploy, OOM) mid-download
   wiped all progress and the retry restarted from zero. At slow egress,
   files larger than the restart interval never completed. Keep the
   .partial on cancel so the next attempt resumes via Range.

3. Partials leaked. Cleanup only ran on the context-cancel path, never on a
   stall or a SIGKILL/OOM, so abandoned .partial files accumulated and could
   fill the models volume. Add CleanupStalePartialFiles and reap partials
   older than 24h on startup.

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

* fix(downloader): discard the .partial on a deliberate user cancel

Review follow-up. The previous commit kept the .partial on every cancellation
so restarts could resume, but that also left a dangling partial when a user
*intentionally* cancelled an install — the file lingered until the 24h reaper.

Distinguish the two: cancel the gallery operation's context with a cause
(downloader.ErrUserCancelled) so the download layer can tell a deliberate
abort (discard the partial) from an incidental one such as a shutdown/restart
(keep it for resume). Detect cancellation via the context rather than the
returned error, because an HTTP request cancelled with a cause surfaces the
cause error, not context.Canceled.

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

* fix(downloader): resolve gosec G122 in CleanupStalePartialFiles

CI's code-scanning (gosec) flagged G122 (symlink TOCTOU) for the os.Remove
call inside the filepath.WalkDir callback. Collect the stale paths during the
walk and delete them afterwards instead of mutating the tree from inside the
callback. Behavior is unchanged; the existing specs still pass.

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-19 21:35:21 +02:00
番茄摔成番茄酱
72d46c1115 feat(crispasr): add word-level timestamp support (#10403)
* feat(crispasr): add word-level timestamp support

Add word-level timestamp extraction to the crispasr backend by calling
the CrispASR C library's word accessor functions that are already
exported by libgocraspasr but were not previously bound by the Go
wrapper.

Two families of word functions are supported:

1. Session-based (get_word_count/text/t0/t1) — works per-segment for
   whisper-like backends.
2. Parakeet-specific (get_parakeet_word_count/text/t0/t1) — returns a
   global word list for TDT/CTC/RNNT parakeet models where the session
   API does not expose per-segment word data.

The Go code tries session-based first and falls back to parakeet-specific
when the session word count is zero.

Depends on #10402 (grpc server Words forwarding) for the words to reach
the HTTP response.

Signed-off-by: fqscfqj <fqscfqj@outlook.com>

* fix(crispasr): use portable sed -i.bak for macOS compatibility

BSD sed requires -i '' for in-place editing while GNU sed uses -i.
Replace with -i.bak which works on both platforms, then remove the
backup file.

Signed-off-by: fqscfqj <fqscfqj@outlook.com>

---------

Signed-off-by: fqscfqj <fqscfqj@outlook.com>
2026-06-19 21:34:30 +02:00
Richard Palethorpe
606128e4e9 feat(vulkan): make Vulkan backends self-contained on the GPU (#10404)
Vulkan backends bundled their own loader and ICD manifests but neither the
Mesa driver the manifests point at nor a way to make the loader find them,
so on a runtime base image without Mesa the loader enumerated zero devices
and the GPU silently fell back to CPU (only NVIDIA worked, since its ICD is
injected by the container toolkit).

- scripts/build/package-gpu-libs.sh: for each installed ICD manifest, bundle
  the driver .so its library_path names — no hard-coded, platform-dependent
  soname list — plus that driver's ldd dependencies, skipping manifests whose
  driver isn't installed. Rewrite each library_path to a bare soname so the
  bundled driver resolves via the LD_LIBRARY_PATH run.sh already sets.
- .docker/install-base-deps.sh, backend/Dockerfile.golang,
  backend/Dockerfile.python: install mesa-vulkan-drivers in every Vulkan
  builder so the driver + manifests exist to be packaged (the LunarG SDK
  ships only the loader and shader tooling).
- pkg/model/process.go: when a backend ships vulkan/icd.d/, point the loader
  at it via VK_DRIVER_FILES/VK_ICD_FILENAMES at launch (no-op otherwise).
  Covered by pkg/model/process_vulkan_test.go.
- backend/go/parakeet-cpp/package.sh: complete the L0 stub (was missing the
  libc-family ldd walk + GPU-lib packaging) by mirroring whisper, so the
  vulkan-parakeet image actually bundles its GPU runtime.

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

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-06-19 17:16:33 +02:00
Souheab
59c7ad5153 fix(nix flake): ensure nix flake builds successfully (#10399)
* Use inference defaults in repo src rather than fetching

there are inference_defaults.json already in the repo so we can use
those, they are regularly updated with github actions, and we avoid hash
mismatch errors in the flake this way

Signed-off-by: Souheab <souheab@protonmail.com>

* Update vendor hash

Signed-off-by: Souheab <souheab@protonmail.com>

* Create react-ui derivation as it is required for go build

Signed-off-by: Souheab <souheab@protonmail.com>

* Add FHS env wrapper to make #!/bin/bash scripts work

Signed-off-by: Souheab <souheab@protonmail.com>

* use pkgs.importNpmLock to deal with npm dependencies instead of using npmDepsHash

Signed-off-by: Souheab <souheab@protonmail.com>

---------

Signed-off-by: Souheab <souheab@protonmail.com>
2026-06-19 17:15:18 +02:00
番茄摔成番茄酱
78d682224a fix(grpc): forward word-level timestamps in AudioTranscription wrapper (#10402)
The gRPC server wrapper in pkg/grpc/server.go reconstructs
TranscriptSegment messages when relaying AudioTranscription results
from backends. The Words field was not being copied, causing all
word-level timestamps to be silently dropped regardless of backend
support.

This was introduced when PR #9621 added the TranscriptWord proto
message and transcriptResultFromProto (server-side), but did not
update the server-side gRPC relay to forward the new field.

Fixes #9306

Signed-off-by: fqscfqj <fqscfqj@outlook.com>
2026-06-19 14:59:50 +02:00
LocalAI [bot]
29dbba7a25 feat(ui): editorial overhaul ops/admin data-viz, sortable tables, mobile reflow, unsaved-changes guards (#10398)
* feat(ui): legible Usage charts - distinct prompt/completion hues + chart a11y

Prompt and completion were the same color (primary at 0.35 opacity), so the
stacked token charts read as one blurry blob. Completion now uses a distinct
data-viz hue (--color-data-3) at full opacity across the time chart, the
per-model distribution bars, and the tooltip. The source-mix chart is no longer
aria-hidden: it exposes role="img" with a label.

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

* feat(ui): sortable Users table

The admin Users table is now sortable by name, email, provider, role, status,
and created date - clickable headers with an aria-sort state, a direction
caret, and keyboard activation (Enter/Space). Permissions and Actions stay
non-sortable.

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

* feat(ui): unsaved-changes guard on Settings and Agent create/edit

Add a reusable UnsavedChangesGuard (router useBlocker + beforeunload) that
prompts before navigating away or closing the tab with unsaved edits. Wired to
Settings (existing isDirty) and AgentCreate (snapshot the loaded form, compare;
suppressed while saving so the post-save redirect is not blocked). Adds the
common.unsaved i18n keys.

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

* feat(ui): sortable Traces tables

Both trace tables are now sortable: the API table by method/path/status and the
backend table by type/time/model/duration, with aria-sort, a direction caret,
and keyboard activation. Sort and the expanded row reset when switching tabs
(the two tables have different columns).

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

* feat(ui): responsive table reflow (cards on mobile), applied to Users

Dense admin tables sideways-scroll on phones. Add a reusable ResponsiveTable
that mirrors the <thead> labels onto each body cell (data-label) and a
<=640px stylesheet that stacks rows into label/value cards. Wired to both
Users tables; reusable for the other dense tables next.

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

* feat(ui): roll responsive table reflow to Traces, Models, Manage, Nodes

Apply ResponsiveTable to the remaining dense tables so they stack into
label/value cards on phones instead of scrolling sideways. Harden the
component for these tables: scope label-mirroring and the card CSS to direct
children (nested detail tables render normally), override inline min-width on
mobile, and pass through table/container inline styles. Nested expansion
tables in Nodes/Models/Manage are intentionally left as-is.

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

* feat(ui): unsaved-changes guard on the Fine-Tuning form

Editing the long fine-tune job form and navigating away silently discarded
everything. Snapshot the assembled getFormConfig() as a baseline, treat the
open form as dirty when it diverges, and reuse UnsavedChangesGuard to prompt
before leaving. The baseline is rebased after a job is submitted so leaving
afterward does not warn.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-19 00:56:17 +02:00
LocalAI [bot]
4ad754eea3 chore: ⬆️ Update ikawrakow/ik_llama.cpp to b3dfb7858cfcb9166e92f366e5af87f19ebc94be (#10395)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-19 00:03:37 +02:00
LocalAI [bot]
67692cb984 chore(model-gallery): ⬆️ update checksum (#10397)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-19 00:03:10 +02:00
LocalAI [bot]
f68edfc85f feat(ui): editorial UI/UX overhaul - design language, shell/nav, conversation/canvas, sub-menus (#10390)
* feat(ui): add Fraunces variable serif + --font-serif token

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

* feat(ui): serif display tier + section-heading typography scale

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

* feat(ui): un-overload accent — nav rail, stronger focus ring, neutral hover

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

* feat(ui): orchestrated page reveal + stagger motion primitives

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

* refactor(ui): fix dead token refs + dedupe toggle to one primitive

Migrate all .toggle-slider consumers (Users, Chat, AgentChat) to the
canonical BEM toggle primitive and delete the legacy duplicate CSS block.

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

* refactor(ui): route boot fallback through the LoadingSpinner primitive

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

* feat(ui): EmptyState primitive with serif title

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

* feat(ui): Skeleton shimmer primitive

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

* feat(ui): PageHeader + SectionHeading editorial primitives

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

* feat(ui): StatusPill primitive + time-of-day greeting helper

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

* feat(ui): Home editorial header + status line (north-star redesign)

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

* feat(ui): Home loaded-models skeleton list, button hierarchy, EmptyState wizard

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

* fix(ui): single focus ring (no double-ring) + neutralize stagger delay under reduced motion

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

* refactor(ui): all-sans editorial headings + tint-only active nav

Per design review, pivot the heading strategy from hybrid-serif to a
refined grotesk: drop the Fraunces dependency, token, and import; page
titles, the Home greeting, and section/empty-state titles now use Geist
at semibold with the editorial fluid sizing and tight tracking. No serif
anywhere.

Active sidebar item is now a tint-only treatment (accent text + tinted
background); the left accent rail is removed and the shared base
.nav-item.active inset bar is suppressed in the sidebar (as the console
rail already does). Update the design-system e2e specs to assert the
sans display font and the tinted-background active state.

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

* test(e2e): add --host flag to ui-test-server

Allow binding the e2e/preview server to an arbitrary address (e.g.
0.0.0.0 to review the UI from another device on the LAN). Defaults to
127.0.0.1 so existing e2e behavior is unchanged.

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

* refactor(ui): declutter Home - discoverable + dismissable API, vertical balance

Home felt overloaded and top-heavy. Three changes from review:
- The API endpoint catalog (12 endpoints) is collapsed by default behind a
  "Browse the API" disclosure; only the base URL + copy stay visible, so the
  catalog is discoverable without dominating the page.
- The whole connect card is dismissable (x): dismissing unmounts it so the
  vertical space is recovered, and the choice is remembered (localStorage).
- .home-page now fills its column and vertically centers its content when
  there is slack, so sparse states (no models / card dismissed) read as a
  balanced launcher instead of content jammed at the top. Overflow-safe:
  tall content flows from the top and scrolls.

Adds connect.browse / connect.hide / connect.dismiss i18n keys to all locales.

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

* feat(ui): editorial PageHeader with section eyebrow + scroll-to-top on nav

PageHeader now derives its eyebrow from the route's section/console (Build /
Operate / Create) via sectionKeyForPath, so pages get a consistent, meaningful
eyebrow with no per-page wiring (override with the eyebrow prop, suppress with
eyebrow={null}). Settings adopts it as the first consumer.

Also fix a navigation scroll bug: the default layout uses the document as its
scroll container and route changes did not reset it, so navigating the console
rail from a scrolled page landed mid-view. App now scrolls to top on pathname
change.

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

* refactor(ui): adopt PageHeader on agent/media/import/backend pages (batch A)

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

* refactor(ui): adopt PageHeader on ops/admin/media pages (batch B)

Replace hand-rolled .page-header title blocks with the shared editorial
PageHeader component across 14 pages (Manage, Middleware, Models,
NodeBackendLogs, Nodes, P2P, SkillEdit, Skills, Sound, Traces, TTS, Usage,
Users, VideoGen). Title/subtitle move into PageHeader; header-own action
clusters (Models stats+buttons, Skills search+buttons) move into the actions
slot. Tabs, filters, stat cards, ResourceMonitor and page body stay as
siblings. Eyebrow is left to auto-derive from the route.

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

* test(ui): home greeting asserts sans font, not the dropped serif

The greeting render-smoke still asserted Fraunces; update it to assert the
Geist sans display font (and not Fraunces), matching the all-sans direction.

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

* feat(ui): ThemeToggle i18n + animated icon, drop transition:all

The theme toggle hard-coded its English tooltip; route it through the existing
nav switchToLightMode/switchToDarkMode keys and add an aria-label. The sun/moon
icon now replays a small rotate+fade on theme change (keyed remount; honored by
the global reduced-motion block). Replace the .theme-toggle `transition: all`
with explicit properties.

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

* feat(ui): canvas drag-to-resize + slide-in, fix hooks order, typed download

Canvas was a fixed pane; make it a workbench:
- Drag the panel's left edge to resize (clamped 360px..75vw), persisted to
  localStorage, double-click to reset; hidden and full-width on narrow screens.
- Slide-in/fade on open via canvasSlideIn (honored by reduced-motion).
- Fix a rules-of-hooks bug: the `if (!current) return null` early return sat
  above useEffect, so the hook count changed when artifacts emptied. All hooks
  now run unconditionally before the guard.
- Downloads use the artifact language's real extension + MIME (a Python
  artifact saves as .py, not .txt) via extensionForLanguage.

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

* feat(ui): per-message code blocks get a language header + copy button

Chat code blocks now render inside a framed block with a header showing the
language and a copy button (delegated handler, copies the block and flips to a
check briefly). Decoration + highlighting run from a MutationObserver scoped to
the messages container, which fires reliably for streamed responses AND for
chats loaded/switched from storage - the prior render-keyed effect missed the
load path (code was left unhighlighted on reload). The observer disconnects
while mutating so it does not retrigger on its own edits.

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

* feat(ui): image attachments show a thumbnail in the composer

Staged image attachments now preview as a 28px thumbnail (from their data URL)
instead of a bare file icon; other types keep the icon. File names truncate and
the remove button gets an aria-label.

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

* feat(ui): jump-to-latest pill when scrolled up in chat

When the user scrolls away from the bottom of a conversation, a floating
"Jump to latest" pill appears (sticky, centered above the composer); clicking
it smooth-scrolls to the newest message and re-pins auto-scroll. Resets on
chat switch. Adds the chat.actions.jumpToLatest i18n key to all locales.

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

* feat(ui): canvas fullscreen toggle + keyboard tab navigation

The canvas header gains a fullscreen toggle (expands the panel to cover the
viewport; resize handle hidden while fullscreen). The artifact tab strip is now
a proper ARIA tablist with roving tabindex and Left/Right arrow-key navigation.

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

* feat(ui): image result lightbox (zoom, prev/next, download, keyboard)

Generated/history images on the Image page are now clickable, opening a
fullscreen Lightbox with a download button, prev/next navigation, an N/M
counter, and keyboard control (Esc to close, Left/Right to navigate). Adds a
reusable `Lightbox` component (usable later for Video) and the media.image
.actions.view i18n key.

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

* feat(ui): generation progress with placeholder tiles + elapsed timer

Image generation replaces the bare spinner with a GenerationProgress scaffold:
shimmer placeholder tiles matching the requested count plus a live elapsed-time
readout, so the (often slow) wait feels accountable. Reusable for the other
media generation pages.

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

* feat(ui): generation progress on Video, TTS, and Sound pages

Reuse GenerationProgress (placeholder tile + elapsed timer) in place of the
bare spinner on the remaining media generation pages, so every slow generation
gives the same accountable feedback.

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

* feat(ui): agent chat gets per-message code-copy + reliable highlighting

AgentChat now shares Chat's code-block treatment: it runs highlightAll +
enhanceCodeBlocks from a MutationObserver on its messages container (the same
proven path), so agent responses get language headers, copy buttons, and
highlighting that fires for both streamed and loaded messages - closing the
divergence with the main chat without a large refactor.

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

* feat(ui): Talk voice visualizer

Add a hero frequency-bar visualizer at the top of the Talk page so users get
ambient feedback that they are heard and that the assistant is speaking - the
audit's main Talk gap (the only prior feedback was a small status pill; the
waveform was buried in the dev diagnostics panel).

VoiceVisualizer is self-contained: it builds its own AudioContext + analysers
from the output <audio> stream (speaking) and the mic stream (listening) so it
does not touch the existing WebRTC/diagnostics graph. Bars are status-tinted
(idle/connected/listening/speaking/error) and animate with a gentle idle wave
when not connected. Live mic/output animation is exercised on a real session.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-18 23:03:27 +02:00
Tai An
c3b3336654 fix(whisperx): use whisperx.diarize.DiarizationPipeline with token kwarg (#10389)
Signed-off-by: Anai-Guo <antai12232931@outlook.com>
2026-06-18 18:50:37 +02:00
LocalAI [bot]
c4cd86bb15 chore: bump localrecall to fix PostgreSQL collection name with ':' (#10375) (#10387)
chore: bump localrecall to include PostgreSQL table-name sanitization fix

Pulls mudler/localrecall#48, which makes sanitizeTableName allowlist valid
identifier characters so collection names containing ':' (e.g. the per-user
"legacy-api-key:<agent>" namespace) no longer break PostgreSQL CREATE TABLE
with "syntax error at or near ':'".

Fixes #10375

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-18 17:05:52 +02:00
LocalAI [bot]
13f59f0822 docs: document the privacy-filter.cpp backend (#10386)
docs: document the privacy-filter.cpp backend in README and compatibility table

The privacy-filter.cpp backend (#10360) was registered in backend/index.yaml
and referenced from the PII feature docs, but was missing from the backend
catalog surfaces. Add it to the README "Backends built by us" table, the
compatibility table (Utilities & Other, CPU/CUDA 13/Vulkan), and the backend
type list in the backends feature doc.

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-18 15:07:01 +02:00
Richard Palethorpe
3fa7b2955c feat(pii): NER tier engine — privacy-filter.cpp backend + NER-centric PII filter (#10360)
Squashed feat/pii-ner-tier-engine rebased onto master (was 45 commits; see
backup/pii-ner-tier-engine-prerebase). Net change:

- privacy-filter.cpp: standalone GGML engine for the openai-privacy-filter
  PII/NER token classifier, wired as a LocalAI gRPC backend (CPU/CUDA/Vulkan).
  TokenClassify moves off the patched llama.cpp path onto this backend.
- PII filter reworked to be NER-centric (encoder/NER detection tier scanning
  whole conversations as one document), with a recreated bounded restricted-
  regex secret-matching pattern detector tier alongside it (per-model
  pii_detection.builtins / .patterns + core/services/routing/piipattern).
- Detection labelled by source (ner vs pattern); backend trace / confidence /
  debug observability; analyze/redact exposed as a synchronous API.
- Instance-wide default detector policy + per-usecase default-on; request
  filtering extended to completions, embeddings, edits & Ollama.
- React UI: NER-centric PII editor, detector-models table, pattern/builtins
  editor, middleware default-policy UI.
- Gallery: privacy-filter-multilingual token-classify model + NER install
  filter; token_classify known_usecase; batch sized to context for NER models.
  privacy-filter backend registered in the backend gallery (cpu/vulkan/cuda-13
  meta + image entries with a capabilities map) matching its CI matrix jobs,
  and an /import-model auto-detect importer (PrivacyFilterImporter, narrow
  privacy-filter GGUF detection) replacing the prior pref-only registration.

Reconciled against master's independent evolution:

- Dropped master's PIIPatternOverrides feature (global-pattern runtime
  overrides + /api/pii/patterns API + runtime_settings.json persistence). The
  per-model NER + pattern-detector design supersedes it; it was built on the
  global redactor pattern set this branch replaced.
- Reverted the llama.cpp Score carry-patch (0006-server-task-type-score):
  removed the patch and restored master's grpc-server.cpp Score RPC (direct
  llama_decode, slot-loop bypass) and LLAMA_VERSION pin, plus master's
  model_config validation forbidding score + chat/completion/embeddings on
  llama-cpp. token_classify is unaffected (it runs on the privacy-filter
  backend, not llama-cpp).

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

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-06-18 11:45:22 +01:00
LocalAI [bot]
c133ca39dc chore: ⬆️ Update ggml-org/llama.cpp to f3e182816421c648188b5eab269853bf1531d950 (#10379)
⬆️ 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-18 11:43:23 +02:00
LocalAI [bot]
757822cd74 chore(model gallery): 🤖 add 1 new models via gallery agent (#10384)
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-18 08:51:30 +02:00
LocalAI [bot]
91f97f2a54 chore: ⬆️ Update ggml-org/whisper.cpp to 86c40c3bd6fc86f1187fb751d111b49e0fc18e84 (#10382)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-18 08:34:43 +02:00
LocalAI [bot]
55f9ff6805 chore: ⬆️ Update mudler/parakeet.cpp to 92a5f0306be354c109150fe58ae4cc4f8a21ca45 (#10380)
⬆️ 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-18 08:32:13 +02:00
LocalAI [bot]
88726f2da4 fix(react-ui): restore sidebar collapse in dev + stop Talk page auto-scroll (#10383)
The sidebar collapse toggle silently no-op'd in dev builds. toggleCollapse
ran its side effects (localStorage write + sidebar-collapse dispatch) inside
the setCollapsed updater. StrictMode double-invokes updaters in dev to surface
impurity, and the synchronous dispatch re-entered setState from the
App/Sidebar listeners mid-update, so the toggle never committed. Production
builds don't double-invoke, which is why only the dev server was affected.
Compute next from current state and move the persist + broadcast into the
handler body so the updater is pure.

Also fix the Talk page anchoring to the transcript box on load. The transcript
is its own overflow container, but scrollIntoView bubbles to every scrollable
ancestor including the window, yanking the whole page down on mount. Scroll
the transcript container directly instead.


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-18 00:48:56 +02:00
LocalAI [bot]
5c2ae7857a chore: ⬆️ Update antirez/ds4 to 80ebbc396aee40eedc1d829222f3362d10fa4c6c (#10378)
⬆️ 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-18 00:32:13 +02:00
LocalAI [bot]
4af360300f chore: ⬆️ Update ikawrakow/ik_llama.cpp to 71af16a6b7f6fb7315b346b4a51aad530599c3f5 (#10381)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-18 00:12:25 +02:00
LocalAI [bot]
5ac864dbed feat(ui): console-based navigation + drop-in API endpoint section (#10377)
* feat(ui): restructure sidebar into Create/Recognition/Build tiers

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

* fix(ui): preserve exact sidebar gating for agent items and fine-tune/quantize

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

* i18n(ui): add nav tier + console keys to all locales

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

* feat(ui): add grouped admin console via pathless layout route

Wrap the existing admin pages in a pathless AdminConsoleLayout route so
they keep their exact flat URLs while gaining a grouped left rail
(Inference / Cluster / Observability / Access / System). Rail item gating
mirrors the sidebar (adminOnly / authOnly / feature + /api/features). The
layout forwards the App-level outlet context (addToast) to the wrapped
pages, which read it via useOutletContext().

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

* feat(ui): fold Audio Transform into Studio as a tab

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

* test(ui): update e2e specs for tiered nav + admin console

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

* fix(ui): gate embedded Studio transform view on audio_transform feature

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

* feat(ui): visual polish + console-ize Build/Recognition tiers

Generalize the one-off admin console into a reusable ConsoleLayout driven by
a shared consoleConfig (single source of truth for the rail, its gating, and
the sidebar entry that opens it — removes the prior rail/sidebar drift).

- Promote Install Models to the top menu next to Home.
- Build and Operate are now console tiers (secondary rail); Create stays inline.
- Fold Recognition (Faces/Voices) into the Build console as a group alongside
  Automation and Training so it no longer feels split off.
- Style the console rail as a panel (header, grouped dividers, rounded active
  pills) with a hover nudge; sidebar items become inset rounded pills. The rail
  slide-in plays only when entering a console, not on item-to-item sub-nav
  (which remounts the layout), so switching no longer flashes the menu. All
  token-based (light + dark), respects reduced-motion.
- Add a delayed RouteFallback loader so lazy routes no longer flash blank;
  scoped inside ConsoleLayout so the rail stays put while the body loads.
- Update e2e specs for the new structure (.console-* classes, console entries).

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

* feat(ui): persist console layout across sub-nav + add drop-in endpoint section

- Keep the page-transition key stable within a console (derived from the
  shared console config) so the ConsoleLayout and its rail persist across
  item-to-item navigation instead of remounting — fixes the submenu flash.
  Cache /api/features across mounts and play the rail entrance animation only
  when actually entering a console.
- Add a "One endpoint, every API" section to Home: leads with LocalAI's own
  native API (images, video, realtime voice over WebRTC/WS, depth, object
  detection, rerank, audio/TTS, face & voice recognition) plus a Full API
  reference link, then the drop-in compatibility layer (OpenAI, Anthropic,
  Ollama, OpenAI Responses) with the live copyable base URL. All 7 locales.

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

* fix(ui): revert Middleware nav label rename (keep Middleware in all locales)

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-18 00:09:17 +02:00
LocalAI [bot]
9b57dcb721 docs: document all available backends and add "built by us" list (#10376)
Bring the Backend & Model Compatibility Table up to the full set of
backends published in backend/index.yaml (60+), organized by modality
with per-backend acceleration targets. Add an "Available Backends"
pointer and expand the backend-type list in the backends feature doc.

Update the README backend count to 60+ and add a "Backends built by us"
section listing the native C/C++/GGML engines maintained by the LocalAI
project (parakeet.cpp, voxtral.c, vibevoice.cpp, rf-detr.cpp,
locate-anything.cpp, depth-anything.cpp, LocalVQE, local-store).


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-17 20:39:09 +02:00
LocalAI [bot]
95e7149c87 chore: ⬆️ Update ggml-org/llama.cpp to 74ade52741203e5c8f81eaf06a96cb1cfe15f2a3 (#10368)
⬆️ 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-17 13:25:29 +02:00
LocalAI [bot]
fd26c8c753 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 064d23a6f816d50491d8c9b35a0cafe546eaf4b5 (#10367)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-17 13:25:14 +02:00
LocalAI [bot]
e60c094a7d feat(ds4): SSD streaming + quality engine options, 128GB DeepSeek gallery models (#10374)
feat(ds4): wire SSD streaming + quality engine options, add 128GB DeepSeek gallery models

The ds4 backend zero-initialized ds4_engine_options and exposed none of the
engine's tunable knobs, so SSD streaming (run a model larger than RAM by
streaming routed MoE experts from the GGUF on SSD) and the quality/perf knobs
were unreachable from LocalAI model YAMLs.

Map ModelOptions.Options onto ds4_engine_options through a declarative table
(kEngineOptSpecs + apply_engine_option) instead of per-field branches: the
struct is fixed C with no reflection, so the field set is enumerated once and a
future knob is a one-line table row. Two fields use ds4's own typed parsers
(GiB budgets, cache-experts count-or-NGB). Bare flags (e.g. "ssd_streaming")
mean true; path-type options (mtp_path, expert_profile_path,
directional_steering_file) resolve relative to the model directory so a gallery
entry can reference a companion file by bare filename. mtp_draft/mtp_margin are
now validated rather than parsed with throwing std::stoi/std::stof.

Add gallery entries for the 128 GB class:
- deepseek-v4-flash-q2-q4 (~91 GB, mixed q2/q4, fits RAM, higher quality)
- deepseek-v4-flash-q4-ssd (~153 GB full 4-bit, runs on 128 GB via SSD streaming)
- deepseek-v4-flash-q2-mtp (~81 GB + MTP speculative draft weights)
- deepseek-v4-pro-q2-ssd (~433 GB Pro, experimental SSD streaming)

SSD streaming is Metal (Darwin) only; the options are inert on CUDA/CPU.


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-17 10:30:06 +02:00
LocalAI [bot]
159df8e2ef feat(swagger): update swagger (#10365)
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-17 09:32:17 +02:00
LocalAI [bot]
de299ca101 chore(model-gallery): ⬆️ update checksum (#10371)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-17 09:28:47 +02:00
LocalAI [bot]
980ec4a311 chore: ⬆️ Update antirez/ds4 to cafc134f78a5a1890d98808d3102f4313573a1bc (#10369)
⬆️ 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-17 09:28:19 +02:00
LocalAI [bot]
dfd5a00e6f chore: ⬆️ Update ggml-org/whisper.cpp to 9efddafb9153e1fb22bdc3dd3057072c99165ed2 (#10366)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-17 09:27:52 +02:00
LocalAI [bot]
63be479066 chore: ⬆️ Update leejet/stable-diffusion.cpp to 7f0e728b7d42f2490dfa5dd9539082d904f2f6b2 (#10370)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-17 09:08:34 +02:00
LocalAI [bot]
4c6750fe6b feat(depth): metric-large + nested metric model gallery entries (#10363)
* feat(depth): add depth-anything-3-metric-large gallery entry

DA3METRIC-LARGE (ViT-L) single-file metric-scale depth + sky, served by the
existing depth-anything backend (same single-GGUF path as mono-large). GGUF
published at mudler/depth-anything.cpp-gguf.

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

* feat(depth): serve nested metric model (two-file load)

The DA3 nested model needs both branches (anyview GIANT + metric ViT-L) loaded
together. Wire it through the backend:
- Load reads a 'metric_model:<file>' entry from ModelOptions.Options and, when
  present, calls da_capi_load_nested(anyview, metric) instead of da_capi_load
  (registers the new abi-4 symbol; helper optionValue + unit test).
- gallery: depth-anything-3-nested (model=anyview, options=metric branch, both
  GGUFs fetched) for metric-scale depth + pose.
- bump depth-anything.cpp pin to cce5edc (abi 4 / da_capi_load_nested).

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-16 22:03:58 +02:00
LocalAI [bot]
a6e1c6d0b3 fix(docs): use relearn notice shortcode instead of unsupported alert (#10364)
The Hugo relearn theme does not provide an "alert" shortcode, so the
docs deploy failed at the Build site step:

  failed to extract shortcode: template for shortcode "alert" not found
  docs/content/features/image-generation.md:106

Convert the vae_decode_only note to the theme-supported notice shortcode
used everywhere else in the docs.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-16 21:06:20 +02:00
LocalAI [bot]
294170d3ed feat(backend): add depth-anything (Depth Anything 3) C++/ggml backend + gallery (#10352)
* feat(backend): add depth-anything (Depth Anything 3) C++/ggml backend + gallery

Mirrors the locate-anything-cpp backend to register a new depth-anything
backend that wraps the Depth Anything 3 ggml port (depth-anything.cpp) via
purego (cgo-less, no Python at inference).

- backend/go/depth-anything-cpp/: gRPC backend (Load + Predict + GenerateImage),
  purego binding to the da_capi_* C ABI, CMake/Makefile/run/package/test scripts
  building depth-anything.cpp's DA_SHARED static .so per CPU variant.
- backend/index.yaml: depth-anything backend meta + all hardware-variant
  capability entries (cpu/cuda12/cuda13/intel-sycl-f32+f16/vulkan/nvidia-l4t).
- gallery/index.yaml: 8 Depth Anything 3 GGUF models (base q4_k/q8_0/f16/f32,
  small, large, giant, mono-large).
- .github/backend-matrix.yml: one build entry per hardware variant.

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

* feat(depth): typed Depth RPC + REST endpoint exposing full DA3 data

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

* fix(depth): pin depth-anything.cpp to e0b6814 (ABI 3 dense C-API)

The Depth RPC handler calls da_capi_depth_dense / da_capi_points (C-API ABI 3);
pin the native build to the commit that exports them.

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

* fix(depth): pin depth-anything.cpp to v0.1.0 release (b515c31)

Repoint the native version from the now-orphaned e0b6814 to the
b515c31 release commit, kept alive by the upstream v0.1.0 tag.
C-API is unchanged (da_capi_abi_version == 3).

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

* fix(depth): wire depth-anything-cpp into build, CI bump, and importer

The backend dir, gallery index, and CI build-matrix were present but the
backend was never wired into the integration points that adding-backends.md
requires:

- root Makefile: add to .NOTPARALLEL, the test-extra chain, a BACKEND_*
  definition, the docker-build target eval, and docker-build-backends
  (mirrors parakeet-cpp; the backend's own Makefile already documented that
  its `test` target is driven by test-extra).
- bump_deps.yaml: register the DEPTHANYTHING_VERSION pin so the daily
  auto-bump bot tracks mudler/depth-anything.cpp master (it cannot see an
  unregistered Makefile pin).
- import form: add a preference-only KnownBackend entry so depth-anything is
  selectable at /import-model (mirrors sam3-cpp; no reliable GGUF auto-detect
  signal, so pref-only per the doc's default).

changed-backends.js needs no entry: the generic golang suffix branch already
resolves backend/go/depth-anything-cpp/.

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

* feat(depth): auto-detect importer for depth-anything GGUFs

Replace the preference-only entry with a real auto-detect importer
(mirrors parakeet-cpp / locate-anything):

- DepthAnythingImporter matches a .gguf whose name carries a
  depth-anything token (depth-anything-<size>-<quant>.gguf), so
  /import-model recognises mudler/depth-anything.cpp-gguf repos and direct
  GGUF URLs without an explicit backend preference. preferences.backend=
  "depth-anything" still forces it.
- Registered before LlamaCPPImporter so its GGUF bundles aren't claimed by
  the generic .gguf importer; the narrow name match means it cannot claim
  arbitrary llama GGUFs or the upstream safetensors PyTorch repos.
- Multi-quant repos pick the smallest quant by default (q4_k -> ... -> f32,
  depth stays >0.998 corr even at q4_k); quantizations preference overrides.
- Drops the now-redundant knownPrefOnlyBackends entry (importer-backed
  backends are not listed there, matching parakeet-cpp).
- Table-driven Ginkgo test covers detection, negative cases (llama GGUF,
  upstream safetensors), default/override/fallback quant pick, and direct
  URL import. 10/10 specs pass.

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

* fix(depth): check conn.Close error in grpc Depth client (errcheck)

The new Depth() client method used a bare `defer conn.Close()`. golangci-lint
runs with new-from-merge-base, so although the 39 sibling methods use the same
bare form (grandfathered), the newly added line trips errcheck. Drop the result
explicitly to satisfy the linter.

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

* fix(depth): bump depth-anything.cpp to v0.1.1 (embeddable CMake)

v0.1.0 (b515c31) used ${CMAKE_SOURCE_DIR} for its include dirs, which
points at the parent project when built via add_subdirectory() as this
backend does, so the container build failed with missing stb_image.h /
da_gguf_keys.h. v0.1.1 (2d42897) switches to project-relative paths.

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

* fix(depth): resolve gosec findings in the backend wrapper

The code-scanning gate flagged three new failure-level alerts in
godepthanythingcpp.go (gosec runs with -no-fail; GitHub gates on new alerts):

- G301: export dirs were created with 0o755. Tighten to 0o750 (no world
  access needed for backend-written export output).
- G304: writeDepthPNG creates req.GetDst(). That path is chosen by the
  LocalAI core as the intended output destination (same pattern every
  image backend uses), not attacker input, so annotate with #nosec G304
  and document why.

The remaining G103 "audit unsafe" notes on the unsafe.Slice C-buffer copies
are warning-level (the same purego interop whisper/parakeet use) and do not
gate the check, per the supertonic exclusion precedent in secscan.yaml.

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

* fix(depth): bump depth-anything.cpp to v0.1.2 (CUDA cross-build arch)

v0.1.1 forced CMAKE_CUDA_ARCHITECTURES=native, which breaks the GPU-less
l4t/cublas CI builds (nvcc "Unsupported gpu architecture 'compute_'" on
CMake 3.22). v0.1.2 (442eea4) drops the override and lets ggml pick its
default cross-build arch list.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-16 16:28:28 +02:00
LocalAI [bot]
1ab61a0875 feat: generic chat_template_kwargs (model config + per-request metadata) (#10359)
* feat(config): add chat_template_kwargs model field + resolver

Adds the ChatTemplateKwargs model-config map and RequestMetadata carrier,
plus ResolveChatTemplateKwargs which layers the config map under coerced
request metadata. Foundation for generic jinja chat-template kwargs (issue #10329).

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

* feat(backend): forward resolved chat_template_kwargs blob to backends

gRPCPredictOpts now merges per-request client metadata over the server-derived
enable_thinking/reasoning_effort (reaching all backends via the standalone keys)
and serialises the resolved chat_template_kwargs map into a JSON blob for
llama.cpp, written last so a client cannot clobber it. Issue #10329.

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

* feat(http): wire request metadata to config.RequestMetadata

The OpenAI request metadata field was parsed but unused; stamp it onto the
per-request ModelConfig so gRPCPredictOpts forwards it as chat_template_kwargs
overrides. Issue #10329.

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

* feat(llama-cpp): generic chat_template_kwargs merge (drop per-key blocks)

Replace the per-key enable_thinking/reasoning_effort handling in both the
streaming and non-streaming chat paths with a single block that parses the
chat_template_kwargs JSON blob resolved by the Go layer and merges every key
into body_json. New jinja template levers (e.g. preserve_thinking) now need
no C++ change. Issue #10329.

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

* docs: document custom chat_template_kwargs (model + per-request)

Issue #10329.

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

* test(backend): pin reasoning_effort as a string in the chat_template_kwargs blob

Issue #10329.

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

* test(http): e2e guard pinning chat_template_kwargs forwarded to gRPC

Adds an ECHO_PREDICT_METADATA marker to the mock-backend that echoes the
received PredictOptions.Metadata, and an app_test.go spec that drives a real
/v1/chat/completions request (model chat_template_kwargs + per-request metadata
override) and asserts the exact metadata + chat_template_kwargs blob the REST
layer forwards to gRPC. Locks the REST->gRPC contract against regressions. Issue #10329.

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

* test(config): grandfather chat_template_kwargs in registry coverage

chat_template_kwargs is a free-form map[string]any (like engine_args, already
on the list), not a scalar the config UI registry can surface, so it is exempt
from the registry-entry requirement. Fixes the TestAllFieldsHaveRegistryEntries
failure introduced by the new field. Issue #10329.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-16 12:16:34 +02:00
LocalAI [bot]
f44034021e chore: ⬆️ Update leejet/stable-diffusion.cpp to 5a34bc7f6e0621dd2f899daa64476eac667d7ed3 (#10335)
* ⬆️ Update leejet/stable-diffusion.cpp

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

* fix(stablediffusion-ggml): adapt gosd.cpp to upstream sd_ctx_params_t API

The bump to 5a34bc7 restructured sd_ctx_params_t: the boolean CPU-offload
knobs (offload_params_to_cpu, keep_clip_on_cpu, keep_vae_on_cpu,
keep_control_net_on_cpu) were replaced by backend assignment specs
(backend/params_backend), and vae_decode_only / free_params_immediately
were dropped entirely. The build broke with "no member named ..." on
every arch.

Translate the legacy options we still accept from gallery configs into
the new backend assignment specs, mirroring prepare_backend_assignments()
in the upstream CLI, so offload_params_to_cpu / keep_*_on_cpu keep
working. vae_decode_only is parsed and ignored for config compatibility.

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

* feat(stablediffusion-ggml): expose backend/params placement options

The upstream bump introduced new sd_ctx_params_t fields for device and
memory placement (backend, params_backend, rpc_servers, max_vram,
stream_layers) plus PuLID-Flux weights (pulid_weights_path). Wire them up
as backend options so models can be split across CPU/GPU/disk/RPC:

- backend: per-component compute placement (e.g. clip=cpu,vae=cuda0)
- params_backend: per-component weight storage incl. disk mmap
- max_vram / stream_layers: graph-cut segmented parameter offload budget
- rpc_servers: offload compute to remote RPC servers
- pulid_weights_path: PuLID-Flux identity injection

The legacy keep_*_on_cpu / offload_params_to_cpu booleans now seed and
compose with the explicit backend/params_backend specs, matching upstream
prepare_backend_assignments(). Option values are taken as everything after
the first ':' so colon-bearing values (rpc_servers host:port) survive
parsing. Documented the new options in the image-generation guide.

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

* feat(stablediffusion-ggml): distributed RPC across ggml workers

Enable the ggml RPC backend (-DSD_RPC=ON) so image generation can be
sharded across remote rpc-server workers. The ggml rpc-server is
backend-agnostic, so this reuses the exact same worker pool as the
llama.cpp backend - one set of `local-ai worker llama-cpp-rpc` /
`p2p-llama-cpp-rpc` workers accelerates both text and image generation.

RPC servers are selected by precedence:
- the explicit `rpc_servers` option, else
- the LLAMACPP_GRPC_SERVERS env var, which LocalAI's p2p worker mode
  populates automatically with discovered workers (the backend inherits
  it from the parent process env), so distributed image generation needs
  no per-model configuration.

Documented manual and p2p setup in the image-generation guide.

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

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-16 12:15:45 +02:00
LocalAI [bot]
6b9f1bd4b3 chore: ⬆️ Update antirez/ds4 to e34a8086693ba7ca5cfabd2b9028ee52f0bfac2e (#10350)
* ⬆️ Update antirez/ds4

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

* fix(ds4): add Homebrew include/lib prefix for Darwin grpc-proto build

The darwin/metal ds4 backend job runs for the first time on this bump
(it was skipped on prior ds4 PRs) and fails compiling backend.pb.cc with
'google/protobuf/runtime_version.h' file not found.

hw_grpc_proto links neither protobuf::libprotobuf nor gRPC::grpc++, so
the generated proto sources rely on default system include paths. That
works on Linux (/usr/include) but not on macOS, where Homebrew installs
under /opt/homebrew. Add the Homebrew prefix to include/link dirs on
Darwin, mirroring the llama-cpp backend that already builds on Darwin CI.

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

* fix(ds4): install nlohmann-json on Darwin CI for ds4 backend

After the protobuf include-path fix the ds4 darwin build advances to
compiling dsml_renderer.cpp, which includes <nlohmann/json.hpp> and
#errors when absent. On Linux the header comes from apt nlohmann-json3-dev
in the build image; the macOS runner had no equivalent. Add the
header-only nlohmann-json formula to the shared Darwin backend brew
install/link list and Homebrew cache, alongside the existing deps.

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

* fix(ds4): build proper OCI image tar for Darwin backend

The darwin packaging referenced scripts/build/oci-pack.sh, which was
never added to the tree, so it fell back to a plain 'tar' that omits
manifest.json. 'local-ai backends install' then rejects the tarball
with 'file manifest.json not found in tar'.

Use './local-ai util create-oci-image' (already built by the 'build'
prerequisite of the backends/ds4-darwin target), mirroring
llama-cpp-darwin.sh, to emit a real OCI image the installer accepts.

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

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-16 09:59:50 +02:00
github-actions[bot]
416f871bea chore: bump inference defaults from unsloth (#10358)
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-16 09:59:36 +02:00
LocalAI [bot]
8bd2df8f68 fix(launcher): truncate download status labels to stop progress dialog blowout (#10357)
fix(launcher): truncate download status labels to stop dialog blowout

The download progress windows place a ProgressBar and a status Label in the
same VBox. On failure the status label is set to "Download failed: <error>",
and the error commonly contains a long, unbreakable URL/path. A Fyne label
with default settings reports its MinSize as the full single-line text width,
so a long message stretches the window — and the progress bar sharing the
VBox — arbitrarily wide (fixes #10355).

Set Truncation = fyne.TextTruncateEllipsis on the four affected status labels
(the main-window status label plus the status label in each of the three
showDownloadProgress implementations). Truncation collapses the label's
MinSize to roughly one character plus the ellipsis regardless of content, so
the window keeps its intended size. TextWrapWord is not enough because it
cannot break a spaceless URL. The full error text remains visible via the
dialog.ShowError call already present in each path.


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-16 09:42:07 +02:00
neo
6799d802d3 docs: add translated README links (#10353) 2026-06-16 09:06:49 +02:00
LocalAI [bot]
40cc549882 fix(ci): track ServeurpersoCom/qwentts.cpp for QWEN3TTS_CPP_VERSION bumps (#10356)
The qwen3-tts backend migrated from predict-woo/qwen3-tts.cpp to
ServeurpersoCom/qwentts.cpp (the Makefile QWEN3TTS_REPO already points
there), but the bump_deps matrix still tracked the old repo. That made
the nightly bumper open PRs (e.g. #10334) against the wrong upstream.
Point the matrix entry at the new repo and its master branch.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-16 09:04:52 +02:00
LocalAI [bot]
3d295adfa8 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 2f524850a1f67716bc0ba80ffa30ce39c5b8bd5f (#10336)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-06-16 09:04:35 +02:00
LocalAI [bot]
4fa2064875 chore: ⬆️ Update ggml-org/llama.cpp to 7dad2f1a17d65b5e2034c277125bc9f97573a779 (#10337)
⬆️ 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-16 08:22:26 +02:00
LocalAI [bot]
cb74399b3a chore: ⬆️ Update ggml-org/whisper.cpp to 0ec0845110dc934911dc48e8c5beb5ad3189b3f3 (#10349)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-16 08:22:10 +02:00
dependabot[bot]
2388686369 chore(deps): bump grpcio from 1.81.0 to 1.81.1 in /backend/python/vllm (#10347)
Bumps [grpcio](https://github.com/grpc/grpc) from 1.81.0 to 1.81.1.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Commits](https://github.com/grpc/grpc/compare/v1.81.0...v1.81.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 22:57:38 +02:00
LocalAI [bot]
edc61053aa fix(gallery): hide broken Gemma 4 QAT MTP entries (#10348)
The Gemma 4 QAT MTP assistant-head gallery entries currently fail to load in the stock llama.cpp backend with unknown architecture errors. Hide them until the assistant GGUFs are verified against the supported backend path.

Assisted-by: Codex:GPT-5 [gh] [git]

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-15 22:57:19 +02:00
Dedy F. Setyawan
9ba8521e7e feat(react-ui): localize models and fix 'Import' typo (#10341)
* feat(react-ui): localize SearchableSelect component

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* feat(react-ui): localize ModelSelector component

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* fix(react-ui): dynamically localize back navigation caption to match page title

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* feat(react-ui): localize back navigation state on Models page

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* feat(react-ui): localize ModelEditor page

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* fix(react-ui): fix Indonesian typo 'Import' to 'Impor' in importModel locale

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

---------

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-06-15 18:26:27 +02:00
LocalAI [bot]
51c23197ed docs: ⬆️ update docs version mudler/LocalAI (#10333)
⬆️ 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-15 16:55:34 +02:00
LocalAI [bot]
2df2876db2 feat(supertonic): add Supertonic ONNX TTS backend (CPU) (#10342)
* feat(supertonic): vendor upstream Go TTS pipeline (helper.go)

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

* feat(supertonic): add gRPC backend (Load/TTS/TTSStream, CPU)

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

* fix(supertonic): satisfy unused linter (use onnxProvider; exclude vendored helper.go)

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

* test(supertonic): unit tests for resolvers + gated end-to-end synthesis

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

* style(supertonic): gofmt backend.go comment block

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

* feat(supertonic): add Makefile, run.sh, package.sh (CPU build)

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

* build(supertonic): wire backend into root Makefile

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

* fix(supertonic): check ort.DestroyEnvironment return (errcheck)

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

* fix(supertonic): resolve voice_styles as sibling of onnx dir; guard trim; test voice

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

* feat(supertonic): add CPU build matrix + gallery index entries

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

* feat(supertonic): expose as pref-only importable backend

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

* feat(supertonic): add Supertonic/supertonic-3 TTS model to the gallery

16 files (4 onnx + tts.json + unicode_indexer.json + 10 voice styles)
from HF Supertone/supertonic-3, served via the supertonic backend.
Defaults to voice F1; onnx/ + sibling voice_styles/ layout matches the
backend's resolveVoicesDir.

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

* fix(meta): register pipeline.max_history_items config field

Pre-existing on master: the field was added without a registry entry,
failing TestAllFieldsHaveRegistryEntries (core/config/meta). Add the
entry so it renders properly in the model-config UI.

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

* ci(secscan): exclude vendored supertonic backend from gosec

helper.go is vendored from supertone-inc/supertonic; its G304/G404/G104
findings are inherent to upstream and the math/rand use is correct for
flow-matching noise (crypto/rand would be wrong).

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-15 16:54:11 +02:00
LocalAI [bot]
f648f07b13 chore: ⬆️ Update ggml-org/llama.cpp to 4988f6e866057afd130c1515ecef0c9bab9a15f8 (#10280)
⬆️ 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-14 21:53:25 +02:00
LocalAI [bot]
1dedb5277c feat(gallery): add all Italian + all UK English sherpa-onnx Piper voices (#10332)
Expands sherpa-onnx Piper TTS coverage in the model gallery. Previously only
5 single-speaker Piper voices shipped (it_IT-paola, en_US-amy, es_ES-davefx,
fr_FR-siwis, de_DE-thorsten). This adds 19 entries:

Italian (it_IT): dii-high, miro-high, riccardo-x_low.
UK English (en_GB): alan (low+medium), alba-medium, aru-medium, cori
(high+medium), dii-high, jenny_dioco-medium, miro-high,
northern_english_male-medium, semaine-medium, southern_english_female
(low+medium), southern_english_male-medium, vctk-medium, sweetbbak-amy.

Each entry mirrors the existing Piper block (sherpa-onnx-tts.yaml base config).
sha256, ONNX path, sample rate and speaker count were read from the actual
release tarballs; licenses and source URLs were taken from each archive's
MODEL_CARD/README rather than assumed:

- dii/miro voices are OpenVoiceOS models under CC BY-NC-SA 4.0 (non-commercial),
  labelled as such in both the license field and description.
- cori is LibriVox public-domain (cc0-1.0); OpenSLR-83 voices are CC BY-SA 4.0;
  alba/vctk are CC BY 4.0.
- vctk (109), aru (12) and semaine (4) are multi-speaker; tagged accordingly
  with a note to select the speaker via the numeric voice id.

The legacy underscore-named southern_english_female_medium duplicate is
intentionally skipped. No backend change is needed: sherpa-onnx auto-detects
single-speaker VITS vs Kokoro, and each tarball ships its own espeak-ng-data.


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-14 18:33:44 +02:00
LocalAI [bot]
7d2a762b53 feat(realtime): configurable pipeline.max_history_items (#10331)
Composed realtime pipelines (VAD+STT+LLM+TTS) defaulted to unlimited history,
so a long-running session grew every turn and fed the whole conversation to the
LLM until its context window filled. Add an optional pipeline.max_history_items
to cap the trailing items per turn; explicit value (including 0=unlimited) wins
over the per-model-type default. Self-contained any-to-any models keep their
6-item default.

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:13:09 +02:00
LocalAI [bot]
61cde6fd77 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 5f917a64b391b7d31839845153a473a65f630458 (#10240)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-14 16:46:49 +02:00
LocalAI [bot]
ca1668dd85 feat(swagger): update swagger (#10278)
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-14 16:46:34 +02:00
LocalAI [bot]
fdc352a618 fix(settings): start watchdog on cold-enable from the React UI (#9125) (#10287)
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>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-14 16:46:14 +02:00
LocalAI [bot]
692970e507 chore: ⬆️ Update leejet/stable-diffusion.cpp to 276025e054555166ec419413c6748ca79986ee93 (#10313)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-06-14 16:44:05 +02:00
LocalAI [bot]
e046a7749f chore(model gallery): 🤖 add 1 new models via gallery agent (#10328)
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-14 16:43:32 +02:00
LocalAI [bot]
e5c95e0449 fix(distributed): stage backend companion assets to remote nodes (#10330)
A model whose ModelFile is a single file (e.g. sherpa-onnx VITS/piper: the
.onnx) failed to load on remote worker nodes because the sibling assets the
backend resolves from the model dir — tokens.txt, lexicon.txt, the
espeak-ng-data / dict directories, Kokoro's voices.bin — were never staged.
Only the declared ModelFile was shipped, so the worker hit "failed to create
sherpa-onnx TTS engine" and TTS produced no audio.

Lean on the existing option-path staging instead of hardcoding filenames:

- stageGenericOptions now also resolves an option value relative to the model's
  own directory (not just the frontend models dir), so a shared config can
  declare companions with bare names regardless of whether Model includes a
  subdirectory; and it expands directory-valued options (e.g. espeak-ng-data)
  file-by-file rather than handing a directory fd to the stager.
- gallery/sherpa-onnx-tts.yaml declares the companion assets as option paths
  (tokens, lexicon, espeak-ng-data, voices.bin, dict, per-lang lexicons). The
  backend ignores these keys and keeps resolving siblings from the model dir;
  they exist only so distributed staging ships them. Absent files are skipped.

Adds router_optionstage_test.go covering file + directory companion staging via
the model-dir fallback.

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:42:59 +02:00
364 changed files with 21022 additions and 6126 deletions

View File

@@ -44,6 +44,39 @@ maps to `DS4_THINK_HIGH`. We pass the chosen mode to `ds4_chat_append_assistant_
via `ModelOptions.Options[] = "kv_cache_dir:/some/path"`. Format is **our own** -
NOT bit-compatible with ds4-server's KVC files (interop is a follow-up plan).
## Engine options (LoadModel)
`LoadModel` maps `ModelOptions.Options[]` (`"key:value"`, from model-YAML
`options:`) onto `ds4_engine_options` through a **declarative table**
(`kEngineOptSpecs` + `apply_engine_option` in `grpc-server.cpp`). The struct is
plain C with no reflection, so the field set is enumerated once in the table;
adding a future engine knob is a one-line table row, not a new branch. Unknown
keys are ignored (back-compat). A bare flag (`ssd_streaming` with no value)
means `true`. Path-type values (`mtp_path`, `expert_profile_path`,
`directional_steering_file`) resolve **relative to the model directory**, so a
gallery entry can reference a companion file it downloaded by bare filename;
absolute values pass through. `ds4_role` / `ds4_layers` / `ds4_listen` /
`ds4_route_timeout` / `kv_cache_dir` keep their dedicated handling (validation
+ coordinator wiring) and are not in the table.
Wired keys: `mtp_path`, `mtp_draft`, `mtp_margin`, `prefill_chunk`,
`power_percent`, `warm_weights`, `quality`, `ssd_streaming`,
`ssd_streaming_cold`, `ssd_streaming_preload_experts`,
`ssd_streaming_cache_experts` (count or `NGB`, sets both experts+bytes via
`ds4_parse_streaming_cache_experts_arg`), `simulate_used_memory` (`NGB` via
`ds4_parse_gib_arg`), `expert_profile_path`, `directional_steering_file`,
`directional_steering_attn`, `directional_steering_ffn`.
## SSD streaming (running models larger than RAM)
ds4's **SSD streaming** keeps non-routed weights resident and streams routed MoE
experts from the GGUF on cache misses, turning "does it fit in RAM" into a speed
spectrum. **Metal (Darwin) only** - it is a no-op on CUDA/CPU. Enable with
`options: ["ssd_streaming"]`; size the routed-expert cache with
`ssd_streaming_cache_experts:NGB` (omit for ds4's automatic 80%-of-working-set
budget). Gallery entries built on this: `deepseek-v4-flash-q4-ssd` (153 GB Flash
on a 128 GB Mac) and `deepseek-v4-pro-q2-ssd` (433 GB Pro, experimental).
## Build matrix
| Build | Where | Notes |

View File

@@ -70,6 +70,12 @@ if [ "${BUILD_TYPE:-}" = "vulkan" ] && [ "${SKIP_DRIVERS:-false}" = "false" ]; t
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
# Mesa Vulkan ICD drivers (ANV/RADV/lavapipe + Arm SoC) and their ICD
# manifests. The LunarG SDK below only provides the loader and shader
# tooling, not hardware drivers — without Mesa the packaged Vulkan backend
# would ship a loader that finds no GPU. package-gpu-libs.sh bundles these
# .so files plus their deps into the backend so it stays self-contained.
apt-get install -y mesa-vulkan-drivers libdrm2
if [ "amd64" = "${TARGETARCH:-}" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz"
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz

View File

@@ -31,6 +31,15 @@ backend/python/**/source
backend/cpp/llama-cpp/llama.cpp
backend/cpp/llama-cpp-*-build
# privacy-filter: same in-place pattern. The Makefile fetches privacy-filter.cpp
# at the pinned commit (or symlinks a PRIVACY_FILTER_SRC checkout for local dev).
# A stale dir/symlink COPY'd into the image makes the clone step fail (dangling
# symlink) or compile against the wrong commit, so keep host build state out.
backend/cpp/privacy-filter/privacy-filter.cpp
backend/cpp/privacy-filter/build
backend/cpp/privacy-filter/grpc-server
backend/cpp/privacy-filter/package
# Rust backend build output (sources are tracked; target/ is generated)
backend/rust/*/target

View File

@@ -716,6 +716,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-depth-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1582,6 +1595,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-depth-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1621,6 +1647,19 @@ include:
backend: "locate-anything-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-depth-anything-cpp'
base-image: "ubuntu:24.04"
ubuntu-version: '2404'
runs-on: 'ubuntu-24.04-arm'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -2631,6 +2670,78 @@ include:
dockerfile: "./backend/Dockerfile.ds4"
context: "./"
ubuntu-version: '2404'
# privacy-filter: PII/NER token classifier (per-arch native -> manifest merge).
# Every variant builds FROM a prebuilt quay.io/go-skynet/ci-cache:base-grpc-*
# image (gRPC + cmake + protoc + conditional CUDA/Vulkan already installed),
# exactly like llama-cpp — no toolchain is installed in Dockerfile.privacy-filter.
# builder-base-image makes the workflow use the Dockerfile's builder-prebuilt
# stage; without it (local builds) the builder-fromsource stage runs the same
# .docker/install-base-deps.sh.
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-privacy-filter'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'true'
backend: "privacy-filter"
dockerfile: "./backend/Dockerfile.privacy-filter"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-privacy-filter'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-arm64'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'true'
backend: "privacy-filter"
dockerfile: "./backend/Dockerfile.privacy-filter"
context: "./"
ubuntu-version: '2404'
# Vulkan: base-grpc-vulkan-amd64 carries the SDK. arm64 vulkan is a one-line
# add once amd64 is proven in CI.
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-privacy-filter'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "privacy-filter"
dockerfile: "./backend/Dockerfile.privacy-filter"
context: "./"
ubuntu-version: '2404'
# CUDA: base-grpc-cuda-13-amd64 carries the toolkit; BUILD_TYPE=cublas ->
# -DPF_CUDA=ON. cuda-12 and arm64/l4t are one-line adds once cuda-13 amd64 is
# proven in CI.
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-privacy-filter'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-13-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'true'
backend: "privacy-filter"
dockerfile: "./backend/Dockerfile.privacy-filter"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -2898,6 +3009,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-depth-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2911,6 +3035,19 @@ include:
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-depth-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2924,6 +3061,19 @@ include:
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-depth-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2938,6 +3088,20 @@ include:
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-depth-anything-cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2952,6 +3116,20 @@ include:
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-depth-anything-cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
@@ -3058,6 +3236,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-depth-anything-cpp'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "depth-anything-cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
# whisper
- build-type: ''
cuda-major-version: ""
@@ -4490,6 +4681,36 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# supertonic CPU (amd64)
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-supertonic'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "supertonic"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# supertonic CPU (arm64)
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-supertonic'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "supertonic"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# Darwin matrix (consumed by backend-jobs-darwin).
includeDarwin:

View File

@@ -98,6 +98,7 @@ jobs:
/opt/homebrew/Cellar/hiredis
/opt/homebrew/Cellar/xxhash
/opt/homebrew/Cellar/zstd
/opt/homebrew/Cellar/nlohmann-json
key: brew-${{ runner.os }}-${{ runner.arch }}-v1-${{ hashFiles('.github/workflows/backend_build_darwin.yml') }}
- name: Dependencies
@@ -109,7 +110,10 @@ jobs:
# Without explicitly installing them, a brew cache-hit run restores
# ccache's Cellar dir but skips installing those transitive deps,
# and ccache fails at runtime with `dyld: Library not loaded`.
brew install protobuf grpc make protoc-gen-go protoc-gen-go-grpc libomp llvm ccache blake3 fmt hiredis xxhash zstd
# nlohmann-json is header-only and required by the ds4 backend
# (dsml_renderer.cpp includes <nlohmann/json.hpp>); on Linux it comes
# from the apt-installed nlohmann-json3-dev in the build image.
brew install protobuf grpc make protoc-gen-go protoc-gen-go-grpc libomp llvm ccache blake3 fmt hiredis xxhash zstd nlohmann-json
# Force-reinstall ccache so brew re-validates its full runtime-dep
# closure on every run. This is the durable fix: when the upstream
# ccache formula gains a new transitive dep (as it has multiple times
@@ -128,7 +132,7 @@ jobs:
# and decides "already installed" without re-linking, so on a cache-
# hit run the formulas aren't on PATH. Force-link them; --overwrite
# tolerates pre-existing symlinks from earlier installs.
brew link --overwrite protobuf grpc make protoc-gen-go protoc-gen-go-grpc libomp llvm ccache blake3 fmt hiredis xxhash zstd 2>/dev/null || true
brew link --overwrite protobuf grpc make protoc-gen-go protoc-gen-go-grpc libomp llvm ccache blake3 fmt hiredis xxhash zstd nlohmann-json 2>/dev/null || true
- name: Save Homebrew cache
if: github.event_name != 'pull_request' && steps.brew-cache.outputs.cache-hit != 'true'
@@ -148,6 +152,7 @@ jobs:
/opt/homebrew/Cellar/hiredis
/opt/homebrew/Cellar/xxhash
/opt/homebrew/Cellar/zstd
/opt/homebrew/Cellar/nlohmann-json
key: brew-${{ runner.os }}-${{ runner.arch }}-v1-${{ hashFiles('.github/workflows/backend_build_darwin.yml') }}
# ---- ccache for llama.cpp CMake builds ----

View File

@@ -26,6 +26,10 @@ jobs:
variable: "DS4_VERSION"
branch: "main"
file: "backend/cpp/ds4/Makefile"
- repository: "localai-org/privacy-filter.cpp"
variable: "PRIVACY_FILTER_VERSION"
branch: "master"
file: "backend/cpp/privacy-filter/Makefile"
- repository: "ggml-org/whisper.cpp"
variable: "WHISPER_CPP_VERSION"
branch: "master"
@@ -38,6 +42,10 @@ jobs:
variable: "PARAKEET_VERSION"
branch: "master"
file: "backend/go/parakeet-cpp/Makefile"
- repository: "mudler/depth-anything.cpp"
variable: "DEPTHANYTHING_VERSION"
branch: "master"
file: "backend/go/depth-anything-cpp/Makefile"
- repository: "leejet/stable-diffusion.cpp"
variable: "STABLEDIFFUSION_GGML_VERSION"
branch: "master"
@@ -66,9 +74,9 @@ jobs:
variable: "LOCATEANYTHING_VERSION"
branch: "master"
file: "backend/go/locate-anything-cpp/Makefile"
- repository: "predict-woo/qwen3-tts.cpp"
- repository: "ServeurpersoCom/qwentts.cpp"
variable: "QWEN3TTS_CPP_VERSION"
branch: "main"
branch: "master"
file: "backend/go/qwen3-tts-cpp/Makefile"
- repository: "ServeurpersoCom/omnivoice.cpp"
variable: "OMNIVOICE_VERSION"

View File

@@ -21,7 +21,10 @@ jobs:
uses: securego/gosec@v2.27.1
with:
# we let the report trigger content trigger a failure using the GitHub Security features.
args: '-no-fail -fmt sarif -out results.sarif ./...'
# backend/go/supertonic is excluded: it vendors upstream supertone-inc/supertonic
# (helper.go), whose findings (G304 model-file loads, G404 math/rand for flow-matching
# noise, G104 unhandled errors) are inherent to that upstream code, not ours to rewrite.
args: '-no-fail -exclude-dir=backend/go/supertonic -fmt sarif -out results.sarif ./...'
- name: Upload SARIF file
if: ${{ github.actor != 'dependabot[bot]' }}
uses: github/codeql-action/upload-sarif@v4

View File

@@ -74,6 +74,8 @@ linters:
paths:
# Upstream whisper.cpp source tree fetched by the whisper backend Makefile.
- 'backend/go/whisper/sources'
# Vendored upstream supertonic pipeline (supertone-inc/supertonic go/helper.go).
- 'backend/go/supertonic/helper.go'
- 'docs/'
rules:
# CLI entry points: kong's `env:"..."` tag is the legitimate env→struct

View File

@@ -1,5 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/crispasr backends/parakeet-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/crispasr backends/parakeet-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter
GOCMD=go
GOTEST=$(GOCMD) test
@@ -595,6 +595,8 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/rust/kokoros test
$(MAKE) -C backend/go/rfdetr-cpp test
$(MAKE) -C backend/go/locate-anything-cpp test
$(MAKE) -C backend/go/depth-anything-cpp test
$(MAKE) -C backend/go/supertonic test
##
## End-to-end gRPC tests that exercise a built backend container image.
@@ -1162,6 +1164,10 @@ BACKEND_TURBOQUANT = turboquant|turboquant|.|false|false
# Single-model; hardware-only validation lives at tests/e2e-backends/
# (BACKEND_BINARY mode); see docs/superpowers/plans/2026-05-11-ds4-backend.md.
BACKEND_DS4 = ds4|ds4|.|false|false
# privacy-filter wraps the standalone privacy-filter.cpp GGML engine (the
# openai-privacy-filter PII/NER token classifier) — the TokenClassify RPC for
# the PII redactor tier, on stock ggml with no llama.cpp carry-patches.
BACKEND_PRIVACY_FILTER = privacy-filter|privacy-filter|.|false|false
# Golang backends
BACKEND_PIPER = piper|golang|.|false|true
@@ -1173,6 +1179,7 @@ BACKEND_STABLEDIFFUSION_GGML = stablediffusion-ggml|golang|.|--progress=plain|tr
BACKEND_WHISPER = whisper|golang|.|false|true
BACKEND_CRISPASR = crispasr|golang|.|false|true
BACKEND_PARAKEET_CPP = parakeet-cpp|golang|.|false|true
BACKEND_DEPTH_ANYTHING_CPP = depth-anything-cpp|golang|.|false|true
BACKEND_VOXTRAL = voxtral|golang|.|false|true
BACKEND_ACESTEP_CPP = acestep-cpp|golang|.|false|true
BACKEND_QWEN3_TTS_CPP = qwen3-tts-cpp|golang|.|false|true
@@ -1181,6 +1188,7 @@ BACKEND_VIBEVOICE_CPP = vibevoice-cpp|golang|.|false|true
BACKEND_LOCALVQE = localvqe|golang|.|false|true
BACKEND_OPUS = opus|golang|.|false|true
BACKEND_SHERPA_ONNX = sherpa-onnx|golang|.|false|true
BACKEND_SUPERTONIC = supertonic|golang|.|false|true
# Python backends with root context
BACKEND_RERANKERS = rerankers|python|.|false|true
@@ -1254,6 +1262,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_LLAMA_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_IK_LLAMA_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_TURBOQUANT)))
$(eval $(call generate-docker-build-target,$(BACKEND_DS4)))
$(eval $(call generate-docker-build-target,$(BACKEND_PRIVACY_FILTER)))
$(eval $(call generate-docker-build-target,$(BACKEND_PIPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_LOCAL_STORE)))
$(eval $(call generate-docker-build-target,$(BACKEND_CLOUD_PROXY)))
@@ -1263,6 +1272,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_STABLEDIFFUSION_GGML)))
$(eval $(call generate-docker-build-target,$(BACKEND_WHISPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_CRISPASR)))
$(eval $(call generate-docker-build-target,$(BACKEND_PARAKEET_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_DEPTH_ANYTHING_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_VOXTRAL)))
$(eval $(call generate-docker-build-target,$(BACKEND_OPUS)))
$(eval $(call generate-docker-build-target,$(BACKEND_RERANKERS)))
@@ -1308,12 +1318,13 @@ $(eval $(call generate-docker-build-target,$(BACKEND_KOKOROS)))
$(eval $(call generate-docker-build-target,$(BACKEND_SAM3_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_RFDETR_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_SHERPA_ONNX)))
$(eval $(call generate-docker-build-target,$(BACKEND_SUPERTONIC)))
# Pattern rule for docker-save targets
docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-privacy-filter
########################################################
### Mock Backend for E2E Tests

View File

@@ -29,6 +29,18 @@
<a href="https://trendshift.io/repositories/5539" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5539" alt="mudler%2FLocalAI | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<!-- Keep these links, translations synced daily. -->
<p align="center">
<a href="https://zdoc.app/de/mudler/LocalAI">Deutsch</a> |
<a href="https://zdoc.app/es/mudler/LocalAI">Español</a> |
<a href="https://zdoc.app/fr/mudler/LocalAI">français</a> |
<a href="https://zdoc.app/ja/mudler/LocalAI">日本語</a> |
<a href="https://zdoc.app/ko/mudler/LocalAI">한국어</a> |
<a href="https://zdoc.app/pt/mudler/LocalAI">Português</a> |
<a href="https://zdoc.app/ru/mudler/LocalAI">Русский</a> |
<a href="https://zdoc.app/zh/mudler/LocalAI">中文</a>
</p>
**LocalAI** is the open-source AI engine. Run any model - LLMs, vision, voice, image, video - on any hardware. No GPU required.
**A small core, not a bundle.** Each backend wraps a best-in-class engine (llama.cpp, vLLM, whisper.cpp, stable-diffusion, MLX...) in its own image, pulled only when a model needs it. You install nothing you don't use.
@@ -208,10 +220,26 @@ For older news and full release notes, see [GitHub Releases](https://github.com/
## Supported Backends & Acceleration
LocalAI supports **36+ backends** including llama.cpp, vLLM, transformers, whisper.cpp, diffusers, MLX, MLX-VLM, and many more. Hardware acceleration is available for **NVIDIA** (CUDA 12/13), **AMD** (ROCm), **Intel** (oneAPI/SYCL), **Apple Silicon** (Metal), **Vulkan**, and **NVIDIA Jetson** (L4T). All backends can be installed on-the-fly from the [Backend Gallery](https://localai.io/backends/).
LocalAI supports **60+ backends** including llama.cpp, vLLM, SGLang, transformers, whisper.cpp, diffusers, MLX, MLX-VLM, and many more. Hardware acceleration is available for **NVIDIA** (CUDA 12/13), **AMD** (ROCm), **Intel** (oneAPI/SYCL), **Apple Silicon** (Metal), **Vulkan**, and **NVIDIA Jetson** (L4T). All backends can be installed on-the-fly from the [Backend Gallery](https://localai.io/backends/).
See the full [Backend & Model Compatibility Table](https://localai.io/model-compatibility/) and [GPU Acceleration guide](https://localai.io/features/gpu-acceleration/).
### Backends built by us
Most backends wrap a best-in-class upstream engine. A handful of them are native C/C++/GGML engines (no Python at inference) developed and maintained by the LocalAI project itself:
| Backend | What it does |
|---------|-------------|
| [parakeet.cpp](https://github.com/mudler/parakeet.cpp) | C++/GGML port of NVIDIA NeMo Parakeet ASR (tdt/ctc/rnnt/hybrid), with cache-aware streaming transcription |
| [voxtral.c](https://github.com/mudler/voxtral.c) | Voxtral Realtime 4B speech-to-text in pure C |
| [vibevoice.cpp](https://github.com/mudler/vibevoice.cpp) | Native port of Microsoft VibeVoice for TTS (voice cloning) and long-form ASR with speaker diarization |
| [rf-detr.cpp](https://github.com/mudler/rf-detr.cpp) | Native RF-DETR object detection and instance segmentation |
| [locate-anything.cpp](https://github.com/mudler/locate-anything.cpp) | Open-vocabulary object detection and visual grounding (LocateAnything-3B) |
| [depth-anything.cpp](https://github.com/mudler/depth-anything.cpp) | Depth Anything 3 monocular metric depth + camera pose estimation |
| [privacy-filter.cpp](https://github.com/localai-org/privacy-filter.cpp) | Standalone GGML PII/NER token-classification engine powering LocalAI's PII redaction tier |
| [LocalVQE](https://github.com/localai-org/LocalVQE) | Joint acoustic echo cancellation, noise suppression, and dereverberation |
| [local-store](https://github.com/mudler/LocalAI) | Local-first vector database for embeddings (shipped in-tree) |
## Resources
- [Documentation](https://localai.io/)

View File

@@ -65,7 +65,12 @@ RUN <<EOT bash
libwayland-dev libxrandr-dev libxcb-randr0-dev libxcb-ewmh-dev \
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils && \
apt-get install -y mesa-vulkan-drivers libdrm2
# Mesa Vulkan ICD drivers (ANV/RADV/lavapipe) + their manifests. The
# LunarG SDK below only provides the loader and shader tooling, not
# hardware drivers — without Mesa, package-gpu-libs.sh has no ICD to
# bundle and the packaged backend finds no GPU at runtime.
if [ "amd64" = "$TARGETARCH" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \

View File

@@ -0,0 +1,109 @@
ARG BASE_IMAGE=ubuntu:24.04
# BUILDER_BASE_IMAGE defaults to BASE_IMAGE so the Dockerfile parses when no
# prebuilt base is supplied; the builder-prebuilt stage is only entered when
# BUILDER_TARGET=builder-prebuilt, so the fallback content is harmless
# (BuildKit prunes the unreferenced builder).
ARG BUILDER_BASE_IMAGE=${BASE_IMAGE}
# BUILDER_TARGET selects which builder stage the scratch image copies from.
# Declared before any FROM so it is usable in `FROM ${BUILDER_TARGET}`. The
# backend_build workflow sets it to builder-prebuilt when the matrix entry
# provides builder-base-image, else builder-fromsource (the local default).
ARG BUILDER_TARGET=builder-fromsource
ARG APT_MIRROR=""
ARG APT_PORTS_MIRROR=""
# privacy-filter: standalone GGML engine for the openai-privacy-filter PII/NER
# token classifier, wrapped as a LocalAI gRPC backend.
#
# Mirrors backend/Dockerfile.llama-cpp: the build toolchain (gRPC + cmake +
# protoc + conditional CUDA/Vulkan) comes from the shared
# .docker/install-base-deps.sh (from-source path) or a prebuilt
# quay.io/go-skynet/ci-cache:base-grpc-* image (CI path) — nothing GPU-specific
# is hand-rolled here. BUILD_TYPE selects the engine backend in the Makefile:
# "" = cpu, "cublas" -> -DPF_CUDA=ON, "vulkan" -> -DPF_VULKAN=ON.
# ============================================================================
# Stage: builder-fromsource — self-contained build. Runs the same install
# script backend/Dockerfile.base-grpc-builder runs, so this path is
# bit-equivalent to the prebuilt base. Used when BUILDER_TARGET=builder-fromsource
# (the default; local `make backends/privacy-filter`).
# ============================================================================
FROM ${BASE_IMAGE} AS builder-fromsource
ARG BUILD_TYPE
ARG CUDA_MAJOR_VERSION
ARG CUDA_MINOR_VERSION
ARG CMAKE_FROM_SOURCE=false
# CUDA Toolkit 13.x needs CMake 3.31.9+ for correct toolchain/arch detection.
ARG CMAKE_VERSION=3.31.10
ARG GRPC_VERSION=v1.65.0
ARG GRPC_MAKEFLAGS="-j4 -Otarget"
ARG SKIP_DRIVERS=false
ARG TARGETARCH
ARG UBUNTU_VERSION=2404
ARG APT_MIRROR
ARG APT_PORTS_MIRROR
ENV BUILD_TYPE=${BUILD_TYPE} \
CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION} \
CUDA_MINOR_VERSION=${CUDA_MINOR_VERSION} \
CMAKE_FROM_SOURCE=${CMAKE_FROM_SOURCE} \
CMAKE_VERSION=${CMAKE_VERSION} \
GRPC_VERSION=${GRPC_VERSION} \
GRPC_MAKEFLAGS=${GRPC_MAKEFLAGS} \
SKIP_DRIVERS=${SKIP_DRIVERS} \
TARGETARCH=${TARGETARCH} \
UBUNTU_VERSION=${UBUNTU_VERSION} \
APT_MIRROR=${APT_MIRROR} \
APT_PORTS_MIRROR=${APT_PORTS_MIRROR} \
DEBIAN_FRONTEND=noninteractive
# CUDA on PATH (a no-op when CUDA is not installed, e.g. cpu/vulkan builds).
ENV PATH=/usr/local/cuda/bin:${PATH}
WORKDIR /build
# apt deps + cmake + protoc + gRPC + conditional CUDA/Vulkan, all from the
# shared script (the source of truth that base-grpc-builder also runs).
RUN --mount=type=bind,source=.docker/install-base-deps.sh,target=/usr/local/sbin/install-base-deps \
--mount=type=bind,source=.docker/apt-mirror.sh,target=/usr/local/sbin/apt-mirror \
bash /usr/local/sbin/install-base-deps
# install-base-deps installs gRPC under /opt/grpc; copy it to /usr/local so the
# backend's find_package(gRPC CONFIG) resolves it at the canonical prefix.
RUN cp -a /opt/grpc/. /usr/local/
COPY . /LocalAI
RUN --mount=type=cache,target=/root/.ccache,id=privacy-filter-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
make -C /LocalAI/backend/cpp/privacy-filter BUILD_TYPE=${BUILD_TYPE} NATIVE=false grpc-server package
# ============================================================================
# Stage: builder-prebuilt — FROM a prebuilt
# quay.io/go-skynet/ci-cache:base-grpc-* image (gRPC at /opt/grpc + apt deps +
# CUDA/Vulkan already installed). Used in CI when the matrix entry sets
# builder-base-image.
# ============================================================================
FROM ${BUILDER_BASE_IMAGE} AS builder-prebuilt
ARG BUILD_TYPE
ARG TARGETARCH
ENV BUILD_TYPE=${BUILD_TYPE}
# CUDA on PATH (a no-op for the cpu/vulkan base images).
ENV PATH=/usr/local/cuda/bin:${PATH}
# Mirror builder-fromsource: the base-grpc image installs gRPC to /opt/grpc but
# does not copy it to /usr/local.
RUN cp -a /opt/grpc/. /usr/local/
COPY . /LocalAI
RUN --mount=type=cache,target=/root/.ccache,id=privacy-filter-ccache-${TARGETARCH}-${BUILD_TYPE},sharing=locked \
make -C /LocalAI/backend/cpp/privacy-filter BUILD_TYPE=${BUILD_TYPE} NATIVE=false grpc-server package
# ============================================================================
# Final stage — copy the package output from the selected builder. BuildKit
# does not expand variables in `COPY --from=`, so alias the chosen builder to a
# fixed stage name first.
# ============================================================================
FROM ${BUILDER_TARGET} AS builder
FROM scratch
COPY --from=builder /LocalAI/backend/cpp/privacy-filter/package/. ./

View File

@@ -66,7 +66,12 @@ RUN <<EOT bash
libwayland-dev libxrandr-dev libxcb-randr0-dev libxcb-ewmh-dev \
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils && \
apt-get install -y mesa-vulkan-drivers libdrm2
# Mesa Vulkan ICD drivers (ANV/RADV/lavapipe) + their manifests. The
# LunarG SDK below only provides the loader and shader tooling, not
# hardware drivers — without Mesa, package-gpu-libs.sh has no ICD to
# bundle and the packaged backend finds no GPU at runtime.
if [ "amd64" = "$TARGETARCH" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \

View File

@@ -24,6 +24,7 @@ service Backend {
rpc TokenizeString(PredictOptions) returns (TokenizationResponse) {}
rpc Status(HealthMessage) returns (StatusResponse) {}
rpc Detect(DetectOptions) returns (DetectResponse) {}
rpc Depth(DepthRequest) returns (DepthResponse) {}
rpc FaceVerify(FaceVerifyRequest) returns (FaceVerifyResponse) {}
rpc FaceAnalyze(FaceAnalyzeRequest) returns (FaceAnalyzeResponse) {}
rpc VoiceVerify(VoiceVerifyRequest) returns (VoiceVerifyResponse) {}
@@ -670,6 +671,35 @@ message DetectResponse {
repeated Detection Detections = 1;
}
// --- Depth estimation messages (Depth Anything 3) ---
message DepthRequest {
string src = 1; // input image (filesystem path or base64-encoded payload)
string dst = 2; // optional output directory for exports (glb/colmap)
bool include_depth = 3; // return the per-pixel metric depth map
bool include_confidence = 4; // return the per-pixel confidence map (DualDPT)
bool include_pose = 5; // return camera extrinsics/intrinsics (DualDPT)
bool include_sky = 6; // return the per-pixel sky map (mono models)
bool include_points = 7; // back-project to a 3D point cloud (DualDPT)
float points_conf_thresh = 8; // keep points with confidence >= this threshold
repeated string exports = 9; // requested exports: "glb", "colmap"
}
message DepthResponse {
int32 width = 1; // processed depth-map width
int32 height = 2; // processed depth-map height
repeated float depth = 3; // width*height row-major metric depth
repeated float confidence = 4; // width*height row-major confidence (DualDPT)
repeated float sky = 5; // width*height row-major sky map (mono)
repeated float extrinsics = 6; // 12 floats, 3x4 row-major (world-to-camera)
repeated float intrinsics = 7; // 9 floats, 3x3 row-major
int32 num_points = 8; // number of 3D points
repeated float points = 9; // num_points*3 xyz, world space
bytes point_colors = 10; // num_points*3 uint8 rgb
repeated string export_paths = 11; // paths written for the requested exports
bool is_metric = 12; // depth is in metric units
}
// --- Face recognition messages ---
message FacialArea {

View File

@@ -9,6 +9,22 @@ option(DS4_NATIVE "Compile with -march=native / -mcpu=native" ON)
set(DS4_GPU "cpu" CACHE STRING "GPU backend: cpu, cuda, or metal")
set(DS4_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ds4" CACHE PATH "Path to cloned ds4 source")
if(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
# Homebrew installs protobuf/grpc under a non-default prefix. The generated
# backend.pb.cc / backend.grpc.pb.cc pull in google/protobuf and grpcpp
# headers, but the hw_grpc_proto library links neither target, so on macOS
# the headers (e.g. google/protobuf/runtime_version.h) are never on the
# compiler's include path. Add the Homebrew prefix globally, matching the
# llama-cpp backend which builds on Darwin CI.
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "arm64")
set(HOMEBREW_DEFAULT_PREFIX "/opt/homebrew")
else()
set(HOMEBREW_DEFAULT_PREFIX "/usr/local")
endif()
link_directories("${HOMEBREW_DEFAULT_PREFIX}/lib")
include_directories("${HOMEBREW_DEFAULT_PREFIX}/include")
endif()
find_package(Threads REQUIRED)
find_package(Protobuf CONFIG QUIET)
if(NOT Protobuf_FOUND)

View File

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

View File

@@ -25,6 +25,8 @@ extern "C" {
#include <chrono>
#include <climits>
#include <csignal>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <ctime>
@@ -105,6 +107,130 @@ static bool parse_layers_spec(const std::string &spec, ds4_distributed_layers *o
return true;
}
// Parse a boolean LoadModel option. An empty value (a bare flag-style option
// like "ssd_streaming" with no colon) means true so model YAMLs can write
// options: ["ssd_streaming"] to enable a switch.
static bool parse_bool_option(const std::string &s, bool *out) {
if (s.empty() || s == "true" || s == "1" || s == "yes" || s == "on") { *out = true; return true; }
if (s == "false" || s == "0" || s == "no" || s == "off") { *out = false; return true; }
return false;
}
// Table-driven mapping from LoadModel option keys to ds4_engine_options fields.
// ds4_engine_options is a fixed C struct with no reflection, so the field set
// is enumerated once here; adding a future engine knob is a one-line table
// entry rather than a new branch in LoadModel. Two fields need ds4's own typed
// parsers (Gib, CacheExperts) so a plain string passthrough can't cover them.
enum class DsOptType { Bool, Int, Uint, Float, Str, Gib, CacheExperts };
struct DsOptSpec {
const char *key;
DsOptType type;
size_t off; // byte offset into ds4_engine_options
size_t off2; // second offset (CacheExperts writes experts + bytes)
bool is_path; // Str values: resolve a relative value against the model dir
};
static const DsOptSpec kEngineOptSpecs[] = {
{"mtp_path", DsOptType::Str, offsetof(ds4_engine_options, mtp_path), 0, true},
{"mtp_draft", DsOptType::Int, offsetof(ds4_engine_options, mtp_draft_tokens), 0},
{"mtp_margin", DsOptType::Float, offsetof(ds4_engine_options, mtp_margin), 0},
{"prefill_chunk", DsOptType::Uint, offsetof(ds4_engine_options, prefill_chunk), 0},
{"power_percent", DsOptType::Int, offsetof(ds4_engine_options, power_percent), 0},
{"warm_weights", DsOptType::Bool, offsetof(ds4_engine_options, warm_weights), 0},
{"quality", DsOptType::Bool, offsetof(ds4_engine_options, quality), 0},
{"ssd_streaming", DsOptType::Bool, offsetof(ds4_engine_options, ssd_streaming), 0},
{"ssd_streaming_cold", DsOptType::Bool, offsetof(ds4_engine_options, ssd_streaming_cold), 0},
{"ssd_streaming_preload_experts", DsOptType::Uint, offsetof(ds4_engine_options, ssd_streaming_preload_experts), 0},
{"ssd_streaming_cache_experts", DsOptType::CacheExperts, offsetof(ds4_engine_options, ssd_streaming_cache_experts),
offsetof(ds4_engine_options, ssd_streaming_cache_bytes)},
{"simulate_used_memory", DsOptType::Gib, offsetof(ds4_engine_options, simulate_used_memory_bytes), 0},
{"expert_profile_path", DsOptType::Str, offsetof(ds4_engine_options, expert_profile_path), 0, true},
{"directional_steering_file", DsOptType::Str, offsetof(ds4_engine_options, directional_steering_file), 0, true},
{"directional_steering_attn", DsOptType::Float, offsetof(ds4_engine_options, directional_steering_attn), 0},
{"directional_steering_ffn", DsOptType::Float, offsetof(ds4_engine_options, directional_steering_ffn), 0},
};
// Apply a single key:value LoadModel option to the engine options struct.
// Unknown keys are ignored (back-compat: callers pass mixed option sets).
// String values are copied into `storage`, whose elements the engine reads by
// pointer during ds4_engine_open; `storage` MUST have reserved capacity so
// push_back never reallocates and dangles an earlier c_str(). Returns false
// with `err` set when a recognized key has an invalid value.
static bool apply_engine_option(ds4_engine_options *opt, const std::string &key,
const std::string &val, const std::string &model_dir,
std::vector<std::string> &storage, std::string &err) {
const DsOptSpec *spec = nullptr;
for (const auto &s : kEngineOptSpecs) {
if (key == s.key) { spec = &s; break; }
}
if (!spec) return true; // unknown key: ignore
char *base = reinterpret_cast<char *>(opt);
switch (spec->type) {
case DsOptType::Bool: {
bool b = false;
if (!parse_bool_option(val, &b)) { err = key + " must be true/false"; return false; }
*reinterpret_cast<bool *>(base + spec->off) = b;
return true;
}
case DsOptType::Int: {
char *end = nullptr;
long v = std::strtol(val.c_str(), &end, 10);
if (val.empty() || !end || *end != '\0') { err = key + " must be an integer"; return false; }
*reinterpret_cast<int *>(base + spec->off) = static_cast<int>(v);
return true;
}
case DsOptType::Uint: {
char *end = nullptr;
long v = std::strtol(val.c_str(), &end, 10);
if (val.empty() || !end || *end != '\0' || v < 0 || v > static_cast<long>(UINT32_MAX)) {
err = key + " must be a non-negative integer"; return false;
}
*reinterpret_cast<uint32_t *>(base + spec->off) = static_cast<uint32_t>(v);
return true;
}
case DsOptType::Float: {
char *end = nullptr;
float f = std::strtof(val.c_str(), &end);
if (val.empty() || !end || *end != '\0') { err = key + " must be a number"; return false; }
*reinterpret_cast<float *>(base + spec->off) = f;
return true;
}
case DsOptType::Str: {
// Resolve a relative path option (e.g. mtp_path: a sibling GGUF the
// gallery downloaded next to the model) against the model directory, so
// YAMLs reference companion files by name. Absolute values pass through.
if (spec->is_path && !model_dir.empty() && !val.empty() && val.front() != '/') {
storage.push_back(model_dir + "/" + val);
} else {
storage.push_back(val);
}
*reinterpret_cast<const char **>(base + spec->off) = storage.back().c_str();
return true;
}
case DsOptType::Gib: {
uint64_t bytes = 0;
if (!ds4_parse_gib_arg(val.c_str(), &bytes)) {
err = key + " must be a GiB value, e.g. 64GB"; return false;
}
*reinterpret_cast<uint64_t *>(base + spec->off) = bytes;
return true;
}
case DsOptType::CacheExperts: {
uint32_t experts = 0;
uint64_t bytes = 0;
if (!ds4_parse_streaming_cache_experts_arg(val.c_str(), &experts, &bytes)) {
err = key + " must be a positive expert count or a <number>GB budget"; return false;
}
*reinterpret_cast<uint32_t *>(base + spec->off) = experts;
*reinterpret_cast<uint64_t *>(base + spec->off2) = bytes;
return true;
}
}
return true;
}
// When acting as a distributed coordinator, block until the worker route
// covers all layers (ds4_session_distributed_route_ready == 1) or the timeout
// elapses. Returns an empty string on success, or an error message to return
@@ -476,39 +602,10 @@ public:
return GStatus::OK;
}
std::string mtp_path;
int mtp_draft = 0;
float mtp_margin = 3.0f;
std::string ds4_role, ds4_layers, ds4_listen;
for (const auto &opt : request->options()) {
auto [k, v] = split_option(opt);
if (k == "mtp_path") mtp_path = v;
else if (k == "mtp_draft") mtp_draft = std::stoi(v);
else if (k == "mtp_margin") mtp_margin = std::stof(v);
else if (k == "kv_cache_dir") g_kv_cache_dir = v;
else if (k == "ds4_role") ds4_role = v;
else if (k == "ds4_layers") ds4_layers = v;
else if (k == "ds4_listen") ds4_listen = v;
else if (k == "ds4_route_timeout") {
if (!parse_positive_int(v, &g_route_timeout_sec)) {
result->set_success(false);
result->set_message("ds4: ds4_route_timeout must be a positive integer");
return GStatus::OK;
}
}
}
g_kv_cache.SetDir(g_kv_cache_dir);
ds4_engine_options opt = {};
opt.model_path = model_path.c_str();
opt.mtp_path = mtp_path.empty() ? nullptr : mtp_path.c_str();
opt.n_threads = request->threads() > 0 ? request->threads() : 0;
opt.mtp_draft_tokens = mtp_draft;
opt.mtp_margin = mtp_margin;
opt.directional_steering_file = nullptr;
opt.warm_weights = false;
opt.quality = false;
opt.mtp_margin = 3.0f; // ds4 default; overridable via the mtp_margin option
#if defined(DS4_NO_GPU)
opt.backend = DS4_BACKEND_CPU;
@@ -518,6 +615,46 @@ public:
opt.backend = DS4_BACKEND_CUDA;
#endif
// Stable storage for string-valued engine options. The engine reads
// these by pointer during ds4_engine_open, so the std::string backing
// store must outlive the call and not reallocate; reserve up front so
// push_back keeps every prior c_str() valid. Static + clear() reuses
// the buffer across LoadModel calls (the old engine is closed above).
static std::vector<std::string> s_opt_strings;
s_opt_strings.clear();
s_opt_strings.reserve(sizeof(kEngineOptSpecs) / sizeof(kEngineOptSpecs[0]));
// Directory of the main model, used to resolve relative path options.
std::string model_dir;
if (auto slash = model_path.find_last_of('/'); slash != std::string::npos) {
model_dir = model_path.substr(0, slash);
}
std::string ds4_role, ds4_layers, ds4_listen;
for (const auto &o : request->options()) {
auto [k, v] = split_option(o);
if (k == "kv_cache_dir") { g_kv_cache_dir = v; continue; }
else if (k == "ds4_role") { ds4_role = v; continue; }
else if (k == "ds4_layers") { ds4_layers = v; continue; }
else if (k == "ds4_listen") { ds4_listen = v; continue; }
else if (k == "ds4_route_timeout") {
if (!parse_positive_int(v, &g_route_timeout_sec)) {
result->set_success(false);
result->set_message("ds4: ds4_route_timeout must be a positive integer");
return GStatus::OK;
}
continue;
}
std::string err;
if (!apply_engine_option(&opt, k, v, model_dir, s_opt_strings, err)) {
result->set_success(false);
result->set_message("ds4: " + err);
return GStatus::OK;
}
}
g_kv_cache.SetDir(g_kv_cache_dir);
// Coordinator wiring. 'ds4_role:coordinator' enables layer-split
// distributed inference: this process listens on ds4_listen and owns
// the ds4_layers slice; workers dial in (see `local-ai worker

View File

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

View File

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

View File

@@ -1922,25 +1922,27 @@ public:
body_json["min_p"] = data["min_p"];
}
// Pass enable_thinking via chat_template_kwargs (where oaicompat_chat_params_parse reads it)
// Forward the chat_template_kwargs the Go layer resolved (model config
// chat_template_kwargs + per-request metadata: enable_thinking,
// reasoning_effort, preserve_thinking, ...). One generic merge replaces
// the previous per-key handling - new template levers need no C++ change.
// oaicompat_chat_params_parse reads these from body_json.
const auto& metadata = request->metadata();
auto et_it = metadata.find("enable_thinking");
if (et_it != metadata.end()) {
if (!body_json.contains("chat_template_kwargs")) {
body_json["chat_template_kwargs"] = json::object();
auto ctk_it = metadata.find("chat_template_kwargs");
if (ctk_it != metadata.end() && !ctk_it->second.empty()) {
try {
json ctk = json::parse(ctk_it->second);
if (ctk.is_object()) {
if (!body_json.contains("chat_template_kwargs")) {
body_json["chat_template_kwargs"] = json::object();
}
for (auto& el : ctk.items()) {
body_json["chat_template_kwargs"][el.key()] = el.value();
}
}
} catch (const std::exception & e) {
SRV_WRN("failed to parse chat_template_kwargs metadata: %s\n", e.what());
}
body_json["chat_template_kwargs"]["enable_thinking"] = (et_it->second == "true");
}
// Pass reasoning_effort via chat_template_kwargs too: the lever
// jinja templates like gpt-oss (Harmony) / LFM2.5 read, distinct
// from enable_thinking which those templates ignore.
auto re_it = metadata.find("reasoning_effort");
if (re_it != metadata.end() && !re_it->second.empty()) {
if (!body_json.contains("chat_template_kwargs")) {
body_json["chat_template_kwargs"] = json::object();
}
body_json["chat_template_kwargs"]["reasoning_effort"] = re_it->second;
}
// Debug: Print full body_json before template processing (includes messages, tools, tool_choice, etc.)
@@ -2756,25 +2758,26 @@ public:
body_json["min_p"] = data["min_p"];
}
// Pass enable_thinking via chat_template_kwargs (where oaicompat_chat_params_parse reads it)
// Forward the chat_template_kwargs the Go layer resolved (model config
// chat_template_kwargs + per-request metadata: enable_thinking,
// reasoning_effort, preserve_thinking, ...). One generic merge replaces
// the previous per-key handling - new template levers need no C++ change.
const auto& predict_metadata = request->metadata();
auto predict_et_it = predict_metadata.find("enable_thinking");
if (predict_et_it != predict_metadata.end()) {
if (!body_json.contains("chat_template_kwargs")) {
body_json["chat_template_kwargs"] = json::object();
auto predict_ctk_it = predict_metadata.find("chat_template_kwargs");
if (predict_ctk_it != predict_metadata.end() && !predict_ctk_it->second.empty()) {
try {
json ctk = json::parse(predict_ctk_it->second);
if (ctk.is_object()) {
if (!body_json.contains("chat_template_kwargs")) {
body_json["chat_template_kwargs"] = json::object();
}
for (auto& el : ctk.items()) {
body_json["chat_template_kwargs"][el.key()] = el.value();
}
}
} catch (const std::exception & e) {
SRV_WRN("failed to parse chat_template_kwargs metadata: %s\n", e.what());
}
body_json["chat_template_kwargs"]["enable_thinking"] = (predict_et_it->second == "true");
}
// Pass reasoning_effort via chat_template_kwargs too: the lever
// jinja templates like gpt-oss (Harmony) / LFM2.5 read, distinct
// from enable_thinking which those templates ignore.
auto predict_re_it = predict_metadata.find("reasoning_effort");
if (predict_re_it != predict_metadata.end() && !predict_re_it->second.empty()) {
if (!body_json.contains("chat_template_kwargs")) {
body_json["chat_template_kwargs"] = json::object();
}
body_json["chat_template_kwargs"]["reasoning_effort"] = predict_re_it->second;
}
// Debug: Print full body_json before template processing (includes messages, tools, tool_choice, etc.)

9
backend/cpp/privacy-filter/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
/privacy-filter.cpp
build/
package/
grpc-server
*.o
backend.pb.cc
backend.pb.h
backend.grpc.pb.cc
backend.grpc.pb.h

View File

@@ -0,0 +1,69 @@
cmake_minimum_required(VERSION 3.21)
project(privacy-filter-grpc-server LANGUAGES CXX C)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(TARGET grpc-server)
# Path to the privacy-filter.cpp engine sources. The Makefile arranges for this
# to exist (clone of a pinned commit, or a symlink to PRIVACY_FILTER_SRC).
set(PRIVACY_FILTER_DIR "${CMAKE_CURRENT_SOURCE_DIR}/privacy-filter.cpp"
CACHE PATH "Path to the privacy-filter.cpp engine source tree")
find_package(Threads REQUIRED)
find_package(Protobuf CONFIG QUIET)
if(NOT Protobuf_FOUND)
find_package(Protobuf REQUIRED)
endif()
find_package(gRPC CONFIG QUIET)
if(NOT gRPC_FOUND)
# Ubuntu's apt-installed grpc++ does not ship a CMake config - fall back.
find_library(GRPCPP_LIB grpc++ REQUIRED)
find_library(GRPCPP_REFLECTION_LIB grpc++_reflection REQUIRED)
add_library(gRPC::grpc++ INTERFACE IMPORTED)
set_target_properties(gRPC::grpc++ PROPERTIES INTERFACE_LINK_LIBRARIES "${GRPCPP_LIB}")
add_library(gRPC::grpc++_reflection INTERFACE IMPORTED)
set_target_properties(gRPC::grpc++_reflection PROPERTIES INTERFACE_LINK_LIBRARIES "${GRPCPP_REFLECTION_LIB}")
endif()
find_program(_PROTOC NAMES protoc REQUIRED)
find_program(_GRPC_CPP_PLUGIN NAMES grpc_cpp_plugin REQUIRED)
get_filename_component(HW_PROTO "${CMAKE_CURRENT_SOURCE_DIR}/../../backend.proto" ABSOLUTE)
get_filename_component(HW_PROTO_PATH "${HW_PROTO}" PATH)
set(HW_PROTO_SRCS "${CMAKE_CURRENT_BINARY_DIR}/backend.pb.cc")
set(HW_PROTO_HDRS "${CMAKE_CURRENT_BINARY_DIR}/backend.pb.h")
set(HW_GRPC_SRCS "${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.cc")
set(HW_GRPC_HDRS "${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.h")
add_custom_command(
OUTPUT "${HW_PROTO_SRCS}" "${HW_PROTO_HDRS}" "${HW_GRPC_SRCS}" "${HW_GRPC_HDRS}"
COMMAND ${_PROTOC}
ARGS --grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
--cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
-I "${HW_PROTO_PATH}"
--plugin=protoc-gen-grpc="${_GRPC_CPP_PLUGIN}"
"${HW_PROTO}"
DEPENDS "${HW_PROTO}")
add_library(hw_grpc_proto STATIC
${HW_GRPC_SRCS} ${HW_GRPC_HDRS}
${HW_PROTO_SRCS} ${HW_PROTO_HDRS})
target_include_directories(hw_grpc_proto PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
# Build only the pf static lib (+ ggml) from the engine tree — no CLI/bench/tests.
# PF_VULKAN is honored when passed on the cmake command line (it lands in the
# shared cache the engine reads).
set(PF_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
set(PF_BUILD_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory(${PRIVACY_FILTER_DIR} ${CMAKE_CURRENT_BINARY_DIR}/privacy-filter.cpp)
add_executable(${TARGET} grpc-server.cpp)
target_link_libraries(${TARGET} PRIVATE
pf
hw_grpc_proto
gRPC::grpc++
gRPC::grpc++_reflection
protobuf::libprotobuf
Threads::Threads)

View File

@@ -0,0 +1,77 @@
# privacy-filter backend Makefile.
#
# Wraps the standalone privacy-filter.cpp GGML engine (the openai-privacy-filter
# PII/NER token classifier) as a LocalAI gRPC backend. The engine source is
# fetched at the pin below — .github/workflows/bump_deps.yaml finds and updates
# PRIVACY_FILTER_VERSION, matching the llama-cpp / ds4 convention.
#
# Local development: point at a working checkout instead of cloning, e.g.
# make PRIVACY_FILTER_SRC=$HOME/c/privacy-filter.cpp grpc-server
PRIVACY_FILTER_VERSION?=646342f7a59c6b7d195185eac60bad762e572f1d
PRIVACY_FILTER_REPO?=https://github.com/localai-org/privacy-filter.cpp
PRIVACY_FILTER_SRC?=
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
BUILD_DIR := build
BUILD_TYPE ?=
NATIVE ?= false
JOBS ?= $(shell nproc 2>/dev/null || echo 4)
CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
# GPU backends; the default (cpu) needs no extra flags. 'cublas' is LocalAI's
# name for the CUDA build (matches llama-cpp / ds4), mapping to the engine's
# GGML_CUDA path; 'vulkan' selects the ggml Vulkan backend.
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS += -DPF_CUDA=ON
endif
ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS += -DPF_VULKAN=ON
endif
# Portable binaries for distribution: disable -march=native unless asked.
ifneq ($(NATIVE),true)
CMAKE_ARGS += -DGGML_NATIVE=OFF
endif
.PHONY: grpc-server package clean purge test all
all: grpc-server
# Provide the engine sources at ./privacy-filter.cpp. With PRIVACY_FILTER_SRC
# set we symlink a local checkout (instant, no network); otherwise we clone the
# pinned commit and its ggml submodule. The directory/symlink is the target, so
# make only does this once — run 'make purge && make' to refetch after a bump.
privacy-filter.cpp:
ifneq ($(PRIVACY_FILTER_SRC),)
ln -sfn $(abspath $(PRIVACY_FILTER_SRC)) privacy-filter.cpp
else
mkdir -p privacy-filter.cpp
cd privacy-filter.cpp && \
git init -q && \
git remote add origin $(PRIVACY_FILTER_REPO) && \
git fetch --depth 1 origin $(PRIVACY_FILTER_VERSION) && \
git checkout FETCH_HEAD && \
git submodule update --init --recursive --depth 1
endif
grpc-server: privacy-filter.cpp
@echo "Building privacy-filter grpc-server ($(BUILD_TYPE)) with $(CMAKE_ARGS)"
mkdir -p $(BUILD_DIR)
cd $(BUILD_DIR) && cmake $(CMAKE_ARGS) $(CURRENT_MAKEFILE_DIR) && cmake --build . --config Release -j $(JOBS)
cp $(BUILD_DIR)/grpc-server grpc-server
package: grpc-server
bash package.sh
test:
@echo "privacy-filter backend: parity/regression coverage lives in the engine repo"
clean:
rm -rf $(BUILD_DIR) grpc-server package
# 'privacy-filter.cpp' may be a symlink (PRIVACY_FILTER_SRC) — rm without a
# trailing slash removes the link, never the linked-to checkout.
purge: clean
rm -rf privacy-filter.cpp

View File

@@ -0,0 +1,210 @@
// privacy-filter LocalAI gRPC backend.
//
// Thin shim over privacy-filter.cpp's flat C API (include/pf.h): a standalone
// GGML engine for the openai-privacy-filter token-classification model family
// (PII NER). It replaces the llama.cpp-patched TokenClassify path for this one
// model family — same GGUF files, no llama.cpp carry-patches.
//
// Only the RPCs the PII tier needs are implemented: LoadModel, TokenClassify,
// plus Health / Status / Free. Everything else inherits the generated base
// class default (UNIMPLEMENTED).
#include "backend.pb.h"
#include "backend.grpc.pb.h"
#include "pf.h"
#include <grpcpp/grpcpp.h>
#include <grpcpp/server.h>
#include <grpcpp/server_builder.h>
#include <grpcpp/ext/proto_server_reflection_plugin.h>
#include <atomic>
#include <chrono>
#include <csignal>
#include <iostream>
#include <memory>
#include <mutex>
#include <string>
using grpc::Server;
using grpc::ServerBuilder;
using grpc::ServerContext;
// NOTE: do NOT alias grpc::Status as Status — the Status RPC method below would
// shadow the type and break the other method signatures. Use GStatus instead.
using GStatus = ::grpc::Status;
using grpc::StatusCode;
namespace {
// The engine is single-model-per-process: LocalAI spawns one backend process
// per loaded model. g_mu guards (re)load against in-flight classification.
std::mutex g_mu;
pf_ctx * g_ctx = nullptr;
std::atomic<Server *> g_server{nullptr};
// Resolve the device string the engine expects ("cpu" / "gpu" / "cuda" /
// "vulkan", optionally ":N"). Priority: an explicit "device:..." in
// ModelOptions.Options, then a non-zero NGPULayers as a coarse "use the GPU"
// signal, else CPU. "gpu" lets the engine pick whichever GPU backend this
// binary was compiled with (CUDA or Vulkan), so the same config works on
// either build; pin "device:cuda"/"device:vulkan" to be explicit.
std::string resolve_device(const backend::ModelOptions * opts) {
for (const auto & o : opts->options()) {
const std::string prefix = "device:";
if (o.rfind(prefix, 0) == 0) {
return o.substr(prefix.size());
}
}
if (opts->ngpulayers() > 0) {
return "gpu";
}
return "cpu";
}
class PrivacyFilterBackend final : public backend::Backend::Service {
public:
GStatus Health(ServerContext *, const backend::HealthMessage *,
backend::Reply * reply) override {
reply->set_message("OK");
return GStatus::OK;
}
GStatus Status(ServerContext *, const backend::HealthMessage *,
backend::StatusResponse * response) override {
std::lock_guard<std::mutex> lock(g_mu);
response->set_state(g_ctx ? backend::StatusResponse::READY
: backend::StatusResponse::UNINITIALIZED);
return GStatus::OK;
}
GStatus LoadModel(ServerContext *, const backend::ModelOptions * request,
backend::Result * result) override {
std::lock_guard<std::mutex> lock(g_mu);
// ModelFile is the absolute path LocalAI resolves; Model is the bare
// name. Prefer the former, fall back to the latter.
const std::string path =
!request->modelfile().empty() ? request->modelfile() : request->model();
if (path.empty()) {
result->set_success(false);
result->set_message("no model path supplied");
return GStatus::OK;
}
const std::string device = resolve_device(request);
if (g_ctx) { pf_free(g_ctx); g_ctx = nullptr; }
pf_ctx * ctx = pf_load(path.c_str(), device.c_str(), request->threads());
const char * err = pf_last_error(ctx);
if (err) {
result->set_success(false);
result->set_message(std::string("privacy-filter load failed: ") + err);
pf_free(ctx);
return GStatus::OK;
}
// ContextSize, when set, becomes the per-forward window. The engine
// ignores values that are too small to window (<= 2*halo) and just
// runs a single forward, so passing it through is always safe.
if (request->contextsize() > 0) {
pf_set_window(ctx, request->contextsize());
}
g_ctx = ctx;
result->set_success(true);
result->set_message("privacy-filter loaded (" + device + ")");
return GStatus::OK;
}
GStatus TokenClassify(ServerContext *, const backend::TokenClassifyRequest * request,
backend::TokenClassifyResponse * response) override {
std::lock_guard<std::mutex> lock(g_mu);
if (!g_ctx) {
return GStatus(StatusCode::FAILED_PRECONDITION, "Model not loaded");
}
const std::string & text = request->text();
if (text.empty()) {
return GStatus::OK; // no text -> no entities
}
pf_entity * ents = nullptr;
size_t n = 0;
if (pf_classify(g_ctx, text.data(), text.size(), request->threshold(), &ents, &n) != 0) {
const char * err = pf_last_error(g_ctx);
return GStatus(StatusCode::INTERNAL,
std::string("TokenClassify failed: ") + (err ? err : "unknown"));
}
// Byte offsets are into the original UTF-8 text; the engine already
// applied the threshold and whitespace-trimmed span edges.
for (size_t i = 0; i < n; i++) {
backend::TokenClassifyEntity * ent = response->add_entities();
ent->set_entity_group(ents[i].label ? ents[i].label : "");
ent->set_start(ents[i].start);
ent->set_end(ents[i].end);
ent->set_score(ents[i].score);
ent->set_text(text.substr((size_t) ents[i].start,
(size_t) (ents[i].end - ents[i].start)));
}
pf_entities_free(ents, n);
return GStatus::OK;
}
GStatus Free(ServerContext *, const backend::HealthMessage *,
backend::Result * result) override {
std::lock_guard<std::mutex> lock(g_mu);
if (g_ctx) { pf_free(g_ctx); g_ctx = nullptr; }
result->set_success(true);
return GStatus::OK;
}
};
void RunServer(const std::string & addr) {
PrivacyFilterBackend service;
grpc::EnableDefaultHealthCheckService(true);
grpc::reflection::InitProtoReflectionServerBuilderPlugin();
ServerBuilder builder;
builder.AddListeningPort(addr, grpc::InsecureServerCredentials());
builder.RegisterService(&service);
builder.SetMaxReceiveMessageSize(64 * 1024 * 1024);
builder.SetMaxSendMessageSize(64 * 1024 * 1024);
std::unique_ptr<Server> server(builder.BuildAndStart());
if (!server) {
std::cerr << "privacy-filter grpc-server: failed to bind " << addr << "\n";
std::exit(1);
}
g_server = server.get();
std::cerr << "privacy-filter grpc-server listening on " << addr << "\n";
server->Wait();
}
void signal_handler(int) {
if (auto * srv = g_server.load()) {
srv->Shutdown(std::chrono::system_clock::now() + std::chrono::seconds(3));
}
}
} // namespace
int main(int argc, char * argv[]) {
std::string addr = "127.0.0.1:50051";
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
const std::string addr_flag = "--addr=";
if (a.rfind(addr_flag, 0) == 0) addr = a.substr(addr_flag.size());
else if (a == "--addr" && i + 1 < argc) addr = argv[++i];
else if (a == "--help" || a == "-h") {
std::cout << "Usage: grpc-server --addr=HOST:PORT\n";
return 0;
}
}
std::signal(SIGINT, signal_handler);
std::signal(SIGTERM, signal_handler);
RunServer(addr);
return 0;
}

View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Assemble package/ for the from-scratch backend image: the grpc-server binary,
# run.sh, the dynamic loader, and every shared library the binary needs.
set -e
CURDIR=$(dirname "$(realpath "$0")")
REPO_ROOT="${CURDIR}/../../.."
mkdir -p "$CURDIR/package/lib"
cp -avf "$CURDIR/grpc-server" "$CURDIR/package/"
cp -rfv "$CURDIR/run.sh" "$CURDIR/package/"
# The dynamic loader, renamed to lib/ld.so so run.sh can invoke it explicitly
# (makes the image independent of the host's glibc layout).
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$CURDIR/package/lib/ld.so"
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
cp -arfLv /lib/ld-linux-aarch64.so.1 "$CURDIR/package/lib/ld.so"
else
echo "package.sh: unknown architecture" >&2; exit 1
fi
# Bundle the binary's transitive shared deps (libstdc++, libgomp, and the apt
# grpc++/protobuf/absl stack) by walking ldd — robust to whichever of those are
# linked shared vs static. The loader line (no "=>") is skipped; ld.so above
# already covers it.
ldd "$CURDIR/grpc-server" | awk '$2 == "=>" && $3 ~ /^\// { print $3 }' | sort -u | \
while read -r so; do
[ -f "$so" ] && cp -arfLv "$so" "$CURDIR/package/lib/"
done
# Vulkan loader / GPU libs when building the GPU variant.
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "privacy-filter package contents:"
ls -lah "$CURDIR/package/" "$CURDIR/package/lib/"

View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Entry point for the privacy-filter backend image / BACKEND_BINARY mode.
set -e
CURDIR=$(dirname "$(realpath "$0")")
export LD_LIBRARY_PATH="$CURDIR/lib:$LD_LIBRARY_PATH"
if [ -f "$CURDIR/lib/ld.so" ]; then
exec "$CURDIR/lib/ld.so" "$CURDIR/grpc-server" "$@"
fi
exec "$CURDIR/grpc-server" "$@"

View File

@@ -67,7 +67,7 @@ sources/CrispASR:
# it, so ${CMAKE_SOURCE_DIR} is THIS backend dir and the talk-llama sources
# aren't found. Rewrite to ${PROJECT_SOURCE_DIR} (the crispasr project root),
# which is correct both standalone and as a subproject. Idempotent.
sed -i 's#\$${CMAKE_SOURCE_DIR}/examples/talk-llama#\$${PROJECT_SOURCE_DIR}/examples/talk-llama#' sources/CrispASR/src/CMakeLists.txt
sed -i.bak 's#\$${CMAKE_SOURCE_DIR}/examples/talk-llama#\$${PROJECT_SOURCE_DIR}/examples/talk-llama#' sources/CrispASR/src/CMakeLists.txt && rm -f sources/CrispASR/src/CMakeLists.txt.bak
# Detect OS
UNAME_S := $(shell uname -s)

View File

@@ -47,6 +47,74 @@ extern "C" void set_abort(int v) {
g_abort.store(v, std::memory_order_relaxed);
}
// --- word-level timestamp accessors ---
extern "C" {
int crispasr_session_result_n_words(crispasr_session_result *r, int seg_i);
const char *crispasr_session_result_word_text(crispasr_session_result *r,
int seg_i, int word_i);
int64_t crispasr_session_result_word_t0(crispasr_session_result *r, int seg_i,
int word_i);
int64_t crispasr_session_result_word_t1(crispasr_session_result *r, int seg_i,
int word_i);
// Parakeet-specific word accessors
int crispasr_parakeet_result_n_words(void *r);
const char *crispasr_parakeet_result_word_text(void *r, int word_i);
int64_t crispasr_parakeet_result_word_t0(void *r, int word_i);
int64_t crispasr_parakeet_result_word_t1(void *r, int word_i);
}
void *get_result(void) { return g_result; }
int get_word_count(int seg_i) {
if (!g_result)
return 0;
return crispasr_session_result_n_words(g_result, seg_i);
}
const char *get_word_text(int seg_i, int word_i) {
if (!g_result)
return "";
return crispasr_session_result_word_text(g_result, seg_i, word_i);
}
int64_t get_word_t0(int seg_i, int word_i) {
if (!g_result)
return 0;
return crispasr_session_result_word_t0(g_result, seg_i, word_i);
}
int64_t get_word_t1(int seg_i, int word_i) {
if (!g_result)
return 0;
return crispasr_session_result_word_t1(g_result, seg_i, word_i);
}
// Parakeet-specific word accessors
int get_parakeet_word_count(void) {
if (!g_result)
return 0;
return crispasr_parakeet_result_n_words(g_result);
}
const char *get_parakeet_word_text(int word_i) {
if (!g_result)
return "";
return crispasr_parakeet_result_word_text(g_result, word_i);
}
int64_t get_parakeet_word_t0(int word_i) {
if (!g_result)
return 0;
return crispasr_parakeet_result_word_t0(g_result, word_i);
}
int64_t get_parakeet_word_t1(int word_i) {
if (!g_result)
return 0;
return crispasr_parakeet_result_word_t1(g_result, word_i);
}
static void ggml_log_cb(enum ggml_log_level level, const char *log,
void *data) {
const char *level_str;

View File

@@ -20,4 +20,18 @@ float *tts_synthesize(const char *text, int *out_n_samples); // 24kHz mono float
void tts_free(float *pcm);
int tts_set_voice(const char *name); // best-effort speaker selection; 0 ok
int tts_set_voice_file(const char *path, const char *ref_text); // load voice pack (.gguf) or zero-shot clone (.wav + ref_text)
// --- word-level timestamp accessors ---
// Session-based (works for whisper-like backends)
void *get_result(void);
int get_word_count(int seg_i);
const char *get_word_text(int seg_i, int word_i);
int64_t get_word_t0(int seg_i, int word_i);
int64_t get_word_t1(int seg_i, int word_i);
// Parakeet-specific (global word list, no segment index)
int get_parakeet_word_count(void);
const char *get_parakeet_word_text(int word_i);
int64_t get_parakeet_word_t0(int word_i);
int64_t get_parakeet_word_t1(int word_i);
}

View File

@@ -34,6 +34,18 @@ var (
CppTTSFree func(ptr uintptr)
CppTTSSetVoice func(name string) int
CppTTSSetVoiceFile func(path string, refText string) int
// Word-level timestamp accessors (session-based, per-segment)
CppGetWordCount func(segI int) int
CppGetWordText func(segI int, wordI int) string
CppGetWordT0 func(segI int, wordI int) int64
CppGetWordT1 func(segI int, wordI int) int64
// Parakeet-specific word accessors (global, no segment index)
CppGetParakeetWordCount func() int
CppGetParakeetWordText func(wordI int) string
CppGetParakeetWordT0 func(wordI int) int64
CppGetParakeetWordT1 func(wordI int) int64
)
type CrispASR struct {
@@ -290,10 +302,36 @@ func (w *CrispASR) AudioTranscription(ctx context.Context, opts *pb.TranscriptRe
// IDs, so Tokens is left empty.
txt := strings.ToValidUTF8(strings.Clone(CppGetSegmentText(i)), "<22>")
// Populate word-level timestamps. Try session-based functions first
// (per-segment); fall back to parakeet-specific functions (global word
// list with no segment index — only populated on the first segment to
// avoid duplication).
words := []*pb.TranscriptWord{}
wordCount := CppGetWordCount(i)
if wordCount == 0 && i == 0 {
wordCount = CppGetParakeetWordCount()
for j := 0; j < wordCount; j++ {
words = append(words, &pb.TranscriptWord{
Start: CppGetParakeetWordT0(j) * (10000000),
End: CppGetParakeetWordT1(j) * (10000000),
Text: strings.ToValidUTF8(strings.Clone(CppGetParakeetWordText(j)), "<22>"),
})
}
} else {
for j := 0; j < wordCount; j++ {
words = append(words, &pb.TranscriptWord{
Start: CppGetWordT0(i, j) * (10000000),
End: CppGetWordT1(i, j) * (10000000),
Text: strings.ToValidUTF8(strings.Clone(CppGetWordText(i, j)), "<22>"),
})
}
}
segment := &pb.TranscriptSegment{
Id: int32(i),
Text: txt,
Start: s, End: t,
Words: words,
}
segments = append(segments, segment)

View File

@@ -44,6 +44,14 @@ func main() {
{&CppTTSFree, "tts_free"},
{&CppTTSSetVoice, "tts_set_voice"},
{&CppTTSSetVoiceFile, "tts_set_voice_file"},
{&CppGetWordCount, "get_word_count"},
{&CppGetWordText, "get_word_text"},
{&CppGetWordT0, "get_word_t0"},
{&CppGetWordT1, "get_word_t1"},
{&CppGetParakeetWordCount, "get_parakeet_word_count"},
{&CppGetParakeetWordText, "get_parakeet_word_text"},
{&CppGetParakeetWordT0, "get_parakeet_word_t0"},
{&CppGetParakeetWordT1, "get_parakeet_word_t1"},
}
for _, lf := range libFuncs {

View File

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

View File

@@ -0,0 +1,28 @@
cmake_minimum_required(VERSION 3.18)
project(libdepthanythingcpp LANGUAGES C CXX)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Static-link ggml into the depth-anything shared library so the resulting .so
# has no runtime dependency on an external libggml — 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)
# depth-anything.cpp build switches: skip CLI/tests, but build libdepthanything
# itself as a SHARED library (DA_SHARED) while ggml stays static
# (BUILD_SHARED_LIBS OFF above). The da_capi_* C ABI is compiled into
# src/da_capi.cpp and re-exported by that shared library, so no extra MODULE
# wrapper is needed (unlike locate-anything.cpp).
set(DA_BUILD_CLI OFF CACHE BOOL "Disable depth-anything CLI" FORCE)
set(DA_BUILD_TESTS OFF CACHE BOOL "Disable depth-anything tests" FORCE)
set(DA_SHARED ON CACHE BOOL "Build libdepthanything as a shared lib" FORCE)
add_subdirectory(./sources/depth-anything.cpp)
# Emit libdepthanything.so into the top-level build dir so the Makefile can
# rename it to the per-variant libdepthanythingcpp-<variant>.so.
set_target_properties(depthanything PROPERTIES
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})

View File

@@ -0,0 +1,139 @@
CMAKE_ARGS?=
BUILD_TYPE?=
NATIVE?=false
GOCMD?=go
GO_TAGS?=
JOBS?=$(shell nproc --ignore=1)
# depth-anything.cpp. Pin to a specific commit for a stable build; a squash
# merge upstream can orphan a branch, so the native version is pinned by SHA.
# This SHA adds the nested two-file metric C-API (abi_version 4,
# da_capi_load_nested) required by the depth-anything-3-nested gallery model;
# tag it (e.g. v0.1.3) upstream to keep the SHA alive.
DEPTHANYTHING_REPO?=https://github.com/mudler/depth-anything.cpp.git
DEPTHANYTHING_VERSION?=cce5edc395fd1843806093d7ccc0c8b0d0b97b72
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
endif
# Forward LocalAI's BUILD_TYPE to the matching ggml backend switch. depth-anything.cpp
# force-sets GGML_CUDA/GGML_VULKAN/GGML_METAL from its own DA_GGML_* options, so
# those must be toggled via the DA_GGML_* names (a bare -DGGML_CUDA=ON would be
# overridden); the remaining ggml switches pass straight through.
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS+=-DGGML_CUDA=ON -DDA_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 -DDA_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+=-DDA_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/depth-anything.cpp:
mkdir -p sources && \
git clone --recursive $(DEPTHANYTHING_REPO) sources/depth-anything.cpp && \
cd sources/depth-anything.cpp && \
git checkout $(DEPTHANYTHING_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 = libdepthanythingcpp-avx.so libdepthanythingcpp-avx2.so libdepthanythingcpp-avx512.so libdepthanythingcpp-fallback.so
else
# On non-Linux (e.g., Darwin), build only fallback variant
VARIANT_TARGETS = libdepthanythingcpp-fallback.so
endif
depth-anything-cpp: main.go godepthanythingcpp.go $(VARIANT_TARGETS)
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o depth-anything-cpp ./
package: depth-anything-cpp
bash package.sh
build: package
clean: purge
rm -rf libdepthanythingcpp*.so depth-anything-cpp package sources
purge:
rm -rf build*
# Build all variants (Linux only)
ifeq ($(UNAME_S),Linux)
libdepthanythingcpp-avx.so: sources/depth-anything.cpp
rm -rfv build-$@
$(info ${GREEN}I depth-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) libdepthanythingcpp-custom
rm -rfv build-$@
libdepthanythingcpp-avx2.so: sources/depth-anything.cpp
rm -rfv build-$@
$(info ${GREEN}I depth-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) libdepthanythingcpp-custom
rm -rfv build-$@
libdepthanythingcpp-avx512.so: sources/depth-anything.cpp
rm -rfv build-$@
$(info ${GREEN}I depth-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) libdepthanythingcpp-custom
rm -rfv build-$@
endif
# Build fallback variant (all platforms)
libdepthanythingcpp-fallback.so: sources/depth-anything.cpp
rm -rfv build-$@
$(info ${GREEN}I depth-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) libdepthanythingcpp-custom
rm -rfv build-$@
libdepthanythingcpp-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)/libdepthanything.so ./$(SO_TARGET)
all: depth-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 a small GGUF + a test image and
# exercises the gRPC Load/Predict wire path via the Go smoke test in
# main_test.go.
test: depth-anything-cpp libdepthanythingcpp-fallback.so
bash test.sh

View File

@@ -0,0 +1,556 @@
package main
// godepthanythingcpp.go - gRPC handlers (Load, Predict, GenerateImage) for the
// depth-anything-cpp backend, wrapping the Depth Anything 3 ggml C-API
// (libdepthanythingcpp-<variant>.so) via purego.
//
// Embeds base.SingleThread to default the unimplemented RPCs to "not supported"
// and to serialize calls — the C side shares a ggml graph allocator and is NOT
// reentrant, so all inference must run one-at-a-time.
//
// Depth has no native OpenAI endpoint, so the model is exposed two ways:
//
// - GenerateImage(src, dst): run depth on the src image and write a
// min-max-normalised grayscale depth PNG to dst.
// - Predict(images[0]): run depth+pose and return a JSON blob with the depth
// dimensions, depth stats and the camera extrinsics (3x4) / intrinsics (3x3).
import (
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/png"
"math"
"os"
"path/filepath"
"strings"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// C-API function pointers, registered in main.go via purego. The da_capi_*
// symbols live inside libdepthanything (src/da_capi.cpp) and are re-exported by
// the DA_SHARED build.
var (
// da_capi_load(const char* gguf_path, int n_threads) -> da_ctx* (0 = fail)
CapiLoad func(gguf string, nThreads int32) uintptr
// da_capi_load_nested(const char* anyview_gguf, const char* metric_gguf,
// int n_threads) -> da_ctx* (0 = fail). The returned ctx serves the nested
// metric model: depth/pose calls produce final metric-scale depth + scaled pose.
CapiLoadNested func(anyview string, metric string, nThreads int32) uintptr
// da_capi_free(da_ctx* ctx) — safe on a 0 handle.
CapiFree func(handle uintptr)
// da_capi_last_error(da_ctx* ctx) -> const char* (owned by ctx, "" if none).
// purego marshals the returned C string into a Go string (a copy), so we
// never free it.
CapiLastError func(handle uintptr) string
// da_capi_depth_path(ctx, image_path, out_h*, out_w*) -> float* depth map
// (row-major H*W); nil on error. Caller frees via da_capi_free_floats.
CapiDepthPath func(handle uintptr, imagePath string, outH *int32, outW *int32) *float32
// da_capi_free_floats(float* p)
CapiFreeFloats func(p *float32)
// da_capi_pose_path(ctx, image_path, out_ext[12], out_intr[9]) -> 0 ok, -1 err
CapiPosePath func(handle uintptr, imagePath string, outExt *float32, outIntr *float32) int32
// da_capi_depth_dense(ctx, image_path, out_h*, out_w*, out_depth**, out_conf**,
// out_sky**, out_ext[12], out_intr[9], out_is_metric*) -> 0 ok, -1 err.
// Each non-NULL out_depth/out_conf/out_sky receives a malloc'd float[H*W] (free
// via da_capi_free_floats); buffers the model doesn't produce are set NULL.
CapiDepthDense func(handle uintptr, imagePath string,
outH, outW *int32,
outDepth, outConf, outSky **float32,
outExt, outIntr *float32,
outIsMetric *int32) int32
// da_capi_points(ctx, image_path, conf_thresh, out_n*, out_xyz**, out_rgb**) ->
// 0 ok, -1 err. *out_xyz = malloc'd float[3*N] (free via da_capi_free_floats),
// *out_rgb = malloc'd uint8[3*N] (free via da_capi_free_bytes).
CapiPoints func(handle uintptr, imagePath string, confThresh float32,
outN *int32, outXyz **float32, outRgb **byte) int32
// da_capi_free_bytes(unsigned char* p)
CapiFreeBytes func(p *byte)
// da_capi_export_glb(ctx, image_path, out_glb) -> 0 ok, -1 err
CapiExportGlb func(handle uintptr, imagePath string, outGlb string) int32
// da_capi_export_colmap(ctx, image_path, out_dir, binary) -> 0 ok, -1 err
CapiExportColmap func(handle uintptr, imagePath string, outDir string, binary int32) int32
)
type DepthAnythingCpp struct {
base.SingleThread
handle uintptr
}
// Load loads the GGUF model at opts.ModelFile (joined with opts.ModelPath if
// relative) and stores the da_ctx handle for later inference calls.
func (r *DepthAnythingCpp) Load(opts *pb.ModelOptions) error {
modelFile := opts.ModelFile
if modelFile == "" {
modelFile = opts.Model
}
if modelFile == "" {
return fmt.Errorf("depth-anything-cpp: ModelFile is empty")
}
resolve := func(name string) string {
if filepath.IsAbs(name) {
return name
}
return filepath.Join(opts.ModelPath, name)
}
modelPath := resolve(modelFile)
if _, err := os.Stat(modelPath); err != nil {
return fmt.Errorf("depth-anything-cpp: model file not found: %s: %w", modelPath, err)
}
// Nested metric models are a two-file pair: the main model is the anyview
// (GIANT) branch and the metric (ViT-L + DPT/sky) branch is named via a
// "metric_model:<filename>" entry in opts.Options. When present we load both
// branches so the engine runs the nested metric alignment.
metricFile := optionValue(opts.Options, "metric_model")
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
}
var h uintptr
if metricFile != "" {
metricPath := resolve(metricFile)
if _, err := os.Stat(metricPath); err != nil {
return fmt.Errorf("depth-anything-cpp: metric_model file not found: %s: %w", metricPath, err)
}
h = CapiLoadNested(modelPath, metricPath, threads)
if h == 0 {
if msg := CapiLastError(0); msg != "" {
return fmt.Errorf("depth-anything-cpp: da_capi_load_nested failed for %s + %s: %s", modelPath, metricPath, msg)
}
return fmt.Errorf("depth-anything-cpp: da_capi_load_nested failed for %s + %s", modelPath, metricPath)
}
} else {
h = CapiLoad(modelPath, threads)
if h == 0 {
// da_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.
if msg := CapiLastError(0); msg != "" {
return fmt.Errorf("depth-anything-cpp: da_capi_load failed for %s: %s", modelPath, msg)
}
return fmt.Errorf("depth-anything-cpp: da_capi_load failed for %s", modelPath)
}
}
r.handle = h
return nil
}
// optionValue returns the value of the first "key:value" entry in opts whose key
// matches (case-sensitive), or "" if absent. Mirrors how other LocalAI backends
// read ModelOptions.Options.
func optionValue(opts []string, key string) string {
prefix := key + ":"
for _, o := range opts {
if strings.HasPrefix(o, prefix) {
return strings.TrimSpace(o[len(prefix):])
}
}
return ""
}
// depthResult is the JSON payload returned by Predict.
type depthResult struct {
DepthW int `json:"depth_w"`
DepthH int `json:"depth_h"`
DepthMin float32 `json:"depth_min"`
DepthMax float32 `json:"depth_max"`
Extrinsics [12]float32 `json:"extrinsics"` // 3x4 row-major
Intrinsics [9]float32 `json:"intrinsics"` // 3x3 row-major
}
// Predict runs depth+pose on the first supplied image and returns depth
// statistics + camera pose as a JSON string. LocalAI wraps the string into the
// Reply.Message of the gRPC response. The image in Images[0] may be a
// filesystem path or a base64-encoded payload.
func (r *DepthAnythingCpp) Predict(opts *pb.PredictOptions) (string, error) {
imgs := opts.GetImages()
if len(imgs) == 0 {
return "", fmt.Errorf("depth-anything-cpp: Predict requires an image in Images[]")
}
imgPath, cleanup, err := materializeImage(imgs[0])
if err != nil {
return "", fmt.Errorf("depth-anything-cpp: %w", err)
}
defer cleanup()
depth, h, w, ext, intr, err := r.runDepthPose(imgPath)
if err != nil {
return "", err
}
dmin, dmax := minMax(depth)
payload, err := json.Marshal(depthResult{
DepthW: w, DepthH: h,
DepthMin: dmin, DepthMax: dmax,
Extrinsics: ext, Intrinsics: intr,
})
if err != nil {
return "", fmt.Errorf("depth-anything-cpp: marshal: %w", err)
}
return string(payload), nil
}
// GenerateImage runs depth on req.Src and writes a normalised grayscale depth
// PNG to req.Dst.
func (r *DepthAnythingCpp) GenerateImage(req *pb.GenerateImageRequest) error {
if req.GetSrc() == "" {
return fmt.Errorf("depth-anything-cpp: GenerateImage requires src")
}
if req.GetDst() == "" {
return fmt.Errorf("depth-anything-cpp: GenerateImage requires dst")
}
imgPath, cleanup, err := materializeImage(req.GetSrc())
if err != nil {
return fmt.Errorf("depth-anything-cpp: %w", err)
}
defer cleanup()
depth, h, w, _, _, err := r.runDepthPose(imgPath)
if err != nil {
return err
}
return writeDepthPNG(req.GetDst(), depth, h, w)
}
// Depth is the typed Depth RPC. It runs the Depth Anything 3 pipeline on the
// request's src image and fills a DepthResponse honoring the include_* flags and
// exports: per-pixel metric depth + confidence (DualDPT) or depth + sky (mono),
// camera extrinsics/intrinsics, an optional back-projected 3D point cloud and
// glb/COLMAP exports. The src may be a filesystem path or a base64 payload.
func (r *DepthAnythingCpp) Depth(in *pb.DepthRequest) (pb.DepthResponse, error) {
// Accumulate into locals and return a single composite literal at the end:
// returning a named pb.DepthResponse value would copy its embedded mutex
// (go vet copylocks).
if r.handle == 0 {
return pb.DepthResponse{}, fmt.Errorf("depth-anything-cpp: model not loaded")
}
if in.GetSrc() == "" {
return pb.DepthResponse{}, fmt.Errorf("depth-anything-cpp: Depth requires src")
}
imgPath, cleanup, err := materializeImage(in.GetSrc())
if err != nil {
return pb.DepthResponse{}, fmt.Errorf("depth-anything-cpp: %w", err)
}
defer cleanup()
// Dense per-pixel output + pose. Pass buffer pointers only for the
// requested maps so the native side can skip unrequested work; ext/intr
// must always point at 12/9 floats per the C ABI.
var (
h, w, isMetric int32
depthPtr, confPtr *float32
skyPtr *float32
ext [12]float32
intr [9]float32
pDepth, pConf, pSky **float32
)
if in.GetIncludeDepth() {
pDepth = &depthPtr
}
if in.GetIncludeConfidence() {
pConf = &confPtr
}
if in.GetIncludeSky() {
pSky = &skyPtr
}
rc := CapiDepthDense(r.handle, imgPath, &h, &w, pDepth, pConf, pSky, &ext[0], &intr[0], &isMetric)
if rc != 0 {
return pb.DepthResponse{}, fmt.Errorf("depth-anything-cpp: da_capi_depth_dense failed (rc=%d): %s", rc, r.lastError())
}
n := int(h) * int(w)
var (
depth, conf, sky []float32
extrinsics, intrinsic []float32
numPoints int32
points []float32
pointColors []byte
exportPaths []string
)
if depthPtr != nil {
depth = copyFloats(depthPtr, n)
CapiFreeFloats(depthPtr)
}
if confPtr != nil {
conf = copyFloats(confPtr, n)
CapiFreeFloats(confPtr)
}
if skyPtr != nil {
sky = copyFloats(skyPtr, n)
CapiFreeFloats(skyPtr)
}
if in.GetIncludePose() {
extrinsics = append([]float32(nil), ext[:]...)
intrinsic = append([]float32(nil), intr[:]...)
}
// 3D point cloud (DualDPT / pose-capable models only).
if in.GetIncludePoints() {
var (
np int32
xyzPtr *float32
rgbPtr *byte
)
if rc := CapiPoints(r.handle, imgPath, in.GetPointsConfThresh(), &np, &xyzPtr, &rgbPtr); rc != 0 {
return pb.DepthResponse{}, fmt.Errorf("depth-anything-cpp: da_capi_points failed (rc=%d): %s", rc, r.lastError())
}
numPoints = np
if xyzPtr != nil {
points = copyFloats(xyzPtr, int(np)*3)
CapiFreeFloats(xyzPtr)
}
if rgbPtr != nil {
pointColors = copyBytes(rgbPtr, int(np)*3)
CapiFreeBytes(rgbPtr)
}
}
// Exports (glb / colmap). They are written under in.Dst (a directory); a
// temp dir is used when Dst is empty.
if len(in.GetExports()) > 0 {
exportPaths, err = r.runExports(imgPath, in.GetDst(), in.GetExports())
if err != nil {
return pb.DepthResponse{}, err
}
}
return pb.DepthResponse{
Width: w,
Height: h,
Depth: depth,
Confidence: conf,
Sky: sky,
Extrinsics: extrinsics,
Intrinsics: intrinsic,
NumPoints: numPoints,
Points: points,
PointColors: pointColors,
ExportPaths: exportPaths,
IsMetric: isMetric != 0,
}, nil
}
// runExports writes the requested exports for imgPath into dstDir and returns
// the written paths. Supported exports: "glb", "colmap".
func (r *DepthAnythingCpp) runExports(imgPath, dstDir string, exports []string) ([]string, error) {
if dstDir == "" {
tmp, err := os.MkdirTemp("", "depth-anything-export-*")
if err != nil {
return nil, fmt.Errorf("depth-anything-cpp: mkdir export dir: %w", err)
}
dstDir = tmp
} else if err := os.MkdirAll(dstDir, 0o750); err != nil {
return nil, fmt.Errorf("depth-anything-cpp: mkdir %s: %w", dstDir, err)
}
var paths []string
for _, exp := range exports {
switch exp {
case "glb":
out := filepath.Join(dstDir, "pointcloud.glb")
if rc := CapiExportGlb(r.handle, imgPath, out); rc != 0 {
return nil, fmt.Errorf("depth-anything-cpp: da_capi_export_glb failed (rc=%d): %s", rc, r.lastError())
}
paths = append(paths, out)
case "colmap":
out := filepath.Join(dstDir, "colmap")
if err := os.MkdirAll(out, 0o750); err != nil {
return nil, fmt.Errorf("depth-anything-cpp: mkdir %s: %w", out, err)
}
if rc := CapiExportColmap(r.handle, imgPath, out, 1); rc != 0 {
return nil, fmt.Errorf("depth-anything-cpp: da_capi_export_colmap failed (rc=%d): %s", rc, r.lastError())
}
paths = append(paths, out)
default:
return nil, fmt.Errorf("depth-anything-cpp: unknown export %q (want glb|colmap)", exp)
}
}
return paths, nil
}
// copyFloats copies n float32 values from a C heap pointer into a fresh Go
// slice so the C buffer can be freed afterwards.
func copyFloats(p *float32, n int) []float32 {
if p == nil || n <= 0 {
return nil
}
src := unsafe.Slice(p, n)
out := make([]float32, n)
copy(out, src)
return out
}
// copyBytes copies n bytes from a C heap pointer into a fresh Go slice.
func copyBytes(p *byte, n int) []byte {
if p == nil || n <= 0 {
return nil
}
src := unsafe.Slice(p, n)
out := make([]byte, n)
copy(out, src)
return out
}
// runDepthPose runs depth estimation then pose recovery on an image file. It
// returns the row-major depth map (length h*w), its dimensions, the 3x4
// extrinsics (12 floats) and 3x3 intrinsics (9 floats).
// runDepthPose returns depth + camera pose via two C-API calls (depth then pose).
// For a nested metric model both calls run the full two-branch pipeline, so this
// path infers twice; the typed Depth RPC (single da_capi_depth_dense call) is the
// efficient path for nested models.
func (r *DepthAnythingCpp) runDepthPose(imagePath string) (depth []float32, h, w int, ext [12]float32, intr [9]float32, err error) {
if r.handle == 0 {
err = fmt.Errorf("depth-anything-cpp: model not loaded")
return
}
var ch, cw int32
ptr := CapiDepthPath(r.handle, imagePath, &ch, &cw)
if ptr == nil {
err = fmt.Errorf("depth-anything-cpp: da_capi_depth_path failed: %s", r.lastError())
return
}
h, w = int(ch), int(cw)
n := h * w
if n > 0 {
src := unsafe.Slice(ptr, n)
depth = make([]float32, n)
copy(depth, src)
}
CapiFreeFloats(ptr)
if rc := CapiPosePath(r.handle, imagePath, &ext[0], &intr[0]); rc != 0 {
err = fmt.Errorf("depth-anything-cpp: da_capi_pose_path failed (rc=%d): %s", rc, r.lastError())
return
}
return
}
// lastError returns the context's last error string, or "" if none.
func (r *DepthAnythingCpp) lastError() string {
if CapiLastError == nil || r.handle == 0 {
return ""
}
return CapiLastError(r.handle)
}
// materializeImage returns a filesystem path for an image argument that may be
// either an existing path or a base64-encoded payload. When the input is
// base64 it is decoded into a temp file; cleanup removes it (no-op for a path).
func materializeImage(arg string) (path string, cleanup func(), err error) {
cleanup = func() {}
if _, statErr := os.Stat(arg); statErr == nil {
return arg, cleanup, nil
}
// Strip an optional data URL prefix (data:image/...;base64,<payload>).
b64 := arg
if i := indexComma(b64); i >= 0 && hasDataPrefix(b64) {
b64 = b64[i+1:]
}
data, decErr := base64.StdEncoding.DecodeString(b64)
if decErr != nil {
return "", cleanup, fmt.Errorf("image is neither an existing path nor valid base64: %v", decErr)
}
f, tErr := os.CreateTemp("", "depth-anything-*.img")
if tErr != nil {
return "", cleanup, tErr
}
if _, wErr := f.Write(data); wErr != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", cleanup, wErr
}
_ = f.Close()
name := f.Name()
return name, func() { _ = os.Remove(name) }, nil
}
func hasDataPrefix(s string) bool {
return len(s) >= 5 && s[:5] == "data:"
}
func indexComma(s string) int {
for i := 0; i < len(s); i++ {
if s[i] == ',' {
return i
}
}
return -1
}
// writeDepthPNG min-max normalises a depth map and writes it as an 8-bit
// grayscale PNG. Near = bright (255), far = dark (0), matching the usual
// depth-map convention for inverse-depth-like outputs.
func writeDepthPNG(dst string, depth []float32, h, w int) error {
if h <= 0 || w <= 0 || len(depth) < h*w {
return fmt.Errorf("depth-anything-cpp: writeDepthPNG: bad dims h=%d w=%d len=%d", h, w, len(depth))
}
dmin, dmax := minMax(depth)
span := dmax - dmin
if span <= 0 || math.IsNaN(float64(span)) {
span = 1
}
img := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
v := depth[y*w+x]
n := (v - dmin) / span // 0..1
if math.IsNaN(float64(n)) {
n = 0
}
if n < 0 {
n = 0
} else if n > 1 {
n = 1
}
img.Pix[y*img.Stride+x] = uint8(n * 255)
}
}
// dst is the gRPC-provided output path chosen by the LocalAI core (the
// intended write destination for the rendered depth map), not
// attacker-controlled input, so the variable path is expected here.
f, err := os.Create(dst) // #nosec G304
if err != nil {
return err
}
defer func() { _ = f.Close() }()
return png.Encode(f, img)
}
func minMax(v []float32) (mn, mx float32) {
if len(v) == 0 {
return 0, 0
}
mn, mx = v[0], v[0]
for _, x := range v {
if math.IsNaN(float64(x)) || math.IsInf(float64(x), 0) {
continue
}
if x < mn {
mn = x
}
if x > mx {
mx = x
}
}
return mn, mx
}

View File

@@ -0,0 +1,62 @@
package main
// main.go - entry point for the depth-anything-cpp gRPC backend.
//
// Dlopens libdepthanythingcpp-<variant>.so via purego at the path in
// DEPTHANYTHING_LIBRARY (set by run.sh based on /proc/cpuinfo), registers the
// da_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("DEPTHANYTHING_LIBRARY")
if libName == "" {
libName = "./libdepthanythingcpp-fallback.so"
}
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
panic(err)
}
libFuncs := []LibFuncs{
{&CapiLoad, "da_capi_load"},
{&CapiLoadNested, "da_capi_load_nested"},
{&CapiFree, "da_capi_free"},
{&CapiLastError, "da_capi_last_error"},
{&CapiDepthPath, "da_capi_depth_path"},
{&CapiFreeFloats, "da_capi_free_floats"},
{&CapiPosePath, "da_capi_pose_path"},
{&CapiDepthDense, "da_capi_depth_dense"},
{&CapiPoints, "da_capi_points"},
{&CapiFreeBytes, "da_capi_free_bytes"},
{&CapiExportGlb, "da_capi_export_glb"},
{&CapiExportColmap, "da_capi_export_colmap"},
}
for _, lf := range libFuncs {
purego.RegisterLibFunc(lf.FuncPtr, lib, lf.Name)
}
flag.Parse()
if err := grpc.StartServer(*addr, &DepthAnythingCpp{}); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,167 @@
package main
// main_test.go - end-to-end smoke test for the depth-anything-cpp gRPC backend.
//
// Spawns the compiled depth-anything-cpp binary on a free local port, dials it
// via gRPC, and exercises LoadModel + Predict against the test fixtures
// downloaded by test.sh: the small (vits) f32 GGUF of Depth Anything 3 and a
// real photo. Asserts that Predict returns a JSON payload with a positive
// depth-map width/height.
//
// The spec Skip()s cleanly if its fixtures (the 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 model hasn't been
// downloaded.
import (
"context"
"encoding/base64"
"encoding/json"
"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 TestDepth(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "depth-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 depth-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 DEPTHANYTHING_LIBRARY env var points the dlopen at the
// freshly built fallback .so. The returned cleanup func kills the process.
func startBackend(port int) func() {
binary, err := filepath.Abs("./depth-anything-cpp")
Expect(err).ToNot(HaveOccurred())
if _, err := os.Stat(binary); err != nil {
Skip(fmt.Sprintf("backend binary not built: %s (run `make depth-anything-cpp` first)", binary))
}
libPath, err := filepath.Abs("./libdepthanythingcpp-fallback.so")
Expect(err).ToNot(HaveOccurred())
if _, err := os.Stat(libPath); err != nil {
Skip(fmt.Sprintf("fallback library not built: %s (run `make libdepthanythingcpp-fallback.so` first)", libPath))
}
addr := fmt.Sprintf("127.0.0.1:%d", port)
cmd := exec.Command(binary, "--addr", addr)
cmd.Env = append(os.Environ(), "DEPTHANYTHING_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 test image downloaded by test.sh and returns its
// base64-encoded content (one of the wire formats accepted by Predict).
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 (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("depth-anything-cpp backend", func() {
It("runs depth+pose against a known-good image", func() {
modelPath := modelPathOrSkip("depth-anything-small-f32.gguf")
imgB64 := loadTestImage()
port := freePort()
cleanup := startBackend(port)
defer cleanup()
client, closeConn := dialBackend(port)
defer closeConn()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
loadResp, err := client.LoadModel(ctx, &pb.ModelOptions{
Model: "depth-anything-small-f32.gguf",
ModelFile: modelPath,
Threads: 4,
})
Expect(err).ToNot(HaveOccurred(), "LoadModel")
Expect(loadResp.GetSuccess()).To(BeTrue(), "LoadModel reported failure: %s", loadResp.GetMessage())
// Predict runs depth+pose and returns the JSON depthResult in Reply.Message.
reply, err := client.Predict(ctx, &pb.PredictOptions{
Images: []string{imgB64},
})
Expect(err).ToNot(HaveOccurred(), "Predict")
var res depthResult
Expect(json.Unmarshal(reply.GetMessage(), &res)).To(Succeed(), "Predict returned non-JSON: %q", string(reply.GetMessage()))
Expect(res.DepthW).To(BeNumerically(">", 0), "depth width should be positive")
Expect(res.DepthH).To(BeNumerically(">", 0), "depth height should be positive")
_, _ = fmt.Fprintf(GinkgoWriter, "depth OK: %dx%d min=%.3f max=%.3f\n",
res.DepthW, res.DepthH, res.DepthMin, res.DepthMax)
})
})

View File

@@ -0,0 +1,64 @@
package main
// nested_e2e_test.go - e2e smoke for the nested two-file metric model. Loads the
// anyview branch as the main model and points the metric branch via the
// "metric_model:<file>" option (exactly as the depth-anything-3-nested gallery
// entry does), then exercises the typed Depth RPC and asserts a metric depth map.
//
// Skips cleanly unless both nested GGUFs are present under ./test-models/ and the
// backend binary + fallback .so are built.
import (
"context"
"fmt"
"path/filepath"
"time"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("depth-anything-cpp nested metric model", func() {
It("loads the two-file pair via the metric_model option and returns metric depth", func() {
anyviewPath := modelPathOrSkip("depth-anything-nested-anyview.gguf")
_ = modelPathOrSkip("depth-anything-nested-metric.gguf")
imgB64 := loadTestImage()
port := freePort()
cleanup := startBackend(port)
defer cleanup()
client, closeConn := dialBackend(port)
defer closeConn()
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Minute)
defer cancel()
loadResp, err := client.LoadModel(ctx, &pb.ModelOptions{
Model: "depth-anything-nested-anyview.gguf",
ModelFile: anyviewPath,
ModelPath: filepath.Dir(anyviewPath),
Options: []string{"metric_model:depth-anything-nested-metric.gguf"},
Threads: 8,
})
Expect(err).ToNot(HaveOccurred(), "LoadModel(nested)")
Expect(loadResp.GetSuccess()).To(BeTrue(), "LoadModel reported failure: %s", loadResp.GetMessage())
resp, err := client.Depth(ctx, &pb.DepthRequest{
Src: imgB64,
IncludeDepth: true,
IncludePose: true,
})
Expect(err).ToNot(HaveOccurred(), "Depth(nested)")
Expect(resp.GetWidth()).To(BeNumerically(">", 0), "depth width")
Expect(resp.GetHeight()).To(BeNumerically(">", 0), "depth height")
Expect(resp.GetIsMetric()).To(BeTrue(), "nested output must be metric")
Expect(len(resp.GetDepth())).To(Equal(int(resp.GetWidth())*int(resp.GetHeight())), "dense depth length")
Expect(len(resp.GetExtrinsics())).To(Equal(12), "extrinsics 3x4")
Expect(resp.GetIntrinsics()[0]).To(BeNumerically(">", 0), "fx > 0")
_, _ = fmt.Fprintf(GinkgoWriter, "nested depth OK: %dx%d is_metric=%v fx=%.2f\n",
resp.GetWidth(), resp.GetHeight(), resp.GetIsMetric(), resp.GetIntrinsics()[0])
})
})

View File

@@ -0,0 +1,20 @@
package main
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = DescribeTable("optionValue",
func(opts []string, key, want string) {
Expect(optionValue(opts, key)).To(Equal(want))
},
Entry("present", []string{"foo:bar", "metric_model:m.gguf"}, "metric_model", "m.gguf"),
Entry("absent", []string{"foo:bar"}, "metric_model", ""),
Entry("nil", []string(nil), "metric_model", ""),
Entry("trims space", []string{"metric_model: m.gguf "}, "metric_model", "m.gguf"),
Entry("value with colon", []string{"metric_model:a:b.gguf"}, "metric_model", "a:b.gguf"),
Entry("first wins", []string{"metric_model:first.gguf", "metric_model:second.gguf"}, "metric_model", "first.gguf"),
Entry("empty value", []string{"metric_model:"}, "metric_model", ""),
Entry("prefix not key", []string{"metric_model_extra:x"}, "metric_model", ""),
)

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/libdepthanythingcpp-*.so $CURDIR/package/
cp -avf $CURDIR/depth-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/libdepthanythingcpp-fallback.so"
if [ "$(uname)" != "Darwin" ]; then
if grep -q -e "\savx\s" /proc/cpuinfo ; then
echo "CPU: AVX found OK"
if [ -e $CURDIR/libdepthanythingcpp-avx.so ]; then
LIBRARY="$CURDIR/libdepthanythingcpp-avx.so"
fi
fi
if grep -q -e "\savx2\s" /proc/cpuinfo ; then
echo "CPU: AVX2 found OK"
if [ -e $CURDIR/libdepthanythingcpp-avx2.so ]; then
LIBRARY="$CURDIR/libdepthanythingcpp-avx2.so"
fi
fi
# Check avx 512
if grep -q -e "\savx512f\s" /proc/cpuinfo ; then
echo "CPU: AVX512F found OK"
if [ -e $CURDIR/libdepthanythingcpp-avx512.so ]; then
LIBRARY="$CURDIR/libdepthanythingcpp-avx512.so"
fi
fi
fi
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
export DEPTHANYTHING_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/depth-anything-cpp "$@"
fi
echo "Using library: $LIBRARY"
exec $CURDIR/depth-anything-cpp "$@"

View File

@@ -0,0 +1,45 @@
#!/bin/bash
set -e
CURDIR=$(dirname "$(realpath $0)")
echo "Running depth-anything-cpp backend tests..."
# Test model from the mudler/depth-anything.cpp-gguf HuggingFace repo. The small
# (vits) f32 GGUF is the lightest backbone (~131 MB), so it keeps the download
# cheap. It is resumed with `curl -C -` and skipped entirely if already present.
DEPTHANYTHING_MODEL_DIR="${DEPTHANYTHING_MODEL_DIR:-$CURDIR/test-models}"
DEPTHANYTHING_MODEL_FILE="${DEPTHANYTHING_MODEL_FILE:-depth-anything-small-f32.gguf}"
DEPTHANYTHING_MODEL_URL="${DEPTHANYTHING_MODEL_URL:-https://huggingface.co/mudler/depth-anything.cpp-gguf/resolve/main/depth-anything-small-f32.gguf}"
mkdir -p "$DEPTHANYTHING_MODEL_DIR"
if [ ! -f "$DEPTHANYTHING_MODEL_DIR/$DEPTHANYTHING_MODEL_FILE" ]; then
echo "Downloading depth-anything small f32 model (~131 MB)..."
# -C - resumes a partial download so an interrupted run doesn't restart from 0.
curl -L -C - -o "$DEPTHANYTHING_MODEL_DIR/$DEPTHANYTHING_MODEL_FILE" "$DEPTHANYTHING_MODEL_URL" --progress-bar
fi
# Use a real photo (people + cars) from the upstream rf-detr.cpp repo (~46 KB).
# Depth estimation needs real content; a synthetic image would be degenerate.
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 test image..."
curl -L -o "$TEST_IMAGE_FILE" "$TEST_IMAGE_URL" --progress-bar
fi
echo "depth-anything-cpp test setup complete."
echo " model: $DEPTHANYTHING_MODEL_DIR/$DEPTHANYTHING_MODEL_FILE"
echo " test image: $TEST_IMAGE_FILE"
# Run the Go smoke test: spawns the backend binary on a free port, calls
# LoadModel + Predict via gRPC against the downloaded GGUF + 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?=b8012f11e5269126eddb7f4fd02f891a2ccc29b0
# Upstream pin lives below as PARAKEET_VERSION?=92a5f0306be354c109150fe58ae4cc4f8a21ca45
# (.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?=b8012f11e5269126eddb7f4fd02f891a2ccc29b0
PARAKEET_VERSION?=92a5f0306be354c109150fe58ae4cc4f8a21ca45
PARAKEET_REPO?=https://github.com/mudler/parakeet.cpp
GOCMD?=go

View File

@@ -1,23 +1,68 @@
#!/bin/bash
#
# L0 packaging stub: copy the binary, run.sh and libparakeet.so* into
# package/. The full ldd walk (libc, libstdc++, libgomp, GPU runtimes,
# arch detection) lands in L3, mirroring backend/go/whisper/package.sh.
# Bundle the parakeet-cpp-grpc binary, libparakeet.so, the core runtime
# libs (libc/libstdc++/libgomp + ld.so) and the GPU runtime for the active
# BUILD_TYPE so the package is self-contained. Mirrors
# backend/go/whisper/package.sh; run.sh routes the (CGO_ENABLED=0) binary
# through lib/ld.so so the packaged libc is used instead of the host's.
set -e
CURDIR=$(dirname "$(realpath "$0")")
REPO_ROOT="${CURDIR}/../../.."
mkdir -p "$CURDIR/package/lib"
cp -avf "$CURDIR/parakeet-cpp-grpc" "$CURDIR/package/"
cp -avf "$CURDIR/run.sh" "$CURDIR/package/"
# libparakeet.so + any soname symlinks (libparakeet.so.X, libparakeet.so.X.Y).
# libparakeet.so + any soname symlinks (libparakeet.so.X[.Y]). purego.Dlopen
# resolves it via LD_LIBRARY_PATH, which run.sh points at lib/.
cp -avf "$CURDIR"/libparakeet.so* "$CURDIR/package/lib/" 2>/dev/null || {
echo "ERROR: libparakeet.so not found in $CURDIR, run 'make' first" >&2
exit 1
}
echo "L0 package layout (full ldd walk lands in L3):"
# Detect architecture and copy the core runtime libs libparakeet.so links
# against, plus the matching dynamic loader as lib/ld.so.
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 "$CURDIR/package/lib/ld.so"
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 "$CURDIR/package/lib/libc.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 "$CURDIR/package/lib/libgcc_s.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 "$CURDIR/package/lib/libstdc++.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 "$CURDIR/package/lib/libm.so.6"
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 "$CURDIR/package/lib/libgomp.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 "$CURDIR/package/lib/libdl.so.2"
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 "$CURDIR/package/lib/librt.so.1"
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 "$CURDIR/package/lib/libpthread.so.0"
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 "$CURDIR/package/lib/ld.so"
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 "$CURDIR/package/lib/libc.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 "$CURDIR/package/lib/libgcc_s.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 "$CURDIR/package/lib/libstdc++.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 "$CURDIR/package/lib/libm.so.6"
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 "$CURDIR/package/lib/libgomp.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 "$CURDIR/package/lib/libdl.so.2"
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 "$CURDIR/package/lib/librt.so.1"
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 "$CURDIR/package/lib/libpthread.so.0"
elif [ "$(uname -s)" = "Darwin" ]; then
echo "Detected Darwin"
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries (CUDA/ROCm/Intel/Vulkan loader + ICDs + drivers)
# based on BUILD_TYPE so the backend can reach the GPU without the runtime
# base image shipping those drivers.
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah "$CURDIR/package/" "$CURDIR/package/lib/"

View File

@@ -8,10 +8,16 @@ JOBS?=$(shell nproc --ignore=1)
# stablediffusion.cpp (ggml)
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
STABLEDIFFUSION_GGML_VERSION?=19bdfe22d255d5b4dff39d449318b9bc5ea2317f
STABLEDIFFUSION_GGML_VERSION?=7f0e728b7d42f2490dfa5dd9539082d904f2f6b2
CMAKE_ARGS+=-DGGML_MAX_NAME=128
# Enable the ggml RPC backend so generation can be sharded across remote
# rpc-server workers (the same backend-agnostic ggml rpc-server used by the
# llama.cpp backend). Servers are selected via the `rpc_servers` option or the
# LLAMACPP_GRPC_SERVERS env var (populated automatically in p2p worker mode).
CMAKE_ARGS+=-DSD_RPC=ON
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
endif

View File

@@ -391,10 +391,18 @@ int load_model(const char *model, char *model_path, char* options[], int threads
const char *control_net_path = "";
const char *embedding_dir = "";
const char *photo_maker_path = "";
const char *pulid_weights_path = "";
const char *tensor_type_rules = "";
char *lora_dir = model_path;
bool vae_decode_only = true;
// Upstream backend/parameter placement specs (see docs/.../stablediffusion).
// Empty means "leave at upstream default" (nullptr).
const char *backend_arg = "";
const char *params_backend_arg = "";
const char *rpc_servers_arg = "";
const char *max_vram_arg = "";
bool stream_layers = false;
int n_threads = threads;
enum sd_type_t wtype = SD_TYPE_COUNT;
enum rng_type_t rng_type = CUDA_RNG;
@@ -418,7 +426,9 @@ int load_model(const char *model, char *model_path, char* options[], int threads
// If options is not NULL, parse options
for (int i = 0; options[i] != NULL; i++) {
const char *optname = strtok(options[i], ":");
const char *optval = strtok(NULL, ":");
// Take everything after the first ':' as the value so values may
// themselves contain colons (e.g. rpc_servers host:port lists).
const char *optval = strtok(NULL, "");
if (optval == NULL) {
optval = "true";
}
@@ -490,9 +500,21 @@ int load_model(const char *model, char *model_path, char* options[], int threads
}
}
if (!strcmp(optname, "photo_maker_path")) photo_maker_path = strdup(optval);
if (!strcmp(optname, "pulid_weights_path")) pulid_weights_path = strdup(optval);
if (!strcmp(optname, "tensor_type_rules")) tensor_type_rules = strdup(optval);
if (!strcmp(optname, "vae_decode_only")) vae_decode_only = (strcmp(optval, "true") == 0 || strcmp(optval, "1") == 0);
// Backend / parameter placement specs (see prepare_backend_assignments
// in the upstream CLI). These compose with the legacy keep_*_on_cpu /
// offload_params_to_cpu booleans below.
if (!strcmp(optname, "backend")) backend_arg = strdup(optval);
if (!strcmp(optname, "params_backend")) params_backend_arg = strdup(optval);
if (!strcmp(optname, "rpc_servers")) rpc_servers_arg = strdup(optval);
if (!strcmp(optname, "max_vram")) max_vram_arg = strdup(optval);
if (!strcmp(optname, "stream_layers")) stream_layers = (strcmp(optval, "true") == 0 || strcmp(optval, "1") == 0);
// vae_decode_only is still accepted for backwards compatibility with
// existing gallery configs, but upstream dropped the option (the model
// now decides), so it is parsed and ignored.
if (!strcmp(optname, "offload_params_to_cpu")) offload_params_to_cpu = (strcmp(optval, "true") == 0 || strcmp(optval, "1") == 0);
if (!strcmp(optname, "keep_clip_on_cpu")) keep_clip_on_cpu = (strcmp(optval, "true") == 0 || strcmp(optval, "1") == 0);
if (!strcmp(optname, "keep_control_net_on_cpu")) keep_control_net_on_cpu = (strcmp(optval, "true") == 0 || strcmp(optval, "1") == 0);
@@ -591,20 +613,48 @@ int load_model(const char *model, char *model_path, char* options[], int threads
ctx_params.embeddings = embedding_vec.empty() ? NULL : embedding_vec.data();
ctx_params.embedding_count = static_cast<uint32_t>(embedding_vec.size());
ctx_params.photo_maker_path = photo_maker_path;
if (strlen(pulid_weights_path) > 0) ctx_params.pulid_weights_path = pulid_weights_path;
ctx_params.tensor_type_rules = tensor_type_rules;
ctx_params.vae_decode_only = vae_decode_only;
// XXX: Setting to true causes a segfault on the second run
ctx_params.free_params_immediately = false;
ctx_params.n_threads = n_threads;
ctx_params.rng_type = rng_type;
ctx_params.keep_clip_on_cpu = keep_clip_on_cpu;
if (wtype != SD_TYPE_COUNT) ctx_params.wtype = wtype;
if (sampler_rng_type != RNG_TYPE_COUNT) ctx_params.sampler_rng_type = sampler_rng_type;
if (prediction != PREDICTION_COUNT) ctx_params.prediction = prediction;
if (lora_apply_mode != LORA_APPLY_MODE_COUNT) ctx_params.lora_apply_mode = lora_apply_mode;
ctx_params.offload_params_to_cpu = offload_params_to_cpu;
ctx_params.keep_control_net_on_cpu = keep_control_net_on_cpu;
ctx_params.keep_vae_on_cpu = keep_vae_on_cpu;
// Backend / parameter placement specs. Upstream replaced the boolean
// CPU-offload knobs (offload_params_to_cpu, keep_clip_on_cpu, keep_vae_on_cpu,
// keep_control_net_on_cpu) with these specs. Seed from the explicit
// backend/params_backend options, then prepend the legacy boolean-derived
// assignments, mirroring prepare_backend_assignments() in the upstream CLI.
// These strings must outlive new_sd_ctx() below.
std::string backend_spec = backend_arg;
std::string params_backend_spec = params_backend_arg;
auto prepend_spec = [](std::string& spec, const char* assignment) {
spec = spec.empty() ? std::string(assignment) : std::string(assignment) + "," + spec;
};
if (offload_params_to_cpu) prepend_spec(params_backend_spec, "*=cpu");
if (keep_clip_on_cpu) prepend_spec(backend_spec, "te=cpu");
if (keep_vae_on_cpu) prepend_spec(backend_spec, "vae=cpu");
if (keep_control_net_on_cpu) prepend_spec(backend_spec, "controlnet=cpu");
if (!backend_spec.empty()) ctx_params.backend = backend_spec.c_str();
if (!params_backend_spec.empty()) ctx_params.params_backend = params_backend_spec.c_str();
// RPC servers: prefer the explicit option, otherwise fall back to the
// LLAMACPP_GRPC_SERVERS env var. LocalAI's p2p worker mode populates that
// var with discovered ggml rpc-server workers (shared with the llama.cpp
// backend), so distributed image generation works with no extra config.
if (strlen(rpc_servers_arg) > 0) {
ctx_params.rpc_servers = rpc_servers_arg;
} else {
const char* env_rpc_servers = std::getenv("LLAMACPP_GRPC_SERVERS");
if (env_rpc_servers != NULL && strlen(env_rpc_servers) > 0) {
ctx_params.rpc_servers = env_rpc_servers;
}
}
// max_vram: GiB budget or per-backend spec for graph-cut segmented param
// offload ("0" = disabled, "-1" = auto). stream_layers only has effect when
// max_vram is set.
if (strlen(max_vram_arg) > 0) ctx_params.max_vram = max_vram_arg;
ctx_params.stream_layers = stream_layers;
ctx_params.diffusion_flash_attn = diffusion_flash_attn;
ctx_params.tae_preview_only = tae_preview_only;
ctx_params.diffusion_conv_direct = diffusion_conv_direct;

4
backend/go/supertonic/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/supertonic
/sources/
/backend-assets/
/package/

View File

@@ -0,0 +1,62 @@
CURRENT_DIR=$(abspath ./)
GOCMD=go
ONNX_VERSION?=1.24.4
ONNX_ARCH?=x64
ONNX_OS?=linux
ifneq (,$(findstring aarch64,$(shell uname -m)))
ONNX_ARCH=aarch64
endif
ifeq ($(OS),Darwin)
ONNX_OS=osx
ifneq (,$(findstring arm64,$(shell uname -m)))
ONNX_ARCH=arm64
else
ONNX_ARCH=x86_64
endif
endif
# CUDA 12 ships as -gpu, CUDA 13 as -gpu_cuda13 (underscore). CPU has no suffix.
ifeq ($(BUILD_TYPE),cublas)
ONNX_PROVIDER=cuda
ifeq ($(CUDA_MAJOR_VERSION),13)
ONNX_VARIANT=-gpu_cuda13
else
ONNX_VARIANT=-gpu
endif
else
ONNX_VARIANT=
ONNX_PROVIDER=cpu
endif
sources/onnxruntime:
mkdir -p sources/onnxruntime
curl -L https://github.com/microsoft/onnxruntime/releases/download/v$(ONNX_VERSION)/onnxruntime-$(ONNX_OS)-$(ONNX_ARCH)$(ONNX_VARIANT)-$(ONNX_VERSION).tgz \
-o sources/onnxruntime/onnxruntime.tgz
cd sources/onnxruntime && tar -xf onnxruntime.tgz --strip-components=1 && rm onnxruntime.tgz
backend-assets/lib: sources/onnxruntime
mkdir -p backend-assets/lib
cp -rfLv sources/onnxruntime/lib/* backend-assets/lib/
supertonic: backend-assets/lib
CGO_ENABLED=1 $(GOCMD) build \
-ldflags "$(LD_FLAGS) -X main.onnxProvider=$(ONNX_PROVIDER)" \
-tags "$(GO_TAGS)" -o supertonic ./
package:
bash package.sh
build: supertonic package
# Tests need only the Go toolchain (gcc); yalue dlopens onnxruntime at
# runtime, so no tarball download is required to compile or run unit specs.
test:
CGO_ENABLED=1 $(GOCMD) test -v -timeout 120s ./...
clean:
rm -rf supertonic sources/ backend-assets/ package/
.PHONY: build package clean test

View File

@@ -0,0 +1,307 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
laudio "github.com/mudler/LocalAI/pkg/audio"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// onnxProvider is set via -ldflags "-X main.onnxProvider=cuda" by the
// CUDA build (later phase). Defaults to CPU.
var onnxProvider = "cpu"
// Per-model generation defaults, overridable via ModelOptions.Options:
//
// supertonic.steps=<int> denoising steps (quality), default 8
// supertonic.speed=<float> speech rate, default 1.05
// supertonic.silence=<float> inter-chunk silence seconds, default 0.3
// supertonic.default_voice=<name> voice-style used when request omits voice
// supertonic.default_lang=<lang> language tag used when request omits it
const (
optionSteps = "supertonic.steps="
optionSpeed = "supertonic.speed="
optionSilence = "supertonic.silence="
optionDefaultVoice = "supertonic.default_voice="
optionDefaultLang = "supertonic.default_lang="
)
type SupertonicBackend struct {
base.SingleThread
tts *TextToSpeech
cfg Config
modelDir string
voicesDir string
defaultVoice string
defaultLang string
steps int
speed float32
silence float32
styleMu sync.Mutex
styles map[string]*Style // voice name -> loaded style cache
}
func (s *SupertonicBackend) Load(opts *pb.ModelOptions) error {
modelDir, err := resolveModelDir(opts.ModelFile)
if err != nil {
return err
}
s.modelDir = modelDir
s.voicesDir = resolveVoicesDir(modelDir)
cfg, err := LoadCfgs(modelDir)
if err != nil {
return fmt.Errorf("loading tts.json from %s: %w", modelDir, err)
}
s.cfg = cfg
// onnxProvider is "cpu" for the CPU build; the CUDA build sets it to
// "cuda" via -ldflags. Upstream LoadTextToSpeech still errors on GPU
// until the CUDA phase wires the execution provider.
tts, err := LoadTextToSpeech(modelDir, onnxProvider == "cuda", cfg)
if err != nil {
return fmt.Errorf("loading supertonic models from %s: %w", modelDir, err)
}
s.tts = tts
s.steps = int(findOptionInt(opts, optionSteps, 8))
s.speed = findOptionFloat(opts, optionSpeed, 1.05)
s.silence = findOptionFloat(opts, optionSilence, 0.3)
s.defaultVoice = findOptionValue(opts, optionDefaultVoice, "")
s.defaultLang = findOptionValue(opts, optionDefaultLang, "na")
s.styles = map[string]*Style{}
return nil
}
func (s *SupertonicBackend) TTS(req *pb.TTSRequest) error {
wav, sr, err := s.synthesize(req)
if err != nil {
return err
}
out := make([]float64, len(wav))
for i, v := range wav {
out[i] = float64(v)
}
if err := writeWavFile(req.Dst, out, sr); err != nil {
return fmt.Errorf("writing wav to %s: %w", req.Dst, err)
}
return nil
}
func (s *SupertonicBackend) TTSStream(req *pb.TTSRequest, results chan []byte) error {
defer close(results)
wav, sr, err := s.synthesize(req)
if err != nil {
return err
}
results <- streamingWAVHeader(uint32(sr))
const chunkSamples = 4096
for off := 0; off < len(wav); off += chunkSamples {
end := off + chunkSamples
if end > len(wav) {
end = len(wav)
}
results <- pcmFloatToInt16LE(wav[off:end])
}
return nil
}
// synthesize runs the full pipeline and returns the trimmed mono float32
// PCM and its sample rate.
func (s *SupertonicBackend) synthesize(req *pb.TTSRequest) ([]float32, int, error) {
if s.tts == nil {
return nil, 0, fmt.Errorf("supertonic model not loaded")
}
if strings.TrimSpace(req.Text) == "" {
return nil, 0, fmt.Errorf("empty text")
}
style, err := s.loadStyle(s.voiceName(req.Voice))
if err != nil {
return nil, 0, err
}
lang := s.resolveLang("")
if req.Language != nil {
lang = s.resolveLang(*req.Language)
}
wav, dur, err := s.tts.Call(req.Text, lang, style, s.steps, s.speed, s.silence)
if err != nil {
return nil, 0, err
}
sr := s.tts.SampleRate
// Call returns concatenated audio; trim to the reported duration.
wavLen := int(float32(sr) * dur)
if wavLen < 0 {
wavLen = 0
}
if wavLen > len(wav) {
wavLen = len(wav)
}
return wav[:wavLen], sr, nil
}
// voiceName picks the request voice, falling back to the model default.
func (s *SupertonicBackend) voiceName(reqVoice string) string {
v := strings.TrimSpace(reqVoice)
if v == "" {
return s.defaultVoice
}
return v
}
// resolveLang validates against AvailableLangs, falling back to the model
// default (then "na").
func (s *SupertonicBackend) resolveLang(reqLang string) string {
l := strings.TrimSpace(reqLang)
if l != "" && isValidLang(l) {
return l
}
if s.defaultLang != "" && isValidLang(s.defaultLang) {
return s.defaultLang
}
return "na"
}
// loadStyle resolves and caches a voice-style. An empty name with no model
// default is an error (supertonic requires a style embedding).
func (s *SupertonicBackend) loadStyle(name string) (*Style, error) {
if name == "" {
return nil, fmt.Errorf("no voice specified and no supertonic.default_voice set")
}
s.styleMu.Lock()
defer s.styleMu.Unlock()
if st, ok := s.styles[name]; ok {
return st, nil
}
path := s.voiceStylePath(name)
st, err := LoadVoiceStyle([]string{path}, false)
if err != nil {
return nil, fmt.Errorf("loading voice style %q (%s): %w", name, path, err)
}
s.styles[name] = st
return st, nil
}
// voiceStylePath maps a voice name to a JSON path. Absolute paths are honored;
// names containing a separator resolve under modelDir; bare names resolve under
// the resolved voicesDir (see resolveVoicesDir).
func (s *SupertonicBackend) voiceStylePath(name string) string {
if !strings.HasSuffix(name, ".json") {
name += ".json"
}
if filepath.IsAbs(name) {
return name
}
if strings.ContainsRune(name, filepath.Separator) {
return filepath.Join(s.modelDir, name)
}
return filepath.Join(s.voicesDir, name)
}
// resolveVoicesDir locates the voice_styles directory. The HF model layout
// puts the ONNX files in an onnx/ subdir with voice_styles/ as its sibling,
// so check modelDir/voice_styles first, then the parent's voice_styles.
func resolveVoicesDir(modelDir string) string {
candidates := []string{
filepath.Join(modelDir, "voice_styles"),
filepath.Join(filepath.Dir(modelDir), "voice_styles"),
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && info.IsDir() {
return c
}
}
return candidates[0]
}
// resolveModelDir accepts either a directory (used as-is) or a file (its
// parent dir is used).
func resolveModelDir(modelFile string) (string, error) {
if modelFile == "" {
return "", fmt.Errorf("empty model path")
}
info, err := os.Stat(modelFile)
if err != nil {
return "", fmt.Errorf("stat model path %s: %w", modelFile, err)
}
if info.IsDir() {
return modelFile, nil
}
return filepath.Dir(modelFile), nil
}
// ---- option helpers (mirrors backend/go/sherpa-onnx/backend.go) ----
func findOptionValue(opts *pb.ModelOptions, prefix, def string) string {
for _, o := range opts.Options {
if strings.HasPrefix(o, prefix) {
return strings.TrimPrefix(o, prefix)
}
}
return def
}
func findOptionFloat(opts *pb.ModelOptions, prefix string, def float32) float32 {
raw := findOptionValue(opts, prefix, "")
if raw == "" {
return def
}
v, err := strconv.ParseFloat(raw, 32)
if err != nil {
return def
}
return float32(v)
}
func findOptionInt(opts *pb.ModelOptions, prefix string, def int32) int32 {
raw := findOptionValue(opts, prefix, "")
if raw == "" {
return def
}
v, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
return def
}
return int32(v)
}
// ---- PCM helpers ----
func pcmFloatToInt16LE(samples []float32) []byte {
buf := make([]byte, len(samples)*2)
for i, f := range samples {
v := int32(f * 32767)
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
binary.LittleEndian.PutUint16(buf[2*i:], uint16(int16(v)))
}
return buf
}
func streamingWAVHeader(sampleRate uint32) []byte {
const streamingSize = 0xFFFFFFFF
h := laudio.NewWAVHeaderWithRate(streamingSize, sampleRate)
h.ChunkSize = streamingSize
var buf bytes.Buffer
_ = h.Write(&buf)
return buf.Bytes()
}

View File

@@ -0,0 +1,86 @@
package main
import (
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
var _ = Describe("voiceStylePath", func() {
s := &SupertonicBackend{modelDir: "/models/st/onnx", voicesDir: "/models/st/voice_styles"}
It("resolves a bare name under the resolved voicesDir", func() {
Expect(s.voiceStylePath("M1")).To(Equal(filepath.Join("/models/st/voice_styles", "M1.json")))
})
It("keeps an explicit .json suffix", func() {
Expect(s.voiceStylePath("M1.json")).To(Equal(filepath.Join("/models/st/voice_styles", "M1.json")))
})
It("honors absolute paths", func() {
Expect(s.voiceStylePath("/abs/v.json")).To(Equal("/abs/v.json"))
})
})
var _ = Describe("resolveVoicesDir", func() {
It("prefers voice_styles under modelDir", func() {
dir := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(dir, "voice_styles"), 0o755)).To(Succeed())
Expect(resolveVoicesDir(dir)).To(Equal(filepath.Join(dir, "voice_styles")))
})
It("falls back to the sibling voice_styles next to an onnx subdir", func() {
root := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(root, "voice_styles"), 0o755)).To(Succeed())
Expect(os.MkdirAll(filepath.Join(root, "onnx"), 0o755)).To(Succeed())
Expect(resolveVoicesDir(filepath.Join(root, "onnx"))).To(Equal(filepath.Join(root, "voice_styles")))
})
})
var _ = Describe("resolveLang", func() {
It("accepts a valid request language", func() {
s := &SupertonicBackend{defaultLang: "na"}
Expect(s.resolveLang("ko")).To(Equal("ko"))
})
It("falls back to the model default for an invalid language", func() {
s := &SupertonicBackend{defaultLang: "en"}
Expect(s.resolveLang("zz")).To(Equal("en"))
})
It("falls back to na when nothing is valid", func() {
s := &SupertonicBackend{defaultLang: ""}
Expect(s.resolveLang("")).To(Equal("na"))
})
})
var _ = Describe("pcmFloatToInt16LE", func() {
It("clamps and encodes little-endian", func() {
out := pcmFloatToInt16LE([]float32{0, 1.0, -1.0, 2.0})
Expect(out).To(HaveLen(8))
Expect(out[0:2]).To(Equal([]byte{0x00, 0x00})) // 0
Expect(out[2:4]).To(Equal([]byte{0xff, 0x7f})) // 32767
Expect(out[6:8]).To(Equal([]byte{0xff, 0x7f})) // clamp 2.0 -> 32767
})
})
var _ = Describe("end-to-end synthesis", Ordered, func() {
var modelDir string
BeforeAll(func() {
modelDir = os.Getenv("SUPERTONIC_MODEL_PATH")
if modelDir == "" {
Skip("set SUPERTONIC_MODEL_PATH to a supertonic model dir to run")
}
Expect(InitializeONNXRuntime()).To(Succeed())
})
It("synthesizes a wav file", func() {
b := &SupertonicBackend{}
Expect(b.Load(&pb.ModelOptions{ModelFile: modelDir, Options: []string{"supertonic.default_voice=F1"}})).To(Succeed())
dst := filepath.Join(GinkgoT().TempDir(), "out.wav")
lang := "en"
Expect(b.TTS(&pb.TTSRequest{Text: "Hello from LocalAI.", Dst: dst, Language: &lang})).To(Succeed())
info, err := os.Stat(dst)
Expect(err).ToNot(HaveOccurred())
Expect(info.Size()).To(BeNumerically(">", 44)) // header + PCM
})
})

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
package main
// Started internally by LocalAI; a server is allocated per model.
import (
"flag"
grpc "github.com/mudler/LocalAI/pkg/grpc"
ort "github.com/yalue/onnxruntime_go"
)
var addr = flag.String("addr", "localhost:50051", "the address to connect to")
func main() {
flag.Parse()
// InitializeONNXRuntime reads ONNXRUNTIME_LIB_PATH (set by run.sh) and
// dlopens libonnxruntime before any session is created in Load().
if err := InitializeONNXRuntime(); err != nil {
panic(err)
}
defer func() { _ = ort.DestroyEnvironment() }()
if err := grpc.StartServer(*addr, &SupertonicBackend{}); err != nil {
panic(err)
}
}

View File

@@ -1,4 +1,4 @@
package ssewire
package main
import (
"testing"
@@ -7,7 +7,7 @@ import (
. "github.com/onsi/gomega"
)
func TestSsewire(t *testing.T) {
func TestSupertonic(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "ssewire test suite")
RunSpecs(t, "Supertonic backend test suite")
}

View File

@@ -0,0 +1,49 @@
#!/bin/bash
set -e
CURDIR=$(dirname "$(realpath $0)")
REPO_ROOT="${CURDIR}/../../.."
mkdir -p $CURDIR/package/lib
cp -avf $CURDIR/supertonic $CURDIR/package/
cp -avf $CURDIR/run.sh $CURDIR/package/
cp -rfLv $CURDIR/backend-assets/lib/* $CURDIR/package/lib/
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 $CURDIR/package/lib/ld.so
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 $CURDIR/package/lib/ld.so
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
else
echo "Error: Could not detect architecture"
exit 1
fi
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/

14
backend/go/supertonic/run.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -ex
CURDIR=$(dirname "$(realpath $0)")
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
export ONNXRUNTIME_LIB_PATH=$CURDIR/lib/libonnxruntime.so
if [ -f $CURDIR/lib/ld.so ]; then
echo "Using lib/ld.so"
exec $CURDIR/lib/ld.so $CURDIR/supertonic "$@"
fi
exec $CURDIR/supertonic "$@"

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# whisper.cpp version
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
WHISPER_CPP_VERSION?=df7638d8229a243af8a4b5a8ae557e0d74e0a0ae
WHISPER_CPP_VERSION?=86c40c3bd6fc86f1187fb751d111b49e0fc18e84
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -458,6 +458,126 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-vulkan-locate-anything-cpp"
mirrors:
- localai/localai-backends:master-gpu-vulkan-locate-anything-cpp
- &depthanything
name: "depth-anything"
alias: "depth-anything"
license: apache-2.0
description: |
Depth Anything 3 monocular metric depth + camera pose estimation in C/C++
using GGML. Loads pre-built GGUF weights and, given an image, returns a
dense depth map plus the recovered camera extrinsics (3x4) and intrinsics
(3x3). No Python at inference (purego, cgo-less).
urls:
- https://github.com/mudler/depth-anything.cpp
- https://huggingface.co/depth-anything/Depth-Anything-V3
tags:
- depth-estimation
- camera-pose
- depth-anything
- gpu
- cpu
capabilities:
default: "cpu-depth-anything-cpp"
nvidia: "cuda12-depth-anything-cpp"
nvidia-cuda-12: "cuda12-depth-anything-cpp"
nvidia-cuda-13: "cuda13-depth-anything-cpp"
nvidia-l4t: "nvidia-l4t-arm64-depth-anything-cpp"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-depth-anything-cpp"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-depth-anything-cpp"
intel: "intel-sycl-f32-depth-anything-cpp"
vulkan: "vulkan-depth-anything-cpp"
- !!merge <<: *depthanything
name: "depth-anything-development"
capabilities:
default: "cpu-depth-anything-cpp-development"
nvidia: "cuda12-depth-anything-cpp-development"
nvidia-cuda-12: "cuda12-depth-anything-cpp-development"
nvidia-cuda-13: "cuda13-depth-anything-cpp-development"
nvidia-l4t: "nvidia-l4t-arm64-depth-anything-cpp-development"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-depth-anything-cpp-development"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-depth-anything-cpp-development"
intel: "intel-sycl-f32-depth-anything-cpp-development"
vulkan: "vulkan-depth-anything-cpp-development"
- !!merge <<: *depthanything
name: "cpu-depth-anything-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-depth-anything-cpp"
mirrors:
- localai/localai-backends:latest-cpu-depth-anything-cpp
- !!merge <<: *depthanything
name: "cpu-depth-anything-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-depth-anything-cpp"
mirrors:
- localai/localai-backends:master-cpu-depth-anything-cpp
- !!merge <<: *depthanything
name: "cuda12-depth-anything-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-depth-anything-cpp"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-12-depth-anything-cpp
- !!merge <<: *depthanything
name: "cuda12-depth-anything-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-depth-anything-cpp"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-depth-anything-cpp
- !!merge <<: *depthanything
name: "cuda13-depth-anything-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-depth-anything-cpp"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-13-depth-anything-cpp
- !!merge <<: *depthanything
name: "cuda13-depth-anything-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-depth-anything-cpp"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-depth-anything-cpp
- !!merge <<: *depthanything
name: "nvidia-l4t-arm64-depth-anything-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-arm64-depth-anything-cpp"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-arm64-depth-anything-cpp
- !!merge <<: *depthanything
name: "nvidia-l4t-arm64-depth-anything-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-arm64-depth-anything-cpp"
mirrors:
- localai/localai-backends:master-nvidia-l4t-arm64-depth-anything-cpp
- !!merge <<: *depthanything
name: "cuda13-nvidia-l4t-arm64-depth-anything-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-cuda-13-arm64-depth-anything-cpp"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-cuda-13-arm64-depth-anything-cpp
- !!merge <<: *depthanything
name: "cuda13-nvidia-l4t-arm64-depth-anything-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-depth-anything-cpp"
mirrors:
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-depth-anything-cpp
- !!merge <<: *depthanything
name: "intel-sycl-f32-depth-anything-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f32-depth-anything-cpp"
mirrors:
- localai/localai-backends:latest-gpu-intel-sycl-f32-depth-anything-cpp
- !!merge <<: *depthanything
name: "intel-sycl-f32-depth-anything-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-sycl-f32-depth-anything-cpp"
mirrors:
- localai/localai-backends:master-gpu-intel-sycl-f32-depth-anything-cpp
- !!merge <<: *depthanything
name: "intel-sycl-f16-depth-anything-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f16-depth-anything-cpp"
mirrors:
- localai/localai-backends:latest-gpu-intel-sycl-f16-depth-anything-cpp
- !!merge <<: *depthanything
name: "intel-sycl-f16-depth-anything-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-sycl-f16-depth-anything-cpp"
mirrors:
- localai/localai-backends:master-gpu-intel-sycl-f16-depth-anything-cpp
- !!merge <<: *depthanything
name: "vulkan-depth-anything-cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-depth-anything-cpp"
mirrors:
- localai/localai-backends:latest-gpu-vulkan-depth-anything-cpp
- !!merge <<: *depthanything
name: "vulkan-depth-anything-cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-vulkan-depth-anything-cpp"
mirrors:
- localai/localai-backends:master-gpu-vulkan-depth-anything-cpp
- &vllm
name: "vllm"
license: apache-2.0
@@ -879,6 +999,42 @@
nvidia-l4t: "vulkan-localvqe"
nvidia-l4t-cuda-12: "vulkan-localvqe"
nvidia-l4t-cuda-13: "vulkan-localvqe"
- &privacyfilter
name: "privacy-filter"
alias: "privacy-filter"
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/5fd5e18a90b6dc4633f6d292/QPiv8pt4JNxr0FdGnpFef.png
description: |
Standalone GGML engine (privacy-filter.cpp) for the OpenMed privacy-filter
PII/NER token-classification model family. It runs the openai-privacy-filter
architecture (a gpt-oss-style sparse-MoE bidirectional token classifier) on
stock upstream GGML — no llama.cpp coupling and no Python — and serves the
TokenClassify RPC (constrained BIOES Viterbi decode into UTF-8 byte-offset
entity spans) used by LocalAI's NER PII redaction tier.
urls:
- https://github.com/localai-org/privacy-filter.cpp
tags:
- token-classification
- ner
- pii
- privacy
- CPU
- GPU
license: apache-2.0
# Builds: CPU (amd64+arm64 manifest), Vulkan (amd64) and CUDA 13 (amd64).
# Only a host that actually reports CUDA 13 gets the CUDA image (it bundles
# the CUDA 13 runtime and needs a recent driver); every other GPU — including
# NVIDIA without a CUDA-13 toolkit, AMD and Intel — routes to the Vulkan
# image, which only needs a Vulkan ICD. Everything else (incl. arm64/Jetson,
# where Vulkan/CUDA images are a future add) falls back to the CPU build,
# already fast for this ~50M-active-param model.
capabilities:
default: "cpu-privacy-filter"
nvidia: "vulkan-privacy-filter"
nvidia-cuda-12: "vulkan-privacy-filter"
nvidia-cuda-13: "cuda13-privacy-filter"
amd: "vulkan-privacy-filter"
intel: "vulkan-privacy-filter"
vulkan: "vulkan-privacy-filter"
- &faster-whisper
icon: https://avatars.githubusercontent.com/u/1520500?s=200&v=4
description: |
@@ -1368,6 +1524,20 @@
nvidia: "cuda12-sherpa-onnx"
nvidia-cuda-12: "cuda12-sherpa-onnx"
metal: "metal-sherpa-onnx"
- &supertonic
name: "supertonic"
alias: "supertonic"
urls:
- https://github.com/supertone-inc/supertonic
description: |
Supertonic backend: lightning-fast, on-device multilingual text-to-speech via ONNX Runtime.
Runs Supertone's flow-matching TTS model (Supertone/supertonic-3), 44.1kHz output, 31 languages,
multiple preset voice styles. No espeak-ng dependency.
tags:
- text-to-speech
- TTS
capabilities:
default: "cpu-supertonic"
- !!merge <<: *neutts
name: "neutts-development"
capabilities:
@@ -2569,6 +2739,37 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-stablediffusion-ggml"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-stablediffusion-ggml
## privacy-filter
- !!merge <<: *privacyfilter
name: "cpu-privacy-filter"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-privacy-filter"
mirrors:
- localai/localai-backends:latest-cpu-privacy-filter
- !!merge <<: *privacyfilter
name: "cpu-privacy-filter-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-privacy-filter"
mirrors:
- localai/localai-backends:master-cpu-privacy-filter
- !!merge <<: *privacyfilter
name: "vulkan-privacy-filter"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-privacy-filter"
mirrors:
- localai/localai-backends:latest-gpu-vulkan-privacy-filter
- !!merge <<: *privacyfilter
name: "vulkan-privacy-filter-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-vulkan-privacy-filter"
mirrors:
- localai/localai-backends:master-gpu-vulkan-privacy-filter
- !!merge <<: *privacyfilter
name: "cuda13-privacy-filter"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-privacy-filter"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-13-privacy-filter
- !!merge <<: *privacyfilter
name: "cuda13-privacy-filter-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-privacy-filter"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-privacy-filter
# vllm
- !!merge <<: *vllm
name: "vllm-development"
@@ -5132,3 +5333,18 @@
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-sherpa-onnx"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-sherpa-onnx
## supertonic
- !!merge <<: *supertonic
name: "supertonic-development"
capabilities:
default: "cpu-supertonic-development"
- !!merge <<: *supertonic
name: "cpu-supertonic"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-supertonic"
mirrors:
- localai/localai-backends:latest-cpu-supertonic
- !!merge <<: *supertonic
name: "cpu-supertonic-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-supertonic"
mirrors:
- localai/localai-backends:master-cpu-supertonic

View File

@@ -270,10 +270,17 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
def TokenClassify(self, request, context):
# Runs HuggingFace's token-classification pipeline and returns
# the aggregated entity spans. The pipeline gives us byte
# offsets via aggregation_strategy="simple" (set at load
# time), so the caller can slice the original text without
# re-tokenising on the Go side.
# the aggregated entity spans.
#
# OFFSET UNITS: the proto contract (TokenClassifyEntity.start/end)
# is UTF-8 BYTE offsets into request.text. HuggingFace's pipeline,
# however, reports start/end as CODEPOINT offsets into the Python
# str (derived from the fast tokenizer's offset_mapping). Those
# coincide only for ASCII; for any multi-byte character they
# diverge — and this entry point exists to serve the explicitly
# multilingual privacy-filter model, so the conversion is
# mandatory, not a nicety. We build one prefix table mapping each
# codepoint index to its byte offset and translate every span.
if not getattr(self, "TokenClassification", False):
context.set_code(grpc.StatusCode.FAILED_PRECONDITION)
context.set_details("model was not loaded as Type=TokenClassification")
@@ -286,18 +293,50 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
context.set_details(f"token-classification failed: {err}")
return backend_pb2.TokenClassifyResponse()
text = request.text
# byte_at[i] = byte length of text[:i]; len == len(text)+1 so an
# exclusive end offset that points one past the last codepoint
# maps to len(text.encode("utf-8")). Built in a single O(n) pass.
byte_at = [0] * (len(text) + 1)
acc = 0
for i, ch in enumerate(text):
byte_at[i] = acc
acc += len(ch.encode("utf-8"))
byte_at[len(text)] = acc
def to_byte(cp_index, default):
# Clamp out-of-range codepoint indices into the table rather
# than throwing: a span we can't place is better dropped Go-side
# than crashing the RPC.
if cp_index is None:
cp_index = default
if cp_index < 0:
cp_index = 0
elif cp_index > len(text):
cp_index = len(text)
return byte_at[cp_index]
threshold = request.threshold if request.threshold > 0 else 0.0
entities = []
for r in results:
score = float(r.get("score", 0.0))
if score < threshold:
continue
cp_start = r.get("start")
cp_end = r.get("end")
start = to_byte(cp_start, 0)
end = to_byte(cp_end, 0)
entities.append(backend_pb2.TokenClassifyEntity(
entity_group=str(r.get("entity_group") or r.get("entity") or ""),
start=int(r.get("start", 0)),
end=int(r.get("end", 0)),
start=start,
end=end,
score=score,
text=str(r.get("word", "")),
# Slice the original text by the (codepoint) span so the
# echoed text matches start..end exactly, instead of the
# pipeline's reconstructed "word" which can carry wordpiece
# artifacts. Falls back to "word" when offsets are absent.
text=(text[cp_start:cp_end] if cp_start is not None and cp_end is not None
else str(r.get("word", ""))),
))
return backend_pb2.TokenClassifyResponse(entities=entities)

View File

@@ -1,4 +1,4 @@
grpcio==1.81.0
grpcio==1.81.1
protobuf
certifi
setuptools

View File

@@ -79,6 +79,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
def AudioTranscription(self, request, context):
import whisperx
from whisperx.diarize import DiarizationPipeline
resultSegments = []
text = ""
@@ -106,8 +107,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# Diarize if requested and HF token is available
if request.diarize and self.hf_token:
if self.diarize_pipeline is None:
self.diarize_pipeline = whisperx.DiarizationPipeline(
use_auth_token=self.hf_token,
self.diarize_pipeline = DiarizationPipeline(
token=self.hf_token,
device=self.device,
)
diarize_segments = self.diarize_pipeline(audio)

View File

@@ -635,8 +635,11 @@ func (l *Launcher) showDownloadProgress(version, title string) {
progressBar := widget.NewProgressBar()
progressBar.SetValue(0)
// Status label
// Status label. Truncate with an ellipsis so a long "Download failed:
// <url>" message can't stretch the window (and progress bar) to fit the
// whole error on one line; the full error is shown in the dialog below.
statusLabel := widget.NewLabel("Preparing download...")
statusLabel.Truncation = fyne.TextTruncateEllipsis
// Release notes button
releaseNotesButton := widget.NewButton("View Release Notes", func() {

View File

@@ -454,8 +454,11 @@ func (sm *SystrayManager) showDownloadProgress(version string) {
progressBar := widget.NewProgressBar()
progressBar.SetValue(0)
// Status label
// Status label. Truncate with an ellipsis so a long "Download failed:
// <url>" message can't stretch the window (and progress bar) to fit the
// whole error on one line; the full error is shown in the dialog below.
statusLabel := widget.NewLabel("Preparing download...")
statusLabel.Truncation = fyne.TextTruncateEllipsis
// Release notes button
releaseNotesButton := widget.NewButton("View Release Notes", func() {

View File

@@ -57,8 +57,16 @@ type LauncherUI struct {
// NewLauncherUI creates a new UI instance
func NewLauncherUI() *LauncherUI {
// Truncate the status text with an ellipsis. Status messages can carry a
// download error containing a long, unbreakable URL/path; without this the
// label demands the full single-line width and stretches the window (and
// the progress bar) arbitrarily wide. The full error is still shown in the
// error dialog.
statusLabel := widget.NewLabel("Initializing...")
statusLabel.Truncation = fyne.TextTruncateEllipsis
return &LauncherUI{
statusLabel: widget.NewLabel("Initializing..."),
statusLabel: statusLabel,
versionLabel: widget.NewLabel("Version: Unknown"),
startStopButton: widget.NewButton("Start LocalAI", nil),
webUIButton: widget.NewButton("Open WebUI", nil),
@@ -602,8 +610,11 @@ func (ui *LauncherUI) showDownloadProgress(version, title string) {
progressBar := widget.NewProgressBar()
progressBar.SetValue(0)
// Status label
// Status label. Truncate with an ellipsis so a long "Download failed:
// <url>" message can't stretch the window (and progress bar) to fit the
// whole error on one line; the full error is shown in the dialog below.
statusLabel := widget.NewLabel("Preparing download...")
statusLabel.Truncation = fyne.TextTruncateEllipsis
// Release notes button
releaseNotesButton := widget.NewButton("View Release Notes", func() {

View File

@@ -12,14 +12,15 @@ import (
"github.com/mudler/LocalAI/core/http/auth"
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
"github.com/mudler/LocalAI/core/services/agentpool"
"github.com/mudler/LocalAI/core/services/cloudproxy/mitm"
"github.com/mudler/LocalAI/core/services/facerecognition"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/monitoring"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/routing/admission"
"github.com/mudler/LocalAI/core/services/routing/billing"
"github.com/mudler/LocalAI/core/services/cloudproxy/mitm"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/services/routing/piidetector"
"github.com/mudler/LocalAI/core/services/routing/router"
"github.com/mudler/LocalAI/core/services/voicerecognition"
"github.com/mudler/LocalAI/core/templates"
@@ -71,15 +72,15 @@ type Application struct {
// 1-to-1 host↔model invariant the dispatcher relies on. Read by
// /api/middleware/status so the admin UI can surface the cause.
mitmHostConflicts atomic.Pointer[map[string][]string]
routerDecisions router.DecisionStore
routerRegistry *router.Registry
admissionLimiter *admission.Limiter
watchdogMutex sync.Mutex
watchdogStop chan bool
p2pMutex sync.Mutex
p2pCtx context.Context
p2pCancel context.CancelFunc
agentJobMutex sync.Mutex
routerDecisions router.DecisionStore
routerRegistry *router.Registry
admissionLimiter *admission.Limiter
watchdogMutex sync.Mutex
watchdogStop chan bool
p2pMutex sync.Mutex
p2pCtx context.Context
p2pCancel context.CancelFunc
agentJobMutex sync.Mutex
// Distributed mode services (nil when not in distributed mode)
distributed *DistributedServices
@@ -254,6 +255,122 @@ func (a *Application) PIIEvents() pii.EventStore {
return a.piiEvents
}
// PIINERResolver returns the resolver the chat PII middleware uses to
// turn a configured detector model name into a ready-to-use NERConfig:
// a token-classifier bound over the shared model loader (lazy — the
// model loads on first Detect) plus the detection policy read from that
// model's own pii_detection block. Unknown names resolve to (zero,
// false) so the middleware fails closed. Pass it via pii.WithNERResolver.
func (a *Application) PIINERResolver() pii.NERDetectorResolver {
return func(modelName string) (pii.NERConfig, bool) {
if modelName == "" {
return pii.NERConfig{}, false
}
cfg, ok := a.ModelConfigLoader().GetModelConfig(modelName)
if !ok {
return pii.NERConfig{}, false
}
// Pattern detectors match secrets with the restricted-regex tier
// in-process (no backend load). Build a pattern matcher instead of the
// gRPC token-classifier; on a compile error fail closed with an error
// detector so the request is blocked, not silently unscanned.
if cfg.IsPatternDetector() {
det, err := piidetector.NewPattern(cfg, a.ApplicationConfig())
if err != nil {
det = pii.NewErrNERDetector(err.Error())
}
return pii.NERConfigFromRaw(
det,
0, // patterns are deterministic — no confidence floor
cfg.PIIDetectionDefaultAction(),
patternEntityActions(cfg),
pii.SourcePattern,
), true
}
det := piidetector.New(a.ModelLoader(), cfg, a.ApplicationConfig())
return pii.NERConfigFromRaw(
det,
cfg.PIIDetectionMinScore(),
cfg.PIIDetectionDefaultAction(),
cfg.PIIDetectionEntityActions(),
pii.SourceNER,
), true
}
}
// patternEntityActions merges a pattern detector's per-pattern Action overrides
// into its entity_actions map. A pattern reports matches under its Name, so a
// per-pattern action is just an entity_actions[Name] entry; explicit
// entity_actions still win if both are set.
func patternEntityActions(cfg config.ModelConfig) map[string]string {
out := cfg.PIIDetectionEntityActions()
for _, p := range cfg.PIIDetection.Patterns {
if p.Action == "" || p.Name == "" {
continue
}
if out == nil {
out = map[string]string{}
}
if _, exists := out[p.Name]; !exists {
out[p.Name] = p.Action
}
}
return out
}
// ResolvePIIPolicy resolves the effective request-side PII policy for a
// consuming model, layering the instance-wide default detector
// (PIIDefaultDetectors, set via POST /api/settings) on top of the per-model
// config. It is the single decision point shared by the chat middleware (via
// WithPolicyResolver) and the MITM listener so both agree.
//
// - enabled: an explicit pii.enabled on the model always wins (true OR
// false). Otherwise PII is on when the backend defaults it on — today
// that means cloud-proxy models, which cross the network to a third party.
// - detectors: the model's own pii.detectors, or — when it lists none — the
// global PIIDefaultDetectors fallback. This is what makes cloud-proxy/MITM
// redaction work out of the box.
//
// appConfig is read live, so changes via the settings API take effect on the
// next request without a restart.
func (a *Application) ResolvePIIPolicy(cfg *config.ModelConfig) (enabled bool, detectors []string) {
if cfg == nil {
return false, nil
}
appCfg := a.ApplicationConfig()
if cfg.PII.Enabled != nil {
enabled = *cfg.PII.Enabled
} else {
enabled = cfg.PIIIsEnabled() // backend default (cloud-proxy)
}
if !enabled {
return false, nil
}
detectors = cfg.PIIDetectors()
if len(detectors) == 0 {
detectors = append([]string(nil), appCfg.PIIDefaultDetectors...)
}
return enabled, detectors
}
// PIIPolicyResolver adapts ResolvePIIPolicy to pii.PolicyResolver for
// pii.WithPolicyResolver. The middleware carries the resolved model config as
// `any` (the MODEL_CONFIG context value, a *config.ModelConfig); this asserts
// it back and applies the instance-wide defaults.
func (a *Application) PIIPolicyResolver() pii.PolicyResolver {
return func(modelCfg any) (bool, []string) {
cfg, ok := modelCfg.(*config.ModelConfig)
if !ok {
return false, nil
}
return a.ResolvePIIPolicy(cfg)
}
}
// MITMCA returns the cloudproxy MITM proxy's CA, or nil when the
// MITM listener is disabled.
func (a *Application) MITMCA() *mitm.CA { return a.mitmCA.Load() }

View File

@@ -8,6 +8,7 @@ import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/cloudproxy/mitm"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/xlog"
)
@@ -91,25 +92,41 @@ func startMITMLocked(app *Application, options *config.ApplicationConfig) error
}
sort.Strings(effectiveHosts)
// Per-host PII gate inherits from the owning model's pii.enabled.
// A non-cloud-proxy backend with no explicit pii.enabled resolves
// to false → host is intercepted but the regex pass is skipped
// (audit events still record).
var piiDisabled []string
// Per-host NER detectors come from the owning model's pii.detectors
// (resolved against each detector model's pii_detection policy). A
// host whose model has pii.enabled=false, lists no detectors, or
// whose detectors can't be resolved gets no entry → it is intercepted
// and forwarded unredacted (audit events still record traffic). An
// unresolvable detector is recorded as an error-detector so the
// request fails closed at request time rather than leaking.
resolver := app.PIINERResolver()
detectorsByHost := map[string][]pii.NERConfig{}
for host, modelName := range ownership.Owners {
cfg, exists := app.backendLoader.GetModelConfig(modelName)
if !exists {
continue
}
if !cfg.PIIIsEnabled() {
piiDisabled = append(piiDisabled, host)
// Resolve through the shared policy so cloud-proxy hosts inherit the
// instance-wide default detector when they name none of their own.
enabled, detectors := app.ResolvePIIPolicy(&cfg)
if !enabled || len(detectors) == 0 {
continue
}
cfgs := make([]pii.NERConfig, 0, len(detectors))
for _, name := range detectors {
nc, ok := resolver(name)
if !ok {
xlog.Error("mitm: detector model not resolvable; requests to host will fail closed", "host", host, "detector", name)
nc = pii.NERConfig{Detector: pii.NewErrNERDetector("detector model '" + name + "' not resolvable")}
}
cfgs = append(cfgs, nc)
}
detectorsByHost[host] = cfgs
}
handler := mitm.NewPIIHandler(mitm.PIIHandlerOptions{
Redactor: app.piiRedactor,
EventStore: app.piiEvents,
HostsWithPIIDisabled: piiDisabled,
EventStore: app.piiEvents,
DetectorsByHost: detectorsByHost,
})
srv, err := mitm.NewServer(mitm.Config{
@@ -132,7 +149,7 @@ func startMITMLocked(app *Application, options *config.ApplicationConfig) error
"ca_dir", caDir,
"intercept_hosts", effectiveHosts,
"model_owned_hosts", len(ownership.Owners),
"pii_disabled_hosts", len(piiDisabled),
"pii_detector_hosts", len(detectorsByHost),
)
return nil
}

View File

@@ -0,0 +1,51 @@
package application
import (
"github.com/mudler/LocalAI/core/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ResolvePIIPolicy", func() {
chat := config.FLAG_CHAT
bp := func(b bool) *bool { return &b }
mk := func(c *config.ApplicationConfig) *Application {
return &Application{applicationConfig: c}
}
It("lets an explicit pii.enabled=false win over the global default detector", func() {
app := mk(&config.ApplicationConfig{PIIDefaultDetectors: []string{"pf"}})
cfg := &config.ModelConfig{Backend: "cloud-proxy", KnownUsecases: &chat}
cfg.PII.Enabled = bp(false)
enabled, dets := app.ResolvePIIPolicy(cfg)
Expect(enabled).To(BeFalse())
Expect(dets).To(BeNil())
})
It("enables a cloud-proxy model with the global default detector (closes the no-op gap)", func() {
// cloud-proxy defaults PIIIsEnabled()==true but lists no detectors, so
// without a global default it scans with nothing.
app := mk(&config.ApplicationConfig{PIIDefaultDetectors: []string{"pf"}})
cfg := &config.ModelConfig{Backend: "cloud-proxy"}
enabled, dets := app.ResolvePIIPolicy(cfg)
Expect(enabled).To(BeTrue())
Expect(dets).To(Equal([]string{"pf"}))
})
It("leaves a non-cloud model off by default (no instance usecase default-on)", func() {
app := mk(&config.ApplicationConfig{PIIDefaultDetectors: []string{"pf"}})
cfg := &config.ModelConfig{Backend: "llama-cpp", KnownUsecases: &chat}
enabled, _ := app.ResolvePIIPolicy(cfg)
Expect(enabled).To(BeFalse())
})
It("prefers the model's own detectors over the global default", func() {
app := mk(&config.ApplicationConfig{PIIDefaultDetectors: []string{"global-pf"}})
cfg := &config.ModelConfig{Backend: "cloud-proxy"}
cfg.PII.Detectors = []string{"own-pf"}
enabled, dets := app.ResolvePIIPolicy(cfg)
Expect(enabled).To(BeTrue())
Expect(dets).To(Equal([]string{"own-pf"}))
})
})

View File

@@ -25,6 +25,7 @@ import (
"github.com/mudler/LocalAI/core/services/storage"
coreStartup "github.com/mudler/LocalAI/core/startup"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/signals"
"github.com/mudler/LocalAI/pkg/vram"
@@ -53,7 +54,6 @@ func New(opts ...config.AppOption) (*Application, error) {
caps, err := xsysinfo.CPUCapabilities()
if err == nil {
xlog.Debug("CPU capabilities", "capabilities", caps)
}
gpus, err := xsysinfo.GPUs()
if err == nil {
@@ -68,18 +68,28 @@ func New(opts ...config.AppOption) (*Application, error) {
return nil, fmt.Errorf("models path cannot be empty")
}
err = os.MkdirAll(options.SystemState.Model.ModelsPath, 0750)
err = os.MkdirAll(options.SystemState.Model.ModelsPath, 0o750)
if err != nil {
return nil, fmt.Errorf("unable to create ModelPath: %q", err)
}
// Reap *.partial downloads abandoned by a previous run (killed mid-transfer
// by an OOM/restart, or stalled before cleanup could run). The 24h window
// is well beyond any legitimate in-flight download, so this never trims an
// active transfer; it just stops dead partials accumulating on the volume.
if removed, cErr := downloader.CleanupStalePartialFiles(options.SystemState.Model.ModelsPath, 24*time.Hour); cErr != nil {
xlog.Warn("Failed to reap stale partial downloads", "error", cErr)
} else if removed > 0 {
xlog.Info("Reaped stale partial downloads", "count", removed)
}
if options.GeneratedContentDir != "" {
err := os.MkdirAll(options.GeneratedContentDir, 0750)
err := os.MkdirAll(options.GeneratedContentDir, 0o750)
if err != nil {
return nil, fmt.Errorf("unable to create ImageDir: %q", err)
}
}
if options.UploadDir != "" {
err := os.MkdirAll(options.UploadDir, 0750)
err := os.MkdirAll(options.UploadDir, 0o750)
if err != nil {
return nil, fmt.Errorf("unable to create UploadDir: %q", err)
}
@@ -87,7 +97,7 @@ func New(opts ...config.AppOption) (*Application, error) {
// Create and migrate data directory
if options.DataPath != "" {
if err := os.MkdirAll(options.DataPath, 0750); err != nil {
if err := os.MkdirAll(options.DataPath, 0o750); err != nil {
return nil, fmt.Errorf("unable to create DataPath: %q", err)
}
// Migrate data from DynamicConfigsDir to DataPath if needed
@@ -192,44 +202,14 @@ func New(opts ...config.AppOption) (*Application, error) {
xlog.Info("stats: disabled by --disable-stats")
}
// Wire the regex PII filter. Default-on: a single-user box gets
// the built-in pattern set the first time it starts, with email/
// phone/SSN/credit-card on mask and api_key_prefix on block. If
// the operator wants different actions, --pii-config points at a
// YAML file that overrides per-id; --disable-pii turns it off
// entirely.
if !options.DisablePII {
patterns, err := pii.LoadConfig(options.PIIConfigPath)
if err != nil {
return nil, fmt.Errorf("pii config: %w", err)
}
application.piiRedactor = pii.NewRedactor(patterns)
application.piiEvents = pii.NewMemoryEventStore(0)
// Apply persisted per-pattern overrides — admins toggling
// action/disabled via the UI and clicking "Save to disk" land
// here on the next start. Bad ids are warned and ignored so a
// stale entry doesn't block startup.
for id, ov := range options.PIIPatternOverrides {
if ov.Action != nil {
if err := application.piiRedactor.SetAction(id, pii.Action(*ov.Action)); err != nil {
xlog.Warn("pii: persisted override skipped", "pattern", id, "error", err)
continue
}
}
if ov.Disabled != nil {
if err := application.piiRedactor.SetDisabled(id, *ov.Disabled); err != nil {
xlog.Warn("pii: persisted disable skipped", "pattern", id, "error", err)
}
}
}
xlog.Info("pii: filter enabled",
"patterns", len(patterns),
"config_path", options.PIIConfigPath,
"persisted_overrides", len(options.PIIPatternOverrides),
)
} else {
xlog.Info("pii: disabled by --disable-pii")
}
// Wire the PII filter subsystem. The redactor is now a stateless
// handle — detection is driven by per-model NER detectors
// (pii.detectors → the detector model's pii_detection policy), run
// request-side by the chat middleware and the MITM input path. The
// regex tier was removed; redaction is opt-in per model via
// PIIIsEnabled(). The event store backs the /api/pii/events audit log.
application.piiRedactor = &pii.Redactor{}
application.piiEvents = pii.NewMemoryEventStore(0)
// Wire the routing decision log. Always-on when stats are enabled —
// the per-router admin page reads this as the live activity feed
@@ -517,7 +497,7 @@ func startWatcher(options *config.ApplicationConfig) {
if _, err := os.Stat(options.DynamicConfigsDir); err != nil {
if os.IsNotExist(err) {
// We try to create the directory if it does not exist and was specified
if err := os.MkdirAll(options.DynamicConfigsDir, 0700); err != nil {
if err := os.MkdirAll(options.DynamicConfigsDir, 0o700); err != nil {
xlog.Error("failed creating DynamicConfigsDir", "error", err)
}
} else {
@@ -764,16 +744,6 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
options.MITMListen = *settings.MITMListen
}
// PII pattern overrides — file is the only source; CLI flags don't
// reach into this map. Apply unconditionally when present; the
// redactor wiring below sees the result on first construction.
if settings.PIIPatternOverrides != nil {
options.PIIPatternOverrides = make(map[string]config.PIIPatternRuntimeOverride, len(*settings.PIIPatternOverrides))
for id, ov := range *settings.PIIPatternOverrides {
options.PIIPatternOverrides[id] = ov
}
}
// Backend upgrade flags
if settings.AutoUpgradeBackends != nil {
if !options.AutoUpgradeBackends {
@@ -924,7 +894,7 @@ func loadOrGenerateHMACSecret(path string) (string, error) {
}
secret := hex.EncodeToString(b)
if err := os.WriteFile(path, []byte(secret), 0600); err != nil {
if err := os.WriteFile(path, []byte(secret), 0o600); err != nil {
return "", fmt.Errorf("failed to persist HMAC secret: %w", err)
}

66
core/backend/depth.go Normal file
View File

@@ -0,0 +1,66 @@
package backend
import (
"context"
"fmt"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/model"
)
// Depth runs depth estimation (Depth Anything 3) on the supplied image and
// returns the full DepthResponse: per-pixel metric depth + confidence + sky,
// camera pose (extrinsics/intrinsics), an optional 3D point cloud and any
// requested exports (glb/colmap). The include_* flags and exports mirror the
// DepthRequest proto so callers can ask for less work.
func Depth(
ctx context.Context,
in *proto.DepthRequest,
loader *model.ModelLoader,
appConfig *config.ApplicationConfig,
modelConfig config.ModelConfig,
) (*proto.DepthResponse, error) {
opts := ModelOptions(modelConfig, appConfig)
depthModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}
if depthModel == nil {
return nil, fmt.Errorf("could not load depth model")
}
var startTime time.Time
if appConfig.EnableTracing {
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
startTime = time.Now()
}
res, err := depthModel.Depth(ctx, in)
if appConfig.EnableTracing {
errStr := ""
if err != nil {
errStr = err.Error()
}
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: startTime,
Duration: time.Since(startTime),
Type: trace.BackendTraceDepth,
ModelName: modelConfig.Name,
Backend: modelConfig.Backend,
Summary: trace.TruncateString(in.GetSrc(), 200),
Error: errStr,
Data: map[string]any{
"exports": in.GetExports(),
},
})
}
return res, err
}

View File

@@ -368,6 +368,25 @@ func gRPCPredictOpts(c config.ModelConfig, modelPath string) *pb.PredictOptions
if c.ReasoningEffort != "" {
metadata["reasoning_effort"] = c.ReasoningEffort
}
// Client request metadata overrides the server-derived reasoning levers and
// reaches every backend through these standalone string keys (Python backends
// read them directly). The reserved blob key is server-owned and skipped.
for k, v := range c.RequestMetadata {
if k == "chat_template_kwargs" {
continue
}
metadata[k] = v
}
// Build the generic chat_template_kwargs blob (model config map + coerced
// metadata) for llama.cpp and write it LAST so a client cannot clobber it.
if blob := c.ResolveChatTemplateKwargs(metadata); len(blob) > 0 {
b, err := json.Marshal(blob)
if err != nil {
xlog.Warn("failed to marshal chat_template_kwargs", "error", err)
} else {
metadata["chat_template_kwargs"] = string(b)
}
}
pbOpts.Metadata = metadata
// Logprobs and TopLogprobs are set by the caller if provided

View File

@@ -161,3 +161,67 @@ var _ = Describe("grpcModelOpts NBatch", func() {
Expect(opts.ContextSize).To(BeEquivalentTo(4096), "n_batch must match the effective n_ctx the backend receives")
})
})
// Guards the generic chat_template_kwargs forwarding: the model config map plus any
// per-request metadata overrides are merged, coerced, and serialised into the
// backend metadata blob that llama.cpp reads. Client metadata also overrides the
// server-derived standalone enable_thinking key (cross-backend consistency).
var _ = Describe("gRPCPredictOpts chat_template_kwargs metadata", func() {
baseCfg := func() config.ModelConfig {
cfg := config.ModelConfig{}
cfg.SetDefaults()
return cfg
}
It("serialises the config map into the chat_template_kwargs blob", func() {
cfg := baseCfg()
cfg.ChatTemplateKwargs = map[string]any{"preserve_thinking": true}
opts := gRPCPredictOpts(cfg, "/tmp/models")
Expect(opts.Metadata).To(HaveKey("chat_template_kwargs"))
var blob map[string]any
Expect(json.Unmarshal([]byte(opts.Metadata["chat_template_kwargs"]), &blob)).To(Succeed())
Expect(blob).To(HaveKeyWithValue("preserve_thinking", true))
})
It("serialises reasoning_effort into the blob as a JSON string", func() {
cfg := baseCfg()
cfg.ReasoningEffort = "high"
opts := gRPCPredictOpts(cfg, "/tmp/models")
Expect(opts.Metadata).To(HaveKey("chat_template_kwargs"))
var blob map[string]any
Expect(json.Unmarshal([]byte(opts.Metadata["chat_template_kwargs"]), &blob)).To(Succeed())
// reasoning_effort must remain a string in the blob (jinja templates that
// key on the level read a string), unlike enable_thinking which is a bool.
Expect(blob["reasoning_effort"]).To(BeAssignableToTypeOf(""))
Expect(blob).To(HaveKeyWithValue("reasoning_effort", "high"))
})
It("lets client request metadata override the server-derived enable_thinking key", func() {
cfg := baseCfg()
disable := true
cfg.ReasoningConfig = reasoning.Config{DisableReasoning: &disable} // server: enable_thinking=false
cfg.RequestMetadata = map[string]string{"enable_thinking": "true"} // client overrides
opts := gRPCPredictOpts(cfg, "/tmp/models")
// standalone key (Python backends) reflects the client override
Expect(opts.Metadata).To(HaveKeyWithValue("enable_thinking", "true"))
// blob (llama.cpp) reflects it too, as a real bool
var blob map[string]any
Expect(json.Unmarshal([]byte(opts.Metadata["chat_template_kwargs"]), &blob)).To(Succeed())
Expect(blob).To(HaveKeyWithValue("enable_thinking", true))
})
It("does not let a client clobber the blob via a chat_template_kwargs metadata key", func() {
cfg := baseCfg()
cfg.ChatTemplateKwargs = map[string]any{"preserve_thinking": true}
cfg.RequestMetadata = map[string]string{"chat_template_kwargs": "{\"preserve_thinking\": false}"}
opts := gRPCPredictOpts(cfg, "/tmp/models")
var blob map[string]any
Expect(json.Unmarshal([]byte(opts.Metadata["chat_template_kwargs"]), &blob)).To(Succeed())
Expect(blob).To(HaveKeyWithValue("preserve_thinking", true))
})
It("omits the blob when there is nothing to forward", func() {
opts := gRPCPredictOpts(baseCfg(), "/tmp/models")
Expect(opts.Metadata).ToNot(HaveKey("chat_template_kwargs"))
})
})

View File

@@ -0,0 +1,150 @@
package backend
import (
"context"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
model "github.com/mudler/LocalAI/pkg/model"
)
// TokenEntity is one detected span from a token-classification (NER)
// model. Mirrors pb.TokenClassifyEntity but keeps the proto type out of
// consumers. Start/End are BYTE offsets into the classified text,
// half-open (addressing text[Start:End]) — the proto contract. Group is
// the model's entity label (e.g. "private_person", "EMAIL").
type TokenEntity struct {
Group string `json:"group"`
Start int `json:"start"`
End int `json:"end"`
Score float32 `json:"score"`
Text string `json:"text"`
}
// TokenClassifyOptions controls a single TokenClassify request.
type TokenClassifyOptions struct {
// Threshold drops entities the backend scores below this value at
// the source. 0 returns everything the model emits; downstream
// callers (e.g. the PII redactor's MinScore) can still filter
// further once they know the per-request policy.
Threshold float32
}
// TokenClassifier runs a token-classification model over text and
// returns the detected entity spans. Implemented by NewTokenClassifier
// over a model-loaded backend; the PII redactor's encoder/NER tier
// consumes this via a pii.NERDetector adapter (see
// core/services/routing/piidetector).
type TokenClassifier interface {
TokenClassify(ctx context.Context, text string) ([]TokenEntity, error)
}
// NewTokenClassifier binds (loader, modelConfig, appConfig) into a
// TokenClassifier. The underlying backend is resolved lazily on the
// first call, mirroring NewScorer.
func NewTokenClassifier(loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig, opts TokenClassifyOptions) TokenClassifier {
return &modelTokenClassifier{loader: loader, modelConfig: modelConfig, appConfig: appConfig, opts: opts}
}
type modelTokenClassifier struct {
loader *model.ModelLoader
modelConfig config.ModelConfig
appConfig *config.ApplicationConfig
opts TokenClassifyOptions
}
func (m *modelTokenClassifier) TokenClassify(ctx context.Context, text string) ([]TokenEntity, error) {
fn, err := ModelTokenClassify(text, m.opts, m.loader, m.modelConfig, m.appConfig)
if err != nil {
return nil, err
}
return fn(ctx)
}
// ModelTokenClassify loads the backend for modelConfig and returns a
// closure that classifies `text`. Mirrors ModelScore: the closure is
// bound to the loaded model so a caller can reuse it within a request
// without re-resolving the backend.
//
// When tracing is enabled it records a BackendTraceTokenClassify row so the
// detector's output — every entity's group, byte range, confidence and the
// matched substring — shows in the Traces UI alongside the request it gated.
// This is the technical view for debugging false positives (e.g. a phone
// number scored as SSN); the persisted PIIEvent keeps only a hash.
func ModelTokenClassify(text string, opts TokenClassifyOptions, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func(ctx context.Context) ([]TokenEntity, error), error) {
modelOpts := ModelOptions(modelConfig, appConfig)
inferenceModel, err := loader.Load(modelOpts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}
return func(ctx context.Context) ([]TokenEntity, error) {
var startTime time.Time
if appConfig.EnableTracing {
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
startTime = time.Now()
}
resp, err := inferenceModel.TokenClassify(ctx, &pb.TokenClassifyRequest{
Text: text,
Threshold: opts.Threshold,
})
entities := tokenClassifyResponseToEntities(resp)
if appConfig.EnableTracing {
trace.RecordBackendTrace(tokenClassifyTrace(modelConfig, text, opts.Threshold, entities, startTime, err))
}
if err != nil {
return nil, err
}
return entities, nil
}, nil
}
// tokenClassifyTrace assembles the Traces-UI row for one NER call: the input
// preview, the threshold, and every detected entity (group, byte range,
// confidence, matched text). Split out from the closure so the Data assembly
// is unit-testable without a live backend.
func tokenClassifyTrace(modelConfig config.ModelConfig, text string, threshold float32, entities []TokenEntity, start time.Time, callErr error) trace.BackendTrace {
errStr := ""
if callErr != nil {
errStr = callErr.Error()
}
return trace.BackendTrace{
Timestamp: start,
Duration: time.Since(start),
Type: trace.BackendTraceTokenClassify,
ModelName: modelConfig.Name,
Backend: modelConfig.Backend,
Summary: trace.TruncateString(text, 200),
Error: errStr,
Data: map[string]any{
"input_chars": len(text),
"threshold": threshold,
"entities": entities,
},
}
}
// tokenClassifyResponseToEntities converts the wire-format response into
// the value type consumed by callers. Extracted so the conversion can be
// unit-tested without a real backend (see token_classify_test.go).
func tokenClassifyResponseToEntities(resp *pb.TokenClassifyResponse) []TokenEntity {
if resp == nil {
return nil
}
out := make([]TokenEntity, 0, len(resp.Entities))
for _, e := range resp.Entities {
if e == nil {
continue
}
out = append(out, TokenEntity{
Group: e.EntityGroup,
Start: int(e.Start),
End: int(e.End),
Score: e.Score,
Text: e.Text,
})
}
return out
}

View File

@@ -0,0 +1,61 @@
package backend
import (
"errors"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("tokenClassifyResponseToEntities", func() {
It("returns nil for a nil response", func() {
Expect(tokenClassifyResponseToEntities(nil)).To(BeNil())
})
It("maps proto entities to TokenEntity, skipping nil rows", func() {
resp := &pb.TokenClassifyResponse{
Entities: []*pb.TokenClassifyEntity{
{EntityGroup: "private_person", Start: 3, End: 8, Score: 0.97, Text: "Alice"},
nil,
{EntityGroup: "EMAIL", Start: 20, End: 40, Score: 0.5, Text: "a@b.com"},
},
}
Expect(tokenClassifyResponseToEntities(resp)).To(Equal([]TokenEntity{
{Group: "private_person", Start: 3, End: 8, Score: 0.97, Text: "Alice"},
{Group: "EMAIL", Start: 20, End: 40, Score: 0.5, Text: "a@b.com"},
}))
})
It("returns an empty (non-nil) slice for a response with no entities", func() {
out := tokenClassifyResponseToEntities(&pb.TokenClassifyResponse{})
Expect(out).NotTo(BeNil())
Expect(out).To(BeEmpty())
})
})
var _ = Describe("tokenClassifyTrace", func() {
cfg := config.ModelConfig{Name: "privacy-filter", Backend: "privacy-filter"}
ents := []TokenEntity{{Group: "SSN", Start: 5, End: 16, Score: 0.62, Text: "123-45-6789"}}
It("captures model, input preview, threshold and per-entity detail", func() {
tr := tokenClassifyTrace(cfg, "ssn is 123-45-6789", 0.5, ents, time.Now(), nil)
Expect(tr.Type).To(Equal(trace.BackendTraceTokenClassify))
Expect(tr.ModelName).To(Equal("privacy-filter"))
Expect(tr.Backend).To(Equal("privacy-filter"))
Expect(tr.Summary).To(ContainSubstring("ssn is"))
Expect(tr.Error).To(BeEmpty())
Expect(tr.Data["input_chars"]).To(Equal(len("ssn is 123-45-6789")))
Expect(tr.Data["threshold"]).To(BeEquivalentTo(float32(0.5)))
Expect(tr.Data["entities"]).To(Equal(ents))
})
It("records the backend error string when the call failed", func() {
tr := tokenClassifyTrace(cfg, "x", 0, nil, time.Now(), errors.New("boom"))
Expect(tr.Error).To(Equal("boom"))
})
})

View File

@@ -57,25 +57,6 @@ type ApplicationConfig struct {
// touch disk or memory.
DisableStats bool
// PIIConfigPath points to an optional YAML file describing the PII
// pattern set. When empty, the routing/pii module's DefaultPatterns()
// (email, phone, SSN, credit card, IPv4, API key prefixes) are
// loaded with their default actions. Each entry overrides the
// matching default by ID:
//
// patterns:
// - id: email
// action: allow # downgrade default mask -> allow (log only)
// - id: ssn
// action: block # upgrade default mask -> block
//
// Unknown ids are rejected with a clear error at startup.
PIIConfigPath string
// DisablePII turns the regex PII filter off entirely. Default
// (false) enables it on the OpenAI chat completions route.
DisablePII bool
// MITMListen is the address (host:port) the cloudproxy MITM
// listener binds on. Empty disables the MITM proxy entirely.
// Use case: redacting PII from Claude Code / Codex CLI traffic
@@ -84,18 +65,20 @@ type ApplicationConfig struct {
// LocalAI exposes at /api/middleware/proxy-ca.crt.
MITMListen string
// PIIDefaultDetectors lists token-classification (NER) detector model
// names applied to any PII-enabled model that does not name its own
// pii.detectors. This makes cloud-proxy / MITM redaction work out of the
// box (those default to PII-enabled but carry no detector list) and lets
// an operator set one detector for the whole instance. Set at runtime via
// POST /api/settings; read live by Application.ResolvePIIPolicy.
PIIDefaultDetectors []string
// MITMCADir holds the persisted MITM proxy CA cert and private
// key. The CA is generated on first start; subsequent starts
// reload it so clients keep trusting the same root. The key
// file is mode 0600.
MITMCADir string
// PIIPatternOverrides applies persisted per-id deltas (action,
// disabled) to the live redactor at startup. Loaded from
// runtime_settings.json and applied right after pii.NewRedactor.
// nil/empty leaves the YAML defaults in place.
PIIPatternOverrides map[string]PIIPatternRuntimeOverride
DisableWebUI bool
OllamaAPIRootEndpoint bool
EnforcePredownloadScans bool
@@ -488,6 +471,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) {
@@ -603,6 +596,7 @@ func WithJSONStringPreload(configFile string) AppOption {
o.PreloadJSONModels = configFile
}
}
func WithConfigFile(configFile string) AppOption {
return func(o *ApplicationConfig) {
o.ConfigFile = configFile
@@ -691,21 +685,6 @@ func WithDisableStats(disable bool) AppOption {
}
}
// WithPIIConfigPath points the routing PII filter at a YAML config
// file. CLI: --pii-config.
func WithPIIConfigPath(path string) AppOption {
return func(o *ApplicationConfig) {
o.PIIConfigPath = path
}
}
// WithDisablePII turns the regex PII filter off. CLI: --disable-pii.
func WithDisablePII(disable bool) AppOption {
return func(o *ApplicationConfig) {
o.DisablePII = disable
}
}
// WithMITMListen sets the address the cloudproxy MITM listener
// binds on. Empty = disabled. CLI: --mitm-listen.
func WithMITMListen(addr string) AppOption {
@@ -1127,6 +1106,8 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
mitmListen := o.MITMListen
piiDefaultDetectors := append([]string(nil), o.PIIDefaultDetectors...)
return RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
WatchdogIdleEnabled: &watchdogIdle,
@@ -1181,6 +1162,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
LogoHorizontalFile: &logoHorizontalFile,
FaviconFile: &faviconFile,
MITMListen: &mitmListen,
PIIDefaultDetectors: &piiDefaultDetectors,
}
}
@@ -1198,18 +1180,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
@@ -1410,6 +1396,10 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
o.MITMListen = *settings.MITMListen
}
if settings.PIIDefaultDetectors != nil {
o.PIIDefaultDetectors = append([]string(nil), (*settings.PIIDefaultDetectors)...)
}
// Note: ApiKeys requires special handling (merging with startup keys) - handled in caller
return requireRestart

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

@@ -8,25 +8,27 @@ import (
// Usecase name constants — the canonical string values used in gallery entries,
// model configs (known_usecases), and UsecaseInfoMap keys.
const (
UsecaseChat = "chat"
UsecaseCompletion = "completion"
UsecaseEdit = "edit"
UsecaseVision = "vision"
UsecaseEmbeddings = "embeddings"
UsecaseTokenize = "tokenize"
UsecaseImage = "image"
UsecaseVideo = "video"
UsecaseTranscript = "transcript"
UsecaseTTS = "tts"
UsecaseSoundGeneration = "sound_generation"
UsecaseRerank = "rerank"
UsecaseDetection = "detection"
UsecaseVAD = "vad"
UsecaseAudioTransform = "audio_transform"
UsecaseDiarization = "diarization"
UsecaseRealtimeAudio = "realtime_audio"
UsecaseFaceRecognition = "face_recognition"
UsecaseSpeakerRecognition = "speaker_recognition"
UsecaseChat = "chat"
UsecaseCompletion = "completion"
UsecaseEdit = "edit"
UsecaseVision = "vision"
UsecaseEmbeddings = "embeddings"
UsecaseTokenize = "tokenize"
UsecaseImage = "image"
UsecaseVideo = "video"
UsecaseTranscript = "transcript"
UsecaseTTS = "tts"
UsecaseSoundGeneration = "sound_generation"
UsecaseRerank = "rerank"
UsecaseDetection = "detection"
UsecaseDepth = "depth"
UsecaseVAD = "vad"
UsecaseAudioTransform = "audio_transform"
UsecaseDiarization = "diarization"
UsecaseRealtimeAudio = "realtime_audio"
UsecaseFaceRecognition = "face_recognition"
UsecaseSpeakerRecognition = "speaker_recognition"
UsecaseTokenClassify = "token_classify"
)
// GRPCMethod identifies a Backend service RPC from backend.proto.
@@ -44,6 +46,7 @@ const (
MethodSoundGeneration GRPCMethod = "SoundGeneration"
MethodTokenizeString GRPCMethod = "TokenizeString"
MethodDetect GRPCMethod = "Detect"
MethodDepth GRPCMethod = "Depth"
MethodRerank GRPCMethod = "Rerank"
MethodVAD GRPCMethod = "VAD"
MethodAudioTransform GRPCMethod = "AudioTransform"
@@ -54,6 +57,7 @@ const (
MethodVoiceVerify GRPCMethod = "VoiceVerify"
MethodVoiceEmbed GRPCMethod = "VoiceEmbed"
MethodVoiceAnalyze GRPCMethod = "VoiceAnalyze"
MethodTokenClassify GRPCMethod = "TokenClassify"
)
// UsecaseInfo describes a single known_usecase value and how it maps
@@ -141,6 +145,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
GRPCMethod: MethodDetect,
Description: "Object detection via the Detect RPC with bounding boxes.",
},
UsecaseDepth: {
Flag: FLAG_DEPTH,
GRPCMethod: MethodDepth,
Description: "Per-pixel metric depth, camera pose and 3D point cloud via the Depth RPC (Depth Anything 3).",
},
UsecaseVAD: {
Flag: FLAG_VAD,
GRPCMethod: MethodVAD,
@@ -171,6 +180,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
GRPCMethod: MethodVoiceVerify,
Description: "Speaker recognition — verify identity, embed and analyze voice via VoiceVerify, VoiceEmbed and VoiceAnalyze RPCs.",
},
UsecaseTokenClassify: {
Flag: FLAG_TOKEN_CLASSIFY,
GRPCMethod: MethodTokenClassify,
Description: "Per-token classification (NER) via the TokenClassify RPC — the PII detector tier. Declared explicitly via known_usecases; never auto-guessed, since the token-classification head is not useful as general generation or embeddings.",
},
}
// BackendCapability describes which gRPC methods and usecases a backend supports.
@@ -207,6 +221,17 @@ var BackendCapabilities = map[string]BackendCapability{
AcceptsImages: true, // requires mmproj
Description: "llama.cpp GGUF models — LLM inference with optional vision via mmproj",
},
// privacy-filter is the standalone GGML engine (backend/cpp/privacy-filter,
// wrapping privacy-filter.cpp) for the openai-privacy-filter PII/NER token
// classifier — the dedicated TokenClassify path that replaces the
// patched-llama.cpp route. Never auto-guessed; declared explicitly via
// known_usecases: [token_classify].
"privacy-filter": {
GRPCMethods: []GRPCMethod{MethodTokenClassify},
PossibleUsecases: []string{UsecaseTokenClassify},
DefaultUsecases: []string{UsecaseTokenClassify},
Description: "privacy-filter.cpp — standalone GGML backend for openai-privacy-filter PII/NER token classification",
},
"vllm": {
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodEmbedding},
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseEmbeddings, UsecaseVision},
@@ -488,6 +513,13 @@ var BackendCapabilities = map[string]BackendCapability{
DefaultUsecases: []string{UsecaseDetection},
Description: "RF-DETR C++ object detection",
},
"depth-anything": {
GRPCMethods: []GRPCMethod{MethodDepth, MethodPredict, MethodGenerateImage},
PossibleUsecases: []string{UsecaseDepth},
DefaultUsecases: []string{UsecaseDepth},
AcceptsImages: true,
Description: "Depth Anything 3 C++ — per-pixel metric depth, camera pose and 3D point cloud",
},
// --- Face and speaker recognition backends ---
"insightface": {

View File

@@ -0,0 +1,48 @@
package config_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
)
// ResolveChatTemplateKwargs layers the model config map (base) under the coerced
// backend metadata (server reasoning levers + client request overrides).
var _ = Describe("ModelConfig.ResolveChatTemplateKwargs", func() {
It("returns nil when nothing is set", func() {
c := &config.ModelConfig{}
Expect(c.ResolveChatTemplateKwargs(nil)).To(BeNil())
})
It("returns the config map when no metadata is present", func() {
c := &config.ModelConfig{ChatTemplateKwargs: map[string]any{"preserve_thinking": true}}
Expect(c.ResolveChatTemplateKwargs(nil)).To(HaveKeyWithValue("preserve_thinking", true))
})
It("lets metadata override the config map", func() {
c := &config.ModelConfig{ChatTemplateKwargs: map[string]any{"enable_thinking": true}}
got := c.ResolveChatTemplateKwargs(map[string]string{"enable_thinking": "false"})
Expect(got).To(HaveKeyWithValue("enable_thinking", false))
})
It("coerces true/false to bool and leaves other strings as-is", func() {
c := &config.ModelConfig{}
got := c.ResolveChatTemplateKwargs(map[string]string{
"enable_thinking": "true",
"reasoning_effort": "high",
})
Expect(got).To(HaveKeyWithValue("enable_thinking", true))
Expect(got).To(HaveKeyWithValue("reasoning_effort", "high"))
})
It("skips the reserved chat_template_kwargs metadata key but keeps siblings", func() {
c := &config.ModelConfig{}
got := c.ResolveChatTemplateKwargs(map[string]string{
"chat_template_kwargs": "{\"x\":1}",
"preserve_thinking": "true",
})
Expect(got).ToNot(HaveKey("chat_template_kwargs"))
Expect(got).To(HaveKeyWithValue("preserve_thinking", true))
})
})

View File

@@ -19,8 +19,19 @@ const (
defaultNGPULayers = 99999999
)
func guessGGUFFromFile(cfg *ModelConfig, f *gguf.GGUFFile, defaultCtx int) {
// reservedNonChatModel reports whether the operator reserved this model for an
// internal primitive — the router score classifier or the PII NER
// token_classify tier. Such a model has no chat template and must not be
// given the generative-chat defaults the GGUF importer otherwise applies
// (FLAG_CHAT, jinja templating): surfacing it in chat pickers defeats the
// reservation. Operators who do want a combined model declare both usecases
// explicitly — the combination is valid.
func reservedNonChatModel(cfg *ModelConfig) bool {
return cfg.KnownUsecases != nil &&
(*cfg.KnownUsecases&(FLAG_SCORE|FLAG_TOKEN_CLASSIFY)) != 0
}
func guessGGUFFromFile(cfg *ModelConfig, f *gguf.GGUFFile, defaultCtx int) {
if defaultCtx == 0 && cfg.ContextSize == nil {
ctxSize := f.EstimateLLaMACppRun().ContextSize
if ctxSize > 0 {
@@ -77,11 +88,19 @@ func guessGGUFFromFile(cfg *ModelConfig, f *gguf.GGUFFile, defaultCtx int) {
cfg.Name = f.Metadata().Name
}
// Instruct to use template from llama.cpp
cfg.TemplateConfig.UseTokenizerTemplate = true
cfg.FunctionsConfig.GrammarConfig.NoGrammar = true
cfg.Options = append(cfg.Options, "use_jinja:true")
cfg.KnownUsecaseStrings = append(cfg.KnownUsecaseStrings, "FLAG_CHAT")
// A model the operator reserved for an internal primitive (the router
// score classifier, or the PII NER token_classify tier) is not a chat
// model: it carries no chat template and must not be painted with the
// generative-chat defaults — appending FLAG_CHAT here would fold chat
// into KnownUsecases on the next sync and surface the model in every
// chat picker. Respect the declaration.
if !reservedNonChatModel(cfg) {
// Instruct to use template from llama.cpp
cfg.TemplateConfig.UseTokenizerTemplate = true
cfg.FunctionsConfig.GrammarConfig.NoGrammar = true
cfg.Options = append(cfg.Options, "use_jinja:true")
cfg.KnownUsecaseStrings = append(cfg.KnownUsecaseStrings, "FLAG_CHAT")
}
// Apply per-model-family inference parameter defaults (temperature, top_p, etc.)
ApplyInferenceDefaults(cfg, f.Metadata().Name)

View File

@@ -0,0 +1,190 @@
package config
import (
"fmt"
"strconv"
"strings"
"github.com/mudler/LocalAI/pkg/xsysinfo"
"github.com/mudler/xlog"
)
// Hardware-driven model-config defaults.
//
// This sits alongside the other config overriders (ApplyInferenceDefaults for
// model families, guessDefaultsFromFile for GGUF/NGPULayers): they all
// heuristically fill ModelConfig values the user left unset. Hardware tuning is
// the same domain — "adjust the config from the device that will run it" — so
// it lives here rather than scattered into the backend or a separate package.
//
// The heuristics are parameterized on a GPU descriptor (not on direct
// detection) so they apply in both deployment shapes: SetDefaults passes the
// LocalGPU on a single host, and the distributed router passes the *selected
// node's* reported GPU before loading there (the frontend that loaded the
// config may have no GPU at all).
// GPU describes the device that will run a model.
type GPU struct {
// Vendor is "nvidia", "amd", … (matches xsysinfo vendor constants).
Vendor string
// ComputeCapability is the NVIDIA compute capability as "major.minor"
// (e.g. "12.1" for GB10 / DGX Spark). Empty for non-NVIDIA / unknown.
ComputeCapability string
// VRAM is total device memory in bytes (0 = unknown).
VRAM uint64
}
// Physical batch (n_batch / n_ubatch) defaults.
const (
// DefaultPhysicalBatch is the conservative default when no hardware-specific
// tuning applies. Matches backend.DefaultBatchSize.
DefaultPhysicalBatch = 512
// BlackwellPhysicalBatch is the default on NVIDIA Blackwell consumer GPUs
// (sm_12x: sm_120 RTX 50-series, sm_121 GB10 / DGX Spark). A larger physical
// batch materially lifts MoE prefill there (per-expert GEMM tiles fill
// better); measured on a GB10 with Qwen3-30B-A3B to saturate around 2048.
BlackwellPhysicalBatch = 2048
)
// IsNVIDIABlackwell reports whether the GPU is in the NVIDIA Blackwell consumer
// family (sm_12x). Datacenter Blackwell (B100/B200/GB200, sm_100 / cc 10.0)
// reports a different compute capability and is intentionally not matched.
func (g GPU) IsNVIDIABlackwell() bool {
maj, _ := parseComputeCapability(g.ComputeCapability)
return maj >= 12
}
// PhysicalBatch returns the canonical physical batch (n_batch/n_ubatch) for the
// given hardware, used when the model config leaves batch unset.
func PhysicalBatch(g GPU) int {
if g.IsNVIDIABlackwell() {
return BlackwellPhysicalBatch
}
return DefaultPhysicalBatch
}
// IsManagedPhysicalBatch reports whether n is a value PhysicalBatch assigns.
// Callers that re-tune a value chosen by an upstream host (the distributed
// router correcting the frontend's guess) use this to avoid clobbering an
// explicit user batch such as 1024.
func IsManagedPhysicalBatch(n int) bool {
return n == DefaultPhysicalBatch || n == BlackwellPhysicalBatch
}
// Parallel-slot (n_parallel) VRAM tiers. llama.cpp serializes requests at
// n_parallel=1 (the backend default) and only auto-enables continuous batching
// when n_parallel > 1 — so a single-slot default makes concurrent requests
// queue. We default a slot count by GPU size so multi-user serving works out of
// the box. With the backend's unified KV cache the slots SHARE the context
// budget, so more slots add concurrency without multiplying KV memory.
const (
parallelSlotsVRAMHigh = uint64(32) << 30 // >=32 GiB -> 8 slots
parallelSlotsVRAMMid = uint64(8) << 30 // >=8 GiB -> 4 slots
parallelSlotsVRAMLow = uint64(4) << 30 // >=4 GiB -> 2 slots
)
// DefaultParallelSlots returns the n_parallel default for the given GPU. Returns
// 1 (no concurrency) when VRAM is unknown or too small, so we never change
// behavior on CPU-only / tiny devices.
func DefaultParallelSlots(g GPU) int {
switch {
case g.VRAM >= parallelSlotsVRAMHigh:
return 8
case g.VRAM >= parallelSlotsVRAMMid:
return 4
case g.VRAM >= parallelSlotsVRAMLow:
return 2
default:
return 1
}
}
// EnsureParallelOption appends a VRAM-scaled "parallel:N" backend option when the
// model doesn't already set one (and the GPU warrants concurrency). Returns the
// possibly-extended options. Shared by the single-host config path
// (ApplyHardwareDefaults) and the distributed router (per selected node).
func EnsureParallelOption(opts []string, gpu GPU) []string {
if slots := DefaultParallelSlots(gpu); slots > 1 && !hasParallelOption(opts) {
return append(opts, fmt.Sprintf("parallel:%d", slots))
}
return opts
}
// hasParallelOption reports whether the model already sets parallel/n_parallel
// (backend options are "name:value" strings) so we never override an explicit value.
func hasParallelOption(opts []string) bool {
for _, o := range opts {
name := o
if i := strings.IndexByte(o, ':'); i >= 0 {
name = o[:i]
}
switch strings.TrimSpace(strings.ToLower(name)) {
case "parallel", "n_parallel":
return true
}
}
return false
}
// localGPU builds a GPU descriptor from local detection, used by SetDefaults on
// a single host (the distributed router builds it from the selected node's
// reported info instead). It is a package var so tests can inject a
// deterministic device — detection does a live nvidia-smi call.
var localGPU = func() GPU {
vendor, _ := xsysinfo.DetectGPUVendor()
vram, _ := xsysinfo.TotalAvailableVRAM()
return GPU{
Vendor: vendor,
ComputeCapability: xsysinfo.NVIDIAComputeCapability(),
VRAM: vram,
}
}
// ApplyHardwareDefaults fills ModelConfig values that depend on the target GPU
// and were left unset by the user. Currently: a larger physical batch on
// Blackwell. Explicit config always wins (we only touch zero values).
func ApplyHardwareDefaults(cfg *ModelConfig, gpu GPU) {
if cfg == nil {
return
}
if cfg.Batch == 0 && gpu.IsNVIDIABlackwell() {
cfg.Batch = BlackwellPhysicalBatch
xlog.Debug("[hardware_defaults] Blackwell GPU: defaulting physical batch",
"batch", cfg.Batch, "compute_cap", gpu.ComputeCapability)
}
// Enable concurrent serving by default on a capable GPU: without this the
// llama.cpp backend runs n_parallel=1 and serializes multi-user requests
// (continuous batching stays off). Unified KV means the slots share the
// context budget, so this is concurrency without extra KV memory. Explicit
// parallel/n_parallel in the model options always wins.
if before := len(cfg.Options); true {
cfg.Options = EnsureParallelOption(cfg.Options, gpu)
if len(cfg.Options) > before {
xlog.Debug("[hardware_defaults] defaulting parallel slots for concurrent serving",
"option", cfg.Options[len(cfg.Options)-1], "vram_gib", gpu.VRAM>>30)
}
}
}
// parseComputeCapability splits a "major.minor" string into integer parts.
// Returns (-1, -1) when it can't be parsed.
func parseComputeCapability(cc string) (int, int) {
cc = strings.TrimSpace(cc)
if cc == "" {
return -1, -1
}
majStr, minStr := cc, "0"
if dot := strings.IndexByte(cc, '.'); dot >= 0 {
majStr, minStr = cc[:dot], cc[dot+1:]
}
maj, err := strconv.Atoi(strings.TrimSpace(majStr))
if err != nil {
return -1, -1
}
min, err := strconv.Atoi(strings.TrimSpace(minStr))
if err != nil {
min = 0
}
return maj, min
}

View File

@@ -0,0 +1,37 @@
package config
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Single-instance path: SetDefaults applies hardware defaults from the local
// GPU. The detection seam (localGPU) is injected so the path is deterministic
// without a real GPU.
var _ = Describe("SetDefaults hardware defaults (single-instance)", func() {
var orig func() GPU
BeforeEach(func() { orig = localGPU })
AfterEach(func() { localGPU = orig })
It("sets the physical batch on a local Blackwell GPU", func() {
localGPU = func() GPU { return GPU{ComputeCapability: "12.1"} }
cfg := &ModelConfig{}
cfg.SetDefaults()
Expect(cfg.Batch).To(Equal(BlackwellPhysicalBatch))
})
It("leaves batch unset on a non-Blackwell local GPU", func() {
localGPU = func() GPU { return GPU{ComputeCapability: "8.9"} }
cfg := &ModelConfig{}
cfg.SetDefaults()
Expect(cfg.Batch).To(Equal(0))
})
It("never overrides an explicit batch", func() {
localGPU = func() GPU { return GPU{ComputeCapability: "12.1"} }
cfg := &ModelConfig{}
cfg.Batch = 1024
cfg.SetDefaults()
Expect(cfg.Batch).To(Equal(1024))
})
})

View File

@@ -0,0 +1,97 @@
package config_test
import (
. "github.com/mudler/LocalAI/core/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Hardware-driven config defaults", func() {
DescribeTable("GPU.IsNVIDIABlackwell (sm_12x consumer family)",
func(cc string, want bool) {
Expect(GPU{ComputeCapability: cc}.IsNVIDIABlackwell()).To(Equal(want))
},
Entry("GB10 12.1", "12.1", true),
Entry("RTX 50 12.0", "12.0", true),
Entry("future 13.0", "13.0", true),
Entry("Hopper 9.0", "9.0", false),
Entry("Ada 8.9", "8.9", false),
Entry("datacenter Blackwell sm_100 10.0", "10.0", false),
Entry("unknown", "", false),
)
Describe("PhysicalBatch / IsManagedPhysicalBatch", func() {
It("returns the Blackwell batch on Blackwell", func() {
Expect(PhysicalBatch(GPU{ComputeCapability: "12.1"})).To(Equal(BlackwellPhysicalBatch))
})
It("returns the default batch otherwise", func() {
Expect(PhysicalBatch(GPU{ComputeCapability: "9.0"})).To(Equal(DefaultPhysicalBatch))
Expect(PhysicalBatch(GPU{})).To(Equal(DefaultPhysicalBatch))
})
It("recognizes managed defaults but not explicit values", func() {
Expect(IsManagedPhysicalBatch(DefaultPhysicalBatch)).To(BeTrue())
Expect(IsManagedPhysicalBatch(BlackwellPhysicalBatch)).To(BeTrue())
Expect(IsManagedPhysicalBatch(1024)).To(BeFalse())
})
})
Describe("ApplyHardwareDefaults", func() {
It("raises an unset batch to 2048 on Blackwell", func() {
cfg := &ModelConfig{}
ApplyHardwareDefaults(cfg, GPU{ComputeCapability: "12.1"})
Expect(cfg.Batch).To(Equal(BlackwellPhysicalBatch))
})
It("leaves batch unset on non-Blackwell", func() {
cfg := &ModelConfig{}
ApplyHardwareDefaults(cfg, GPU{ComputeCapability: "9.0"})
Expect(cfg.Batch).To(Equal(0))
})
It("never overrides an explicit batch", func() {
cfg := &ModelConfig{}
cfg.Batch = 1024
ApplyHardwareDefaults(cfg, GPU{ComputeCapability: "12.1"})
Expect(cfg.Batch).To(Equal(1024))
})
It("no-ops on nil", func() {
Expect(func() { ApplyHardwareDefaults(nil, GPU{ComputeCapability: "12.1"}) }).ToNot(Panic())
})
})
const gib = uint64(1) << 30
DescribeTable("DefaultParallelSlots (by VRAM)",
func(vramGiB uint64, want int) {
Expect(DefaultParallelSlots(GPU{VRAM: vramGiB * gib})).To(Equal(want))
},
Entry("GB10 119 GiB", uint64(119), 8),
Entry("48 GiB", uint64(48), 8),
Entry("24 GiB", uint64(24), 4),
Entry("8 GiB", uint64(8), 4),
Entry("6 GiB", uint64(6), 2),
Entry("2 GiB", uint64(2), 1),
Entry("unknown 0", uint64(0), 1),
)
Describe("ApplyHardwareDefaults parallel slots", func() {
It("adds a VRAM-scaled parallel option on a capable GPU", func() {
cfg := &ModelConfig{}
ApplyHardwareDefaults(cfg, GPU{ComputeCapability: "12.1", VRAM: 119 * gib})
Expect(cfg.Options).To(ContainElement("parallel:8"))
})
It("scales the slot count down with VRAM", func() {
cfg := &ModelConfig{}
ApplyHardwareDefaults(cfg, GPU{VRAM: 24 * gib})
Expect(cfg.Options).To(ContainElement("parallel:4"))
})
It("adds no parallel option on small/unknown VRAM", func() {
cfg := &ModelConfig{}
ApplyHardwareDefaults(cfg, GPU{VRAM: 2 * gib})
Expect(cfg.Options).ToNot(ContainElement(ContainSubstring("parallel")))
})
It("never overrides an explicit parallel option", func() {
cfg := &ModelConfig{Options: []string{"parallel:2"}}
ApplyHardwareDefaults(cfg, GPU{VRAM: 119 * gib})
Expect(cfg.Options).To(Equal([]string{"parallel:2"}))
})
})
})

View File

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

View File

@@ -64,6 +64,7 @@ var UsecaseOptions = []FieldOption{
{Value: "image", Label: "Image"},
{Value: "vision", Label: "Vision"},
{Value: "detection", Label: "Detection"},
{Value: "depth", Label: "Depth"},
{Value: "face_recognition", Label: "Face Recognition"},
{Value: "transcript", Label: "Transcript"},
{Value: "diarization", Label: "Diarization"},

View File

@@ -0,0 +1,41 @@
package meta_test
import (
"reflect"
"testing"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/config/meta"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMeta(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "config/meta suite")
}
var _ = Describe("pattern detector field metadata", func() {
byPath := func() map[string]meta.FieldMeta {
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
out := make(map[string]meta.FieldMeta, len(md.Fields))
for _, f := range md.Fields {
out[f.Path] = f
}
return out
}
It("renders builtins as a select with the catalogue as options", func() {
f, ok := byPath()["pii_detection.builtins"]
Expect(ok).To(BeTrue(), "pii_detection.builtins field should exist")
Expect(f.Component).To(Equal("pii-builtins-select"))
Expect(f.Options).NotTo(BeEmpty())
})
It("renders custom patterns with the pattern-list editor", func() {
f, ok := byPath()["pii_detection.patterns"]
Expect(ok).To(BeTrue(), "pii_detection.patterns field should exist")
Expect(f.Component).To(Equal("pii-pattern-list"))
})
})

View File

@@ -1,5 +1,19 @@
package meta
import "github.com/mudler/LocalAI/core/services/routing/piipattern"
// builtinPatternOptions turns the piipattern built-in catalogue into select
// options for the editor's built-in-patterns checklist, keeping the catalogue
// the single source of truth.
func builtinPatternOptions() []FieldOption {
cat := piipattern.BuiltinCatalogue()
out := make([]FieldOption, 0, len(cat))
for _, b := range cat {
out = append(out, FieldOption{Value: b.Name, Label: b.Name + " — " + b.Description})
}
return out
}
// DefaultRegistry returns enrichment overrides for the ~30 most commonly used
// config fields. Fields not listed here still appear with auto-generated
// labels and type-inferred components.
@@ -434,6 +448,13 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Component: "json-editor",
Order: 78,
},
"pipeline.max_history_items": {
Section: "pipeline",
Label: "Max History Items",
Description: "Cap how many trailing conversation items are fed to the LLM each realtime turn (0 = unlimited, rely on the LLM's context window). Set it on a composed pipeline (VAD+STT+LLM+TTS) so a long-running session doesn't grow until the context fills. Unset uses the per-model-type default.",
Component: "number",
Order: 79,
},
// --- Functions ---
"function.grammar.parallel_calls": {
@@ -497,12 +518,60 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Component: "toggle",
Order: 200,
},
"pii.patterns": {
"pii.detectors": {
Section: "pii",
Label: "PII Detector Models",
Description: "Token-classification (NER) models that scan this model's requests for PII. The detection policy (which entities, what action, min score) lives on each detector model's own PII Detection block. Multiple detectors union their hits.",
Component: "model-multi-select",
AutocompleteProvider: "models:token_classify",
Order: 201,
},
// --- PII detection policy (on a token_classify detector model) ---
"pii_detection.min_score": {
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).",
Label: "Detector Min Score",
Description: "When this model is used as a PII detector, drop detections scored below this confidence before they are acted on. 0 keeps every detection.",
Component: "slider",
Min: f64(0),
Max: f64(1),
Step: f64(0.01),
Order: 210,
},
"pii_detection.default_action": {
Section: "pii",
Label: "Detector Default Action",
Description: "Action applied to detected entity groups with no explicit per-entity override. Defaults to mask — the safe-by-default policy for a PII filter.",
Component: "select",
Options: []FieldOption{
{Value: "mask", Label: "mask (redact the span)"},
{Value: "block", Label: "block (reject the request)"},
{Value: "allow", Label: "allow (detect & log only)"},
},
Default: "mask",
Order: 211,
},
"pii_detection.entity_actions": {
Section: "pii",
Label: "Detector Entity Actions",
Description: "Per-entity-group action policy for this detector model (e.g. PASSWORD → block, EMAIL → mask). Groups without an entry use the default action.",
Component: "entity-action-list",
Order: 212,
},
"pii_detection.builtins": {
Section: "pii",
Label: "Built-in Secret Patterns",
Description: "Built-in regex patterns for common credentials (API keys, tokens, private keys). Turning any on makes this a pattern detector — it matches high-entropy secrets the NER tier can't, in-process with no model load.",
Component: "pii-builtins-select",
Options: builtinPatternOptions(),
Order: 213,
},
"pii_detection.patterns": {
Section: "pii",
Label: "Custom Secret Patterns",
Description: "Operator-defined patterns in a restricted regex subset (e.g. \"sk-prefix-\\w+\"). Each must contain a fixed literal anchor of ≥3 chars; open-ended shapes like emails are rejected (leave those to NER). Matches report under the pattern name as the entity group.",
Component: "pii-pattern-list",
Order: 201,
Order: 214,
},
// --- Cloud passthrough proxy ---

View File

@@ -112,6 +112,7 @@ var grandfatheredUnregistered = []string{
"agent.max_attempts",
"agent.max_iterations",
"cfg_scale",
"chat_template_kwargs",
"concurrency_groups",
"cutstrings",
"debug",

View File

@@ -10,6 +10,7 @@ import (
"text/template"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/piipattern"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/reasoning"
@@ -23,7 +24,6 @@ const (
// @Description TTS configuration
type TTSConfig struct {
// Voice wav path or id
Voice string `yaml:"voice,omitempty" json:"voice,omitempty"`
@@ -70,6 +70,19 @@ type ModelConfig struct {
// (Harmony) or LFM2.5 — honor it; "none" also toggles enable_thinking off.
ReasoningEffort string `yaml:"reasoning_effort,omitempty" json:"reasoning_effort,omitempty"`
// ChatTemplateKwargs are arbitrary key/values forwarded to the backend's jinja
// chat template via chat_template_kwargs (e.g. preserve_thinking: true). The
// server-derived reasoning levers (enable_thinking / reasoning_effort) and any
// per-request metadata overrides layer on top. See gRPCPredictOpts.
ChatTemplateKwargs map[string]any `yaml:"chat_template_kwargs,omitempty" json:"chat_template_kwargs,omitempty"`
// RequestMetadata holds the raw client request `metadata` map for the current
// request. The request middleware stamps it; gRPCPredictOpts merges it into the
// backend gRPC metadata (overriding the server-derived enable_thinking /
// reasoning_effort) and folds it, coerced, into the chat_template_kwargs blob.
// Never persisted to YAML.
RequestMetadata map[string]string `yaml:"-" json:"-"`
FeatureFlag FeatureFlag `yaml:"feature_flags,omitempty" json:"feature_flags,omitempty"` // Feature Flag registry. We move fast, and features may break on a per model/backend basis. Registry for (usually temporary) flags that indicate aborting something early.
// LLM configs (GPT4ALL, Llama.cpp, ...)
LLMConfig `yaml:",inline" json:",inline"`
@@ -103,13 +116,18 @@ type ModelConfig struct {
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
Overrides []string `yaml:"overrides,omitempty" json:"overrides,omitempty"`
MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp,omitempty"`
Agent AgentConfig `yaml:"agent,omitempty" json:"agent,omitempty"`
PII PIIConfig `yaml:"pii,omitempty" json:"pii,omitempty"`
Router RouterConfig `yaml:"router,omitempty" json:"router,omitempty"`
Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"`
MITM MITMModelConfig `yaml:"mitm,omitempty" json:"mitm,omitempty"`
Limits LimitsConfig `yaml:"limits,omitempty" json:"limits,omitempty"`
MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp,omitempty"`
Agent AgentConfig `yaml:"agent,omitempty" json:"agent,omitempty"`
PII PIIConfig `yaml:"pii,omitempty" json:"pii,omitempty"`
// PIIDetection is the detection policy when THIS model is used as a
// PII detector (a token_classify model named in another model's
// pii.detectors). Ignored on models that aren't referenced as
// detectors.
PIIDetection PIIDetectionConfig `yaml:"pii_detection,omitempty" json:"pii_detection,omitempty"`
Router RouterConfig `yaml:"router,omitempty" json:"router,omitempty"`
Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"`
MITM MITMModelConfig `yaml:"mitm,omitempty" json:"mitm,omitempty"`
Limits LimitsConfig `yaml:"limits,omitempty" json:"limits,omitempty"`
}
// @Description Admission-control limits applied per request. The
@@ -384,18 +402,54 @@ type PIIConfig struct {
// the YAML key is distinguishable from explicit false.
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// Patterns lets a model upgrade or downgrade individual pattern
// 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.
Patterns []PIIPatternOverride `yaml:"patterns,omitempty" json:"patterns,omitempty"`
// Detectors lists the token-classification (NER) models whose
// detections drive PII redaction for this model. The detection policy
// (min score, per-entity actions, default action) lives on each named
// detector model's own pii_detection block, not here — a consuming
// model just opts in by listing detectors. Multiple detectors union
// their hits; overlapping spans resolve to the strongest action.
Detectors []string `yaml:"detectors,omitempty" json:"detectors,omitempty"`
}
// @Description Per-model action override for a single PII pattern.
type PIIPatternOverride struct {
ID string `yaml:"id" json:"id"`
Action string `yaml:"action" json:"action"`
// @Description Detection policy for a token-classification (NER) model
// used as a PII detector. Lives on the detector model's own config so the
// model is a self-describing policy unit: consuming models reference it by
// name (via pii.detectors) and inherit this policy with no per-consumer
// overrides.
type PIIDetectionConfig struct {
// MinScore drops detections the model scores below this confidence
// before they are acted on. 0 keeps every detection.
MinScore float32 `yaml:"min_score,omitempty" json:"min_score,omitempty"`
// DefaultAction (mask | block | allow) applies to detected entity
// groups with no explicit EntityActions entry. Empty defaults to
// "mask" — the safe-by-default policy for a PII filter.
DefaultAction string `yaml:"default_action,omitempty" json:"default_action,omitempty"`
// EntityActions maps an entity group the model emits (e.g. "EMAIL",
// "PASSWORD") to an action, overriding DefaultAction for that group.
// This is where an operator says which PII to block vs mask vs
// allow-log.
EntityActions map[string]string `yaml:"entity_actions,omitempty" json:"entity_actions,omitempty"`
// Builtins names the built-in pattern groups this (pattern) detector
// enables, e.g. "anthropic_api_key", "github_token". Pattern detectors
// match high-entropy structured secrets the NER tier can't; see
// core/services/routing/piipattern.
Builtins []string `yaml:"builtins,omitempty" json:"builtins,omitempty"`
// Patterns lists operator-defined secret patterns in the restricted-regex
// subset (validated at load). Each match is reported under its Name as the
// entity group, so EntityActions/DefaultAction apply by Name.
Patterns []PIIPattern `yaml:"patterns,omitempty" json:"patterns,omitempty"`
}
// PIIPattern is one operator-defined pattern on a pattern detector model. Name
// is the entity group reported for matches (and the EntityActions key). Match
// is the restricted-regex source. Action optionally overrides DefaultAction for
// this pattern. MinLen drops matches shorter than N bytes (0 = no floor).
type PIIPattern struct {
Name string `yaml:"name" json:"name"`
Match string `yaml:"match" json:"match"`
Action string `yaml:"action,omitempty" json:"action,omitempty"`
MinLen int `yaml:"min_len,omitempty" json:"min_len,omitempty"`
}
// PIIIsEnabled returns the resolved PII state for this model. Single
@@ -408,27 +462,71 @@ func (c *ModelConfig) PIIIsEnabled() bool {
return c.Backend == "cloud-proxy"
}
// PIIPatternOverrides returns the per-pattern action overrides as a map
// keyed by pattern ID. The values are the raw action strings — the pii
// package validates and converts them.
//
// Returned via the documented modelPIIConfig interface in
// core/services/routing/pii/middleware.go without taking a config
// dependency on this package.
func (c *ModelConfig) PIIPatternOverrides() map[string]string {
if len(c.PII.Patterns) == 0 {
// PIIDetectors returns the names of the token-classification models that
// drive PII redaction for this (consuming) model. Read via the
// ModelPIIConfig interface in core/services/routing/pii/middleware.go.
func (c *ModelConfig) PIIDetectors() []string {
if len(c.PII.Detectors) == 0 {
return nil
}
out := make(map[string]string, len(c.PII.Patterns))
for _, p := range c.PII.Patterns {
if p.ID == "" {
continue
}
out[p.ID] = p.Action
out := make([]string, len(c.PII.Detectors))
copy(out, c.PII.Detectors)
return out
}
// piiCoverableUsecases lists the model usecases whose serving API has a
// request-side PII filter wired (a piiadapter + the pii middleware). It scopes
// the Middleware admin list (PIIFilterApplies). Grow it as adapters are added
// for new endpoints. cloud-proxy carries no usecase flag but is always covered
// (via the MITM / proxy chat path), so PIIFilterApplies handles it separately.
var piiCoverableUsecases = []ModelConfigUsecase{FLAG_CHAT, FLAG_COMPLETION, FLAG_EDIT, FLAG_EMBEDDINGS}
// PIIFilterApplies reports whether request-side PII filtering can apply to
// this model at all — i.e. it is reachable through a text-accepting endpoint
// that has a PII adapter wired. Used to scope the Middleware admin view so it
// lists only models PII could protect, not every config (VAD, STT,
// embedding-only, image, or the token_classify detector models themselves,
// which are the filters rather than consumers). Detector/score models return
// false naturally: HasUsecases short-circuits to false for any usecase a
// declared score/token_classify model did not itself declare.
func (c *ModelConfig) PIIFilterApplies() bool {
if c.Backend == "cloud-proxy" {
return true
}
return slices.ContainsFunc(piiCoverableUsecases, c.HasUsecases)
}
// PIIDetectionMinScore returns the confidence floor this model applies
// when used as a PII detector.
func (c *ModelConfig) PIIDetectionMinScore() float32 { return c.PIIDetection.MinScore }
// PIIDetectionDefaultAction returns the raw default-action string applied
// to detected entity groups without an explicit override. The pii package
// validates it and applies the "mask" fallback.
func (c *ModelConfig) PIIDetectionDefaultAction() string { return c.PIIDetection.DefaultAction }
// PIIDetectionEntityActions returns the per-entity-group action policy as
// a fresh map of raw action strings (validated by the pii package).
func (c *ModelConfig) PIIDetectionEntityActions() map[string]string {
if len(c.PIIDetection.EntityActions) == 0 {
return nil
}
out := make(map[string]string, len(c.PIIDetection.EntityActions))
for k, v := range c.PIIDetection.EntityActions {
out[k] = v
}
return out
}
// IsPatternDetector reports whether this detector model matches secrets with
// regex patterns (built-in and/or operator-defined) rather than a neural NER
// model. Such a model runs entirely in-process (no backend / GGUF / VRAM); the
// PII resolver builds an in-process pattern matcher for it instead of loading a
// gRPC token-classifier.
func (c *ModelConfig) IsPatternDetector() bool {
return len(c.PIIDetection.Builtins) > 0 || len(c.PIIDetection.Patterns) > 0
}
// @Description MCP configuration
type MCPConfig struct {
Servers string `yaml:"remote,omitempty" json:"remote,omitempty"`
@@ -472,8 +570,10 @@ func (c *MCPConfig) MCPConfigFromYAML() (MCPGenericConfig[MCPRemoteServers], MCP
type MCPGenericConfig[T any] struct {
Servers T `yaml:"mcpServers,omitempty" json:"mcpServers,omitempty"`
}
type MCPRemoteServers map[string]MCPRemoteServer
type MCPSTDIOServers map[string]MCPSTDIOServer
type (
MCPRemoteServers map[string]MCPRemoteServer
MCPSTDIOServers map[string]MCPSTDIOServer
)
// @Description MCP remote server configuration
type MCPRemoteServer struct {
@@ -510,6 +610,13 @@ type Pipeline struct {
// LLM model config. Unset leaves the LLM model config in charge.
DisableThinking *bool `yaml:"disable_thinking,omitempty" json:"disable_thinking,omitempty"`
// MaxHistoryItems caps how many trailing conversation items are fed to the
// LLM each realtime turn (0 = unlimited, rely on the LLM's context window).
// Unset (nil) uses the per-model-type default. Set it on a composed pipeline
// (VAD+STT+LLM+TTS) so a long-running session doesn't grow until the LLM's
// context fills.
MaxHistoryItems *int `yaml:"max_history_items,omitempty" json:"max_history_items,omitempty"`
// VoiceRecognition gates the pipeline behind speaker verification. Nil
// (block absent) means no gate, preserving existing behavior.
VoiceRecognition *PipelineVoiceRecognition `yaml:"voice_recognition,omitempty" json:"voice_recognition,omitempty"`
@@ -544,6 +651,44 @@ func (c *ModelConfig) ApplyReasoningEffort(requestEffort string) {
}
}
// coerceChatTemplateKwarg coerces a request-metadata string value for use as a
// jinja chat_template_kwarg. "true"/"false" become real booleans (so a jinja
// `{% if preserve_thinking %}` reads false correctly, since any non-empty string
// is truthy); everything else stays a string. Numeric/typed per-request values are
// out of scope - set those in the model YAML chat_template_kwargs (YAML keeps the type).
func coerceChatTemplateKwarg(v string) any {
switch v {
case "true":
return true
case "false":
return false
default:
return v
}
}
// ResolveChatTemplateKwargs builds the final chat_template_kwargs map forwarded to
// the backend, layered: the model config map (base) < the coerced backend metadata
// (server reasoning levers + client request overrides). `meta` is the already-merged
// backend metadata string map. The reserved "chat_template_kwargs" key is skipped so
// a client cannot smuggle a nested blob. Returns nil when there is nothing to forward.
func (c *ModelConfig) ResolveChatTemplateKwargs(meta map[string]string) map[string]any {
out := map[string]any{}
for k, v := range c.ChatTemplateKwargs {
out[k] = v
}
for k, v := range meta {
if k == "chat_template_kwargs" {
continue
}
out[k] = coerceChatTemplateKwarg(v)
}
if len(out) == 0 {
return nil
}
return out
}
// @Description PipelineStreaming toggles incremental delivery per realtime stage.
type PipelineStreaming struct {
LLM *bool `yaml:"llm,omitempty" json:"llm,omitempty"`
@@ -966,6 +1111,11 @@ func (cfg *ModelConfig) SetDefaults(opts ...ConfigLoaderOption) {
// This ensures gallery-installed and runtime-loaded models get optimal parameters.
ApplyInferenceDefaults(cfg, cfg.Name, cfg.Model)
// Apply hardware-driven defaults (e.g. a larger physical batch on Blackwell).
// Uses the local GPU here; in distributed mode the router re-applies the same
// heuristics for the selected node's GPU before loading. Explicit config wins.
ApplyHardwareDefaults(cfg, localGPU())
// https://github.com/ggerganov/llama.cpp/blob/75cd4c77292034ecec587ecb401366f57338f7c0/common/sampling.h#L22
defaultTopP := 0.95
defaultTopK := 40
@@ -1163,6 +1313,8 @@ func (c *ModelConfig) Validate() (bool, error) {
// llama_context against concurrent generation/embedding traffic
// (see backend/cpp/llama-cpp/grpc-server.cpp on Score). Reject the
// combination here so operators are forced to split the model.
// (token_classify is unaffected — it runs on the standalone
// privacy-filter backend, not llama-cpp.)
const scoreConflicts = FLAG_CHAT | FLAG_COMPLETION | FLAG_EMBEDDINGS
if (c.Backend == "llama-cpp" || c.Backend == "llama") &&
c.HasUsecases(FLAG_SCORE) && c.KnownUsecases != nil &&
@@ -1172,6 +1324,26 @@ func (c *ModelConfig) Validate() (bool, error) {
"with chat/completion/embeddings — split into separate model configs")
}
// Pattern detector: validate built-in names and that each operator-defined
// pattern is a well-formed, anchored, bounded restricted-regex. Reject at
// load so a bad pattern surfaces as a clear config error rather than a
// silent no-op (or a fail-closed block) at request time.
if c.IsPatternDetector() {
for _, name := range c.PIIDetection.Builtins {
if _, ok := piipattern.LookupBuiltin(name); !ok {
return false, fmt.Errorf("pii_detection: unknown built-in pattern %q", name)
}
}
for _, p := range c.PIIDetection.Patterns {
if p.Name == "" {
return false, fmt.Errorf("pii_detection: pattern is missing a name")
}
if err := piipattern.ValidatePattern(p.Match); err != nil {
return false, fmt.Errorf("pii_detection: pattern %q: %w", p.Name, err)
}
}
}
// router.score_normalization is consumed lazily by the score
// classifier at first-request time; without load-time validation
// a typo wouldn't surface until the first router request panicked
@@ -1278,12 +1450,24 @@ const (
// Marks a model as wired for the Score gRPC primitive (joint
// log-prob of candidate continuations under a shared prompt). Must
// be declared explicitly via `known_usecases: [score]` — there's
// no heuristic for it. On the llama-cpp backend, Score bypasses
// the slot loop and races the llama_context, so Validate() refuses
// to load a llama-cpp config that combines FLAG_SCORE with
// chat/completion/embeddings.
// no heuristic for it. On llama-cpp, Score bypasses the slot loop
// (direct llama_decode), so combining score with
// chat/completion/embeddings in one config is rejected at validation.
FLAG_SCORE ModelConfigUsecase = 0b10000000000000000000
// Marks a model as wired for the Depth gRPC primitive (per-pixel
// metric depth + camera pose + 3D point cloud via Depth Anything 3).
FLAG_DEPTH ModelConfigUsecase = 0b100000000000000000000
// Marks a model as wired for the TokenClassify gRPC primitive (the
// openai-privacy-filter PII NER tier — per-token BIOES classification).
// Like FLAG_SCORE it must be declared explicitly via
// `known_usecases: [token_classify]`; there's no heuristic. Requires
// TOKEN_CLS pooling, which is loaded via the embeddings flag. On
// llama-cpp the classification windows ride the embedding task queue,
// so it may combine freely with other usecases.
FLAG_TOKEN_CLASSIFY ModelConfigUsecase = 0b1000000000000000000000
// Common Subsets
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
)
@@ -1341,6 +1525,8 @@ func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
"FLAG_DIARIZATION": FLAG_DIARIZATION,
"FLAG_REALTIME_AUDIO": FLAG_REALTIME_AUDIO,
"FLAG_SCORE": FLAG_SCORE,
"FLAG_DEPTH": FLAG_DEPTH,
"FLAG_TOKEN_CLASSIFY": FLAG_TOKEN_CLASSIFY,
}
}
@@ -1368,19 +1554,20 @@ func GetUsecasesFromYAML(input []string) *ModelConfigUsecase {
// HasUsecases examines a ModelConfig and determines which endpoints have a chance of success.
//
// Declared known_usecases are normally additive — the guessing heuristic
// still adds whatever it can infer from backend/templates. The one
// exception is FLAG_SCORE: when the operator declared score, they
// reserved the model for the router classifier. Letting GuessUsecases
// paint chat/completion on top would surface it in chat pickers it was
// deliberately kept out of, and (on llama-cpp) reintroduce the slot
// contention the score/chat conflict check exists to prevent. So a
// declared score list is authoritative.
// still adds whatever it can infer from backend/templates. The exceptions
// are FLAG_SCORE and FLAG_TOKEN_CLASSIFY: when the operator declared
// either, they reserved the model for an internal direct-decode primitive
// (the router classifier, or the PII NER tier). Letting GuessUsecases
// paint chat/completion/embeddings on top would surface it in pickers it
// was deliberately kept out of, and (on llama-cpp) reintroduce the slot
// contention the conflict check exists to prevent. So a declared score or
// token_classify list is authoritative.
func (c *ModelConfig) HasUsecases(u ModelConfigUsecase) bool {
if c.KnownUsecases != nil {
if (u & *c.KnownUsecases) == u {
return true
}
if (*c.KnownUsecases & FLAG_SCORE) == FLAG_SCORE {
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY)) != 0 {
return false
}
}
@@ -1484,6 +1671,13 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
}
}
if (u & FLAG_DEPTH) == FLAG_DEPTH {
depthBackends := []string{"depth-anything"}
if !slices.Contains(depthBackends, c.Backend) {
return false
}
}
if (u & FLAG_FACE_RECOGNITION) == FLAG_FACE_RECOGNITION {
faceBackends := []string{"insightface"}
if !slices.Contains(faceBackends, c.Backend) {
@@ -1553,6 +1747,15 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
return false
}
if (u & FLAG_TOKEN_CLASSIFY) == FLAG_TOKEN_CLASSIFY {
// No heuristic: token-classification intent is a deliberate
// operator choice (it reserves the model from generation traffic
// on llama-cpp, and the model's TOKEN_CLS head isn't useful as
// general embeddings), so HasUsecases(FLAG_TOKEN_CLASSIFY) is true
// only when KnownUsecases declares it explicitly.
return false
}
return true
}

View File

@@ -7,6 +7,7 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
var _ = Describe("Test cases for config related functions", func() {
@@ -72,9 +73,10 @@ parameters:
Expect(valid).To(BeTrue())
// llama-cpp configs can't mix the score usecase with
// chat/completion/embeddings — Score bypasses the slot
// loop and would race the llama_context. The check fires
// at load and save time; here we exercise it directly.
// chat/completion/embeddings — Score bypasses the slot loop
// and would race the llama_context. (token_classify is exempt:
// it runs on the privacy-filter backend, not llama-cpp, so the
// token_classify combinations below stay valid.)
scoreFlag := FLAG_SCORE | FLAG_CHAT
conflicting := ModelConfig{
Name: "router-but-also-chat",
@@ -96,15 +98,23 @@ parameters:
Expect(valid).To(BeTrue())
Expect(err).NotTo(HaveOccurred())
// The constraint is llama-cpp-specific; other backends
// may safely combine.
scoreAndChat := FLAG_SCORE | FLAG_CHAT
otherBackend := ModelConfig{
Name: "vllm-router-and-chat",
Backend: "vllm",
KnownUsecases: &scoreAndChat,
tcAndChat := FLAG_TOKEN_CLASSIFY | FLAG_CHAT
tcCombined := ModelConfig{
Name: "ner-and-chat",
Backend: "llama-cpp",
KnownUsecases: &tcAndChat,
}
valid, err = otherBackend.Validate()
valid, err = tcCombined.Validate()
Expect(valid).To(BeTrue())
Expect(err).NotTo(HaveOccurred())
tcAndEmbeddings := FLAG_TOKEN_CLASSIFY | FLAG_EMBEDDINGS
tcWithEmbeddings := ModelConfig{
Name: "pii-ner",
Backend: "llama-cpp",
KnownUsecases: &tcAndEmbeddings,
}
valid, err = tcWithEmbeddings.Validate()
Expect(valid).To(BeTrue())
Expect(err).NotTo(HaveOccurred())
@@ -228,7 +238,6 @@ parameters:
})
})
It("Properly handles backend usecase matching", func() {
a := ModelConfig{
Name: "a",
}
@@ -336,17 +345,17 @@ parameters:
// Declared `known_usecases: [score]` is authoritative — the
// guessing heuristic must NOT add chat on top, even though the
// inherited chatml template would otherwise satisfy the chat
// heuristic. Score means "this model is reserved for the
// router classifier"; surfacing it as a chat model defeats the
// reservation and reintroduces the slot contention the load-time
// score/chat conflict check exists to prevent.
// heuristic. A score-only declaration means "this model is
// reserved for the router classifier"; surfacing it as a chat
// model defeats the reservation. (Operators who do want both
// may declare both — the combination is supported.)
scoreReserved := FLAG_SCORE
j := ModelConfig{
Name: "arch-router",
Backend: "llama-cpp",
KnownUsecases: &scoreReserved,
TemplateConfig: TemplateConfig{
Chat: "inherited from chatml",
Chat: "inherited from chatml",
ChatMessage: "inherited from chatml",
Completion: "inherited from chatml",
},
@@ -355,6 +364,27 @@ parameters:
Expect(j.HasUsecases(FLAG_CHAT)).To(BeFalse())
Expect(j.HasUsecases(FLAG_COMPLETION)).To(BeFalse())
Expect(j.HasUsecases(FLAG_EMBEDDINGS)).To(BeFalse())
// Declared `known_usecases: [token_classify]` is likewise
// authoritative — a PII NER model is reserved for the redactor's
// NER tier and must not surface as chat or as a general embeddings
// model, even though it loads with embeddings enabled (its
// TOKEN_CLS head produces BIOES logits, not reusable embeddings).
tcReserved := FLAG_TOKEN_CLASSIFY
embTrue := true
k := ModelConfig{
Name: "privacy-filter",
Backend: "llama-cpp",
KnownUsecases: &tcReserved,
Embeddings: &embTrue,
TemplateConfig: TemplateConfig{
Chat: "inherited from chatml",
ChatMessage: "inherited from chatml",
},
}
Expect(k.HasUsecases(FLAG_TOKEN_CLASSIFY)).To(BeTrue())
Expect(k.HasUsecases(FLAG_CHAT)).To(BeFalse())
Expect(k.HasUsecases(FLAG_EMBEDDINGS)).To(BeFalse())
})
It("Test Validate with invalid MCP config", func() {
tmp, err := os.CreateTemp("", "config.yaml")
@@ -598,3 +628,162 @@ concurrency_groups:
})
})
})
var _ = Describe("PII config accessors", func() {
It("PIIDetectors returns a fresh copy of the consumer's detector list", func() {
cfg := &ModelConfig{PII: PIIConfig{Detectors: []string{"a", "b"}}}
got := cfg.PIIDetectors()
Expect(got).To(Equal([]string{"a", "b"}))
got[0] = "mutated"
Expect(cfg.PII.Detectors[0]).To(Equal("a"), "accessor must not alias the underlying slice")
})
It("PIIDetectors is nil when none are configured", func() {
Expect((&ModelConfig{}).PIIDetectors()).To(BeNil())
})
It("exposes the detector model's pii_detection policy", func() {
cfg := &ModelConfig{PIIDetection: PIIDetectionConfig{
MinScore: 0.5,
DefaultAction: "mask",
EntityActions: map[string]string{"PASSWORD": "block", "EMAIL": "mask"},
}}
Expect(cfg.PIIDetectionMinScore()).To(BeNumerically("~", 0.5, 1e-6))
Expect(cfg.PIIDetectionDefaultAction()).To(Equal("mask"))
ea := cfg.PIIDetectionEntityActions()
Expect(ea).To(HaveKeyWithValue("PASSWORD", "block"))
ea["PASSWORD"] = "mutated"
Expect(cfg.PIIDetection.EntityActions["PASSWORD"]).To(Equal("block"), "accessor must return a fresh map")
})
It("unmarshals pii.detectors and pii_detection from YAML", func() {
var cfg ModelConfig
raw := []byte("name: consumer\npii:\n enabled: true\n detectors: [pf]\npii_detection:\n min_score: 0.4\n default_action: mask\n entity_actions:\n PASSWORD: block\n")
Expect(yaml.Unmarshal(raw, &cfg)).To(Succeed())
Expect(cfg.PIIDetectors()).To(Equal([]string{"pf"}))
Expect(cfg.PIIDetectionDefaultAction()).To(Equal("mask"))
Expect(cfg.PIIDetectionEntityActions()).To(HaveKeyWithValue("PASSWORD", "block"))
})
})
var _ = Describe("GGUF importer chat-default guard (reservedNonChatModel)", func() {
mk := func(flags ModelConfigUsecase) *ModelConfig {
return &ModelConfig{Backend: "llama-cpp", KnownUsecases: &flags}
}
It("treats declared score / token_classify models as reserved (no chat defaults)", func() {
Expect(reservedNonChatModel(mk(FLAG_SCORE))).To(BeTrue())
Expect(reservedNonChatModel(mk(FLAG_TOKEN_CLASSIFY))).To(BeTrue())
// embeddings declared alongside token_classify (the PII NER shape) is
// still reserved.
Expect(reservedNonChatModel(mk(FLAG_TOKEN_CLASSIFY | FLAG_EMBEDDINGS))).To(BeTrue())
})
It("does not reserve ordinary or undeclared models", func() {
Expect(reservedNonChatModel(mk(FLAG_CHAT))).To(BeFalse())
Expect(reservedNonChatModel(mk(FLAG_EMBEDDINGS))).To(BeFalse())
Expect(reservedNonChatModel(&ModelConfig{Backend: "llama-cpp"})).To(BeFalse())
})
It("keeps a token_classify GGUF config valid by withholding FLAG_CHAT", func() {
// The privacy-filter import shape: the GGUF importer appends FLAG_CHAT
// to a templateless model, which the next sync folds into
// KnownUsecases. token_classify+chat is a VALID combination
// (token_classify runs on the privacy-filter backend, not llama-cpp,
// so the score/chat conflict check does not apply to it), but the
// importer must still not paint a declared-reserved model as chat
// — that would surface it in every chat picker.
reserved := []string{"token_classify"}
withChat := append(append([]string{}, reserved...), "FLAG_CHAT")
// What the importer would produce WITHOUT the guard: valid (the
// score/chat conflict check is score-specific), just undesirable
// defaults.
combined := &ModelConfig{Backend: "llama-cpp", KnownUsecaseStrings: withChat}
combined.syncKnownUsecasesFromString()
valid, err := combined.Validate()
Expect(valid).To(BeTrue())
Expect(err).NotTo(HaveOccurred())
// With the guard (FLAG_CHAT withheld): the declaration survives and the
// config validates.
good := &ModelConfig{Backend: "llama-cpp", KnownUsecaseStrings: reserved}
good.syncKnownUsecasesFromString()
Expect(reservedNonChatModel(good)).To(BeTrue())
valid, err = good.Validate()
Expect(valid).To(BeTrue())
Expect(err).NotTo(HaveOccurred())
Expect(good.HasUsecases(FLAG_TOKEN_CLASSIFY)).To(BeTrue())
})
})
var _ = Describe("PIIFilterApplies (Middleware admin list scoping)", func() {
withUsecases := func(backend string, flags ModelConfigUsecase) *ModelConfig {
return &ModelConfig{Name: "m", Backend: backend, KnownUsecases: &flags}
}
It("includes chat-capable models and cloud-proxy models", func() {
Expect(withUsecases("llama-cpp", FLAG_CHAT).PIIFilterApplies()).To(BeTrue())
// cloud-proxy is always covered (MITM / proxy chat path), regardless
// of declared usecases.
Expect((&ModelConfig{Name: "claude", Backend: "cloud-proxy"}).PIIFilterApplies()).To(BeTrue())
})
It("excludes the detector and score models themselves", func() {
// token_classify detectors are the filters, not consumers; score
// classifiers are internal primitives. Both short-circuit
// HasUsecases(FLAG_CHAT) to false.
Expect(withUsecases("llama-cpp", FLAG_TOKEN_CLASSIFY).PIIFilterApplies()).To(BeFalse())
Expect(withUsecases("llama-cpp", FLAG_SCORE).PIIFilterApplies()).To(BeFalse())
})
It("includes embedding and completion models (their request text is filtered)", func() {
// Phase 4 wired PII onto /v1/embeddings, /v1/completions and /v1/edits,
// so those usecases are now coverable.
emb := withUsecases("llama-cpp", FLAG_EMBEDDINGS)
t := true
emb.Embeddings = &t
Expect(emb.PIIFilterApplies()).To(BeTrue())
Expect(withUsecases("llama-cpp", FLAG_COMPLETION).PIIFilterApplies()).To(BeTrue())
})
It("excludes models with no text-accepting, PII-covered endpoint", func() {
// VAD / audio-in models carry no coverable usecase.
Expect((&ModelConfig{Name: "vad", Backend: "silero-vad"}).PIIFilterApplies()).To(BeFalse())
Expect(withUsecases("whisper", FLAG_TRANSCRIPT).PIIFilterApplies()).To(BeFalse())
})
})
var _ = Describe("pattern detector config", func() {
patternCfg := func() *ModelConfig {
c := &ModelConfig{Name: "secret-filter", Backend: "pattern"}
c.PIIDetection.Builtins = []string{"anthropic_api_key"}
c.PIIDetection.Patterns = []PIIPattern{{Name: "INTERNAL", Match: `tok-[A-Za-z0-9]{20,}`}}
return c
}
It("IsPatternDetector keys off builtins/patterns", func() {
Expect(patternCfg().IsPatternDetector()).To(BeTrue())
Expect((&ModelConfig{Name: "ner", Backend: "llama-cpp"}).IsPatternDetector()).To(BeFalse())
})
It("Validate accepts a well-formed pattern detector (no model file needed)", func() {
ok, err := patternCfg().Validate()
Expect(err).NotTo(HaveOccurred())
Expect(ok).To(BeTrue())
})
It("Validate rejects an unknown built-in", func() {
c := &ModelConfig{Name: "x", Backend: "pattern"}
c.PIIDetection.Builtins = []string{"does_not_exist"}
_, err := c.Validate()
Expect(err).To(MatchError(ContainSubstring("unknown built-in")))
})
It("Validate rejects an unanchored custom pattern", func() {
c := &ModelConfig{Name: "x", Backend: "pattern"}
c.PIIDetection.Patterns = []PIIPattern{{Name: "EMAILish", Match: `[\w.]+@[\w.]+\.\w+`}}
_, err := c.Validate()
Expect(err).To(MatchError(ContainSubstring("pattern \"EMAILish\"")))
})
})

View File

@@ -18,8 +18,8 @@ type RuntimeSettings struct {
WatchdogInterval *string `json:"watchdog_interval,omitempty"` // Interval between watchdog checks (e.g., 2s, 30s)
// Backend management
SingleBackend *bool `json:"single_backend,omitempty"` // Deprecated: use MaxActiveBackends = 1 instead
MaxActiveBackends *int `json:"max_active_backends,omitempty"` // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
SingleBackend *bool `json:"single_backend,omitempty"` // Deprecated: use MaxActiveBackends = 1 instead
MaxActiveBackends *int `json:"max_active_backends,omitempty"` // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
AutoUpgradeBackends *bool `json:"auto_upgrade_backends,omitempty"` // Automatically upgrade backends when new versions are detected
PreferDevelopmentBackends *bool `json:"prefer_development_backends,omitempty"` // Prefer development backend versions by default in UI
// Memory Reclaimer settings (works with GPU if available, otherwise RAM)
@@ -97,19 +97,9 @@ type RuntimeSettings struct {
// trusted clients.
MITMListen *string `json:"mitm_listen,omitempty"`
// PII pattern overrides — keyed by pattern id, applied to the live
// redactor at startup and persisted by POST /api/pii/patterns/persist.
// Distinguishes from --pii-config (which replaces the entire
// pattern set) by only carrying the per-id action/enabled deltas
// against the global default catalog.
PIIPatternOverrides *map[string]PIIPatternRuntimeOverride `json:"pii_pattern_overrides,omitempty"`
}
// PIIPatternRuntimeOverride captures the persistable deltas an admin
// has applied to a single global PII pattern. Both fields are pointers
// so an override that only flips Disabled doesn't have to also restate
// Action (and vice versa).
type PIIPatternRuntimeOverride struct {
Action *string `json:"action,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
// PIIDefaultDetectors are the token-classification detector models applied
// to any PII-enabled model that names no detectors of its own (so
// cloud-proxy/MITM redaction works without per-model config). No omitempty:
// an empty array must round-trip so the operator can clear it from the UI.
PIIDefaultDetectors *[]string `json:"pii_default_detectors"`
}

View File

@@ -0,0 +1,181 @@
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"
"github.com/mudler/LocalAI/pkg/downloader"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
"go.yaml.in/yaml/v2"
)
var _ Importer = &DepthAnythingImporter{}
// DepthAnythingImporter recognises depth-anything.cpp GGUF weights, the
// C++/ggml port of ByteDance Depth Anything 3. The signal is narrow on
// purpose: depth-anything.cpp names its weights
// "depth-anything-<size>-<quant>.gguf" (e.g. depth-anything-small-f32.gguf,
// depth-anything-large-q4_k.gguf), so we only match a .gguf whose name carries
// a depth-anything token. That keeps us from claiming arbitrary llama-style
// GGUFs (the importer is registered before llama-cpp), and it deliberately
// does NOT match the upstream depth-anything/* PyTorch repos (which ship
// safetensors checkpoints, not runnable GGUFs).
// preferences.backend="depth-anything" forces the importer regardless.
type DepthAnythingImporter struct{}
func (i *DepthAnythingImporter) Name() string { return "depth-anything" }
func (i *DepthAnythingImporter) Modality() string { return "image" }
func (i *DepthAnythingImporter) AutoDetects() bool { return true }
func (i *DepthAnythingImporter) 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 == "depth-anything" {
return true
}
// Direct URL or path to a depth-anything GGUF.
if isDepthAnythingGGUF(filepath.Base(details.URI)) {
return true
}
// HF repo shipping at least one depth-anything GGUF.
if details.HuggingFace != nil {
for _, f := range details.HuggingFace.Files {
if isDepthAnythingGGUF(filepath.Base(f.Path)) {
return true
}
}
}
return false
}
func (i *DepthAnythingImporter) 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
}
// depth-anything quants stay above 0.998 correlation even at q4_k, so
// default to the smallest, then fall back up the size ladder; the last
// file wins if none match (mirrors whisper / llama-cpp). The ladder lists
// both f16 and f32 since the published GGUFs ship f32 rather than f16.
preferredQuants, _ := preferencesMap["quantizations"].(string)
quants := []string{"q4_k", "q5_k", "q6_k", "q8_0", "f16", "f32"}
if preferredQuants != "" {
quants = strings.Split(preferredQuants, ",")
}
cfg := gallery.ModelConfig{
Name: name,
Description: description,
}
modelConfig := config.ModelConfig{
Name: name,
Description: description,
Backend: "depth-anything",
}
uri := downloader.URI(details.URI)
directGGUF := isDepthAnythingGGUF(filepath.Base(details.URI))
switch {
case uri.LooksLikeURL() && directGGUF:
// Direct file URL (e.g. .../resolve/main/depth-anything-small-f32.gguf).
// The exact file is known, no quant pick.
fileName, err := uri.FilenameFromUrl()
if err != nil {
return gallery.ModelConfig{}, err
}
target := filepath.Join("depth-anything", "models", name, fileName)
cfg.Files = append(cfg.Files, gallery.File{
URI: details.URI,
Filename: target,
})
modelConfig.PredictionOptions = schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: target},
}
case details.HuggingFace != nil:
// HF repo: collect every depth-anything GGUF, pick the preferred quant,
// and nest under depth-anything/models/<name>/ so a multi-quant repo
// doesn't collide on disk.
var ggufFiles []hfapi.ModelFile
for _, f := range details.HuggingFace.Files {
if isDepthAnythingGGUF(filepath.Base(f.Path)) {
ggufFiles = append(ggufFiles, f)
}
}
if chosen, ok := pickPreferredGGMLFile(ggufFiles, quants); ok {
target := filepath.Join("depth-anything", "models", name, filepath.Base(chosen.Path))
cfg.Files = append(cfg.Files, gallery.File{
URI: chosen.URL,
Filename: target,
SHA256: chosen.SHA256,
})
modelConfig.PredictionOptions = schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: target},
}
}
default:
// Bare URI with no HF metadata (pref-only path): point at the basename
// so users can tweak the YAML after import.
modelConfig.PredictionOptions = schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: filepath.Base(details.URI)},
}
}
data, err := yaml.Marshal(modelConfig)
if err != nil {
return gallery.ModelConfig{}, err
}
cfg.ConfigFile = string(data)
return cfg, nil
}
// isDepthAnythingGGUF reports whether name is a depth-anything.cpp GGUF: a
// .gguf file whose name carries a depth-anything token. The .gguf check is
// case-insensitive; the tokens cover the published naming
// (depth-anything-<size>-<quant>.gguf) and its hyphen/underscore variants.
func isDepthAnythingGGUF(name string) bool {
lower := strings.ToLower(name)
if !strings.HasSuffix(lower, ".gguf") {
return false
}
for _, tok := range []string{"depth-anything", "depth_anything", "depthanything"} {
if strings.Contains(lower, tok) {
return true
}
}
return false
}

View File

@@ -0,0 +1,112 @@
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"
)
// depthAnythingDetails builds Details carrying a synthetic HF file list so
// detection can be exercised without hitting the network.
func depthAnythingDetails(uri string, prefs string, files ...hfapi.ModelFile) importers.Details {
return importers.Details{
URI: uri,
Preferences: json.RawMessage(prefs),
HuggingFace: &hfapi.ModelDetails{Files: files},
}
}
var _ = Describe("DepthAnythingImporter", func() {
imp := &importers.DepthAnythingImporter{}
Context("Importer interface metadata", func() {
It("exposes name/modality/autodetect", func() {
Expect(imp.Name()).To(Equal("depth-anything"))
Expect(imp.Modality()).To(Equal("image"))
Expect(imp.AutoDetects()).To(BeTrue())
})
})
Context("detection (Match)", func() {
It("matches an HF repo shipping a depth-anything GGUF", func() {
d := depthAnythingDetails("huggingface://mudler/depth-anything.cpp-gguf", `{}`,
hfapi.ModelFile{Path: "depth-anything-small-f32.gguf"},
hfapi.ModelFile{Path: "README.md"},
)
Expect(imp.Match(d)).To(BeTrue())
})
It("matches a direct URL to a depth-anything GGUF", func() {
d := depthAnythingDetails("https://huggingface.co/mudler/depth-anything.cpp-gguf/resolve/main/depth-anything-large-q4_k.gguf", `{}`)
Expect(imp.Match(d)).To(BeTrue())
})
It("honours preferences.backend=depth-anything for arbitrary URIs", func() {
d := depthAnythingDetails("https://example.com/whatever", `{"backend": "depth-anything"}`)
Expect(imp.Match(d)).To(BeTrue())
})
It("does NOT claim a generic llama-style GGUF", func() {
d := depthAnythingDetails("huggingface://someorg/some-llm-gguf", `{}`,
hfapi.ModelFile{Path: "llama-3-8b-instruct-q4_k_m.gguf"},
)
Expect(imp.Match(d)).To(BeFalse())
})
It("does NOT claim the upstream PyTorch repo (safetensors, no GGUF)", func() {
d := depthAnythingDetails("huggingface://depth-anything/Depth-Anything-V3", `{}`,
hfapi.ModelFile{Path: "model.safetensors"},
)
Expect(imp.Match(d)).To(BeFalse())
})
})
Context("import (Import)", func() {
It("picks the default quant (q4_k) from a multi-quant HF repo", func() {
d := depthAnythingDetails("huggingface://mudler/depth-anything.cpp-gguf", `{"name":"depth-anything-small"}`,
hfapi.ModelFile{Path: "depth-anything-small-f32.gguf", URL: "https://hf/f32", SHA256: "aaa"},
hfapi.ModelFile{Path: "depth-anything-small-q4_k.gguf", URL: "https://hf/q4k", SHA256: "bbb"},
hfapi.ModelFile{Path: "depth-anything-small-q8_0.gguf", URL: "https://hf/q8", SHA256: "ccc"},
)
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.ConfigFile).To(ContainSubstring("backend: depth-anything"), fmt.Sprintf("%+v", cfg))
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://hf/q4k"), "default quant should be q4_k")
Expect(cfg.Files[0].Filename).To(ContainSubstring("depth-anything/models/depth-anything-small/depth-anything-small-q4_k.gguf"))
})
It("honours a preferred quantization override", func() {
d := depthAnythingDetails("huggingface://mudler/depth-anything.cpp-gguf", `{"name":"d","quantizations":"q8_0"}`,
hfapi.ModelFile{Path: "depth-anything-small-f32.gguf", URL: "https://hf/f32"},
hfapi.ModelFile{Path: "depth-anything-small-q8_0.gguf", URL: "https://hf/q8"},
)
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://hf/q8"))
})
It("falls back to f32 when no quantized file is present", func() {
d := depthAnythingDetails("huggingface://mudler/depth-anything.cpp-gguf", `{"name":"d"}`,
hfapi.ModelFile{Path: "depth-anything-base-f32.gguf", URL: "https://hf/f32"},
)
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://hf/f32"))
})
It("uses the exact file for a direct GGUF URL", func() {
d := depthAnythingDetails("https://huggingface.co/mudler/depth-anything.cpp-gguf/resolve/main/depth-anything-base-q5_k.gguf", `{"name":"da"}`)
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].Filename).To(ContainSubstring("depth-anything/models/da/depth-anything-base-q5_k.gguf"))
})
})
})

View File

@@ -163,12 +163,23 @@ var defaultImporters = []Importer{
// bundles aren't claimed by the generic .gguf importer; kept next to
// RFDetrImporter as both are detection models.
&LocateAnythingImporter{},
// DepthAnythingImporter (ByteDance Depth Anything 3 metric depth + camera
// pose, native C++/ggml port) must run before LlamaCPPImporter so its GGUF
// bundles aren't claimed by the generic .gguf importer; matches only the
// depth-anything-<size>-<quant>.gguf naming, so it cannot claim arbitrary
// GGUFs.
&DepthAnythingImporter{},
// Existing
// DS4Importer must precede LlamaCPPImporter - ds4 weights are GGUFs and
// would otherwise be claimed by the generic .gguf-handling llama-cpp
// importer. Matches only the antirez/deepseek-v4-gguf repo + filename
// pattern, so false-positives against arbitrary GGUFs are impossible.
&DS4Importer{},
// PrivacyFilterImporter must precede LlamaCPPImporter too — the OpenMed
// privacy-filter GGUFs would otherwise be claimed by the generic .gguf
// importer. Matches only .gguf names carrying the "privacy-filter" token,
// so arbitrary GGUFs are never claimed.
&PrivacyFilterImporter{},
&LlamaCPPImporter{},
&MLXImporter{},
&VLLMImporter{},

View File

@@ -0,0 +1,202 @@
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"
"github.com/mudler/LocalAI/pkg/downloader"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
"go.yaml.in/yaml/v2"
)
var _ Importer = &PrivacyFilterImporter{}
// PrivacyFilterImporter recognises the OpenMed privacy-filter PII/NER model
// family, served by the standalone privacy-filter.cpp ggml engine (the
// openai-privacy-filter architecture). Detection is deliberately narrow: the
// engine can only run a privacy-filter GGUF, so we match a .gguf whose name
// carries the "privacy-filter" token (e.g. privacy-filter-multilingual-f16.gguf)
// or an HF repo that ships one. That keeps us from claiming arbitrary
// llama-style GGUFs (the importer is registered before llama-cpp) and from
// claiming the upstream OpenMed/privacy-filter-* safetensors repos, which carry
// no runnable GGUF. preferences.backend="privacy-filter" forces it regardless.
type PrivacyFilterImporter struct{}
func (i *PrivacyFilterImporter) Name() string { return "privacy-filter" }
// Modality is "text": the filter operates in the text domain and there is no
// dedicated token-classification chip in the import UI, so it groups with the
// other text-domain backends (matching how ds4 — another single-family text
// GGUF — is classified).
func (i *PrivacyFilterImporter) Modality() string { return "text" }
func (i *PrivacyFilterImporter) AutoDetects() bool { return true }
func (i *PrivacyFilterImporter) 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 == "privacy-filter" {
return true
}
// Direct URL or path to a privacy-filter GGUF.
if isPrivacyFilterGGUF(filepath.Base(details.URI)) {
return true
}
// HF repo shipping at least one privacy-filter GGUF.
if details.HuggingFace != nil {
for _, f := range details.HuggingFace.Files {
if isPrivacyFilterGGUF(filepath.Base(f.Path)) {
return true
}
}
}
// Fallback: hfapi recursion bug may leave HuggingFace nil — match a repo
// that names itself as the privacy-filter GGUF distribution (both tokens
// present), e.g. LocalAI-io/privacy-filter-multilingual-GGUF. Requiring
// "gguf" keeps the safetensors-only source repo out.
if _, repo, ok := HFOwnerRepoFromURI(details.URI); ok {
lower := strings.ToLower(repo)
if privacyFilterName(lower) && strings.Contains(lower, "gguf") {
return true
}
}
return false
}
func (i *PrivacyFilterImporter) 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
}
// The token classifier's accuracy is parity-sensitive, so prefer the
// highest-precision weights first (f16 is what the gallery ships today),
// then fall back down the quant ladder; the last file wins if none match.
preferredQuants, _ := preferencesMap["quantizations"].(string)
quants := []string{"f16", "q8_0", "q6_k", "q5_k", "q4_k"}
if preferredQuants != "" {
quants = strings.Split(preferredQuants, ",")
}
cfg := gallery.ModelConfig{
Name: name,
Description: description,
}
trueV := true
modelConfig := config.ModelConfig{
Name: name,
Description: description,
Backend: "privacy-filter",
// embeddings:true mirrors the gallery entry — the privacy-filter
// backend loads in embedding mode to expose per-token logits.
Embeddings: &trueV,
// token_classify reserves the model for the PII NER tier; another
// model opts into redaction by listing this one under pii.detectors.
KnownUsecaseStrings: []string{"token_classify"},
}
uri := downloader.URI(details.URI)
directGGUF := isPrivacyFilterGGUF(filepath.Base(details.URI))
switch {
case uri.LooksLikeURL() && directGGUF:
// Direct file URL (e.g. .../resolve/main/privacy-filter-multilingual-f16.gguf).
// The exact file is known, no quant pick.
fileName, err := uri.FilenameFromUrl()
if err != nil {
return gallery.ModelConfig{}, err
}
target := filepath.Join("privacy-filter", "models", name, fileName)
cfg.Files = append(cfg.Files, gallery.File{
URI: details.URI,
Filename: target,
})
modelConfig.PredictionOptions = schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: target},
}
case details.HuggingFace != nil:
// HF repo: collect every privacy-filter GGUF, pick the preferred quant,
// and nest under privacy-filter/models/<name>/ so a multi-quant repo
// doesn't collide on disk.
var ggufFiles []hfapi.ModelFile
for _, f := range details.HuggingFace.Files {
if isPrivacyFilterGGUF(filepath.Base(f.Path)) {
ggufFiles = append(ggufFiles, f)
}
}
if chosen, ok := pickPreferredGGMLFile(ggufFiles, quants); ok {
target := filepath.Join("privacy-filter", "models", name, filepath.Base(chosen.Path))
cfg.Files = append(cfg.Files, gallery.File{
URI: chosen.URL,
Filename: target,
SHA256: chosen.SHA256,
})
modelConfig.PredictionOptions = schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: target},
}
}
default:
// Bare URI with no HF metadata (pref-only path): point at the basename
// so users can tweak the YAML after import.
modelConfig.PredictionOptions = schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: filepath.Base(details.URI)},
}
}
data, err := yaml.Marshal(modelConfig)
if err != nil {
return gallery.ModelConfig{}, err
}
cfg.ConfigFile = string(data)
return cfg, nil
}
// privacyFilterName reports whether a lower-cased string carries the
// privacy-filter token in either separator form.
func privacyFilterName(lower string) bool {
return strings.Contains(lower, "privacy-filter") || strings.Contains(lower, "privacy_filter")
}
// isPrivacyFilterGGUF reports whether name is a privacy-filter GGUF: a .gguf
// file whose name carries the privacy-filter token. The .gguf check is
// case-insensitive.
func isPrivacyFilterGGUF(name string) bool {
lower := strings.ToLower(name)
if !strings.HasSuffix(lower, ".gguf") {
return false
}
return privacyFilterName(lower)
}

View File

@@ -0,0 +1,104 @@
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"
)
// privacyFilterDetails builds Details carrying a synthetic HF file list so
// detection can be exercised without hitting the network.
func privacyFilterDetails(uri string, prefs string, files ...hfapi.ModelFile) importers.Details {
return importers.Details{
URI: uri,
Preferences: json.RawMessage(prefs),
HuggingFace: &hfapi.ModelDetails{Files: files},
}
}
var _ = Describe("PrivacyFilterImporter", func() {
imp := &importers.PrivacyFilterImporter{}
Context("Importer interface metadata", func() {
It("exposes name/modality/autodetect", func() {
Expect(imp.Name()).To(Equal("privacy-filter"))
Expect(imp.Modality()).To(Equal("text"))
Expect(imp.AutoDetects()).To(BeTrue())
})
})
Context("detection (Match)", func() {
It("matches an HF repo shipping a privacy-filter GGUF", func() {
d := privacyFilterDetails("huggingface://LocalAI-io/privacy-filter-multilingual-GGUF", "",
hfapi.ModelFile{Path: "privacy-filter-multilingual-f16.gguf", URL: "https://hf/f16"})
Expect(imp.Match(d)).To(BeTrue())
})
It("matches a direct URL to a privacy-filter GGUF", func() {
d := privacyFilterDetails("https://hf/resolve/main/privacy-filter-multilingual-f16.gguf", "")
Expect(imp.Match(d)).To(BeTrue())
})
It("matches the GGUF distribution repo by name when HF metadata is absent", func() {
d := importers.Details{URI: "huggingface://LocalAI-io/privacy-filter-multilingual-GGUF", Preferences: json.RawMessage("")}
Expect(imp.Match(d)).To(BeTrue())
})
It("honours preferences.backend=privacy-filter for arbitrary URIs", func() {
d := privacyFilterDetails("huggingface://some/unrelated-repo", `{"backend":"privacy-filter"}`)
Expect(imp.Match(d)).To(BeTrue())
})
It("does NOT claim a generic llama-style GGUF", func() {
d := privacyFilterDetails("huggingface://TheBloke/Llama-2-7B-GGUF", "",
hfapi.ModelFile{Path: "llama-2-7b.Q4_K_M.gguf", URL: "https://hf/llama"})
Expect(imp.Match(d)).To(BeFalse())
})
It("does NOT claim the upstream safetensors source repo (no GGUF)", func() {
d := privacyFilterDetails("huggingface://OpenMed/privacy-filter-multilingual", "",
hfapi.ModelFile{Path: "model.safetensors", URL: "https://hf/st"},
hfapi.ModelFile{Path: "config.json", URL: "https://hf/cfg"})
Expect(imp.Match(d)).To(BeFalse())
})
})
Context("import (Import)", func() {
It("emits a privacy-filter token_classify config from an HF GGUF repo", func() {
d := privacyFilterDetails("huggingface://LocalAI-io/privacy-filter-multilingual-GGUF", `{"name":"pii"}`,
hfapi.ModelFile{Path: "privacy-filter-multilingual-f16.gguf", URL: "https://hf/f16", SHA256: "abc"})
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.ConfigFile).To(ContainSubstring("backend: privacy-filter"), fmt.Sprintf("%+v", cfg))
Expect(cfg.ConfigFile).To(ContainSubstring("token_classify"))
Expect(cfg.ConfigFile).To(ContainSubstring("embeddings: true"))
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://hf/f16"))
Expect(cfg.Files[0].SHA256).To(Equal("abc"))
Expect(cfg.Files[0].Filename).To(ContainSubstring("privacy-filter/models/pii/privacy-filter-multilingual-f16.gguf"))
})
It("prefers the highest-precision quant (f16) from a multi-quant repo", func() {
d := privacyFilterDetails("huggingface://LocalAI-io/privacy-filter-multilingual-GGUF", "",
hfapi.ModelFile{Path: "privacy-filter-multilingual-q4_k.gguf", URL: "https://hf/q4k"},
hfapi.ModelFile{Path: "privacy-filter-multilingual-f16.gguf", URL: "https://hf/f16"})
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://hf/f16"), "f16 should win over q4_k")
})
It("uses the exact file for a direct GGUF URL", func() {
d := privacyFilterDetails("https://hf/resolve/main/privacy-filter-multilingual-f16.gguf", "")
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].Filename).To(ContainSubstring("privacy-filter/models/"))
Expect(cfg.Files[0].Filename).To(ContainSubstring("privacy-filter-multilingual-f16.gguf"))
})
})
})

View File

@@ -735,6 +735,18 @@ parameters:
`
Expect(os.WriteFile(filepath.Join(modelDir, "mock-model.yaml"), []byte(mockModelYAML), 0644)).To(Succeed())
// A second model carrying chat_template_kwargs so the REST->gRPC
// metadata-forwarding spec below can assert the model-YAML kwarg is
// merged with the per-request override.
mockCTKModelYAML := `name: mock-ctk-model
backend: mock-backend
parameters:
model: mock-model.bin
chat_template_kwargs:
preserve_thinking: true
`
Expect(os.WriteFile(filepath.Join(modelDir, "mock-ctk-model.yaml"), []byte(mockCTKModelYAML), 0644)).To(Succeed())
systemState, err := system.GetSystemState(
system.WithBackendPath(backendDir),
system.WithModelPath(modelDir),
@@ -809,6 +821,59 @@ parameters:
Expect(string(dat)).To(ContainSubstring("mock-backend"))
})
It("forwards chat_template_kwargs and reasoning levers to gRPC PredictOptions.Metadata", func() {
// True HTTP->gRPC contract guard: drive a real /v1/chat/completions
// request and assert the exact metadata the REST layer forwarded to
// the backend. The mock-backend echoes PredictOptions.Metadata as JSON
// when it sees the ECHO_PREDICT_METADATA marker in the prompt, so this
// pins the request->gRPC mapping (model-YAML chat_template_kwargs +
// per-request metadata override + type coercion + standalone keys)
// without adding a new RPC. The marker rides in the user content and
// must survive into the backend prompt; if a future default chat
// template drops raw user content, move the marker to /v1/completions.
reqBody := map[string]any{
"model": "mock-ctk-model",
"messages": []map[string]any{
{"role": "user", "content": "ECHO_PREDICT_METADATA"},
},
// per-request override: overrides the standalone enable_thinking key
// and exercises coercion ("false" -> bool, "low" -> string) in the blob
"metadata": map[string]string{
"enable_thinking": "false",
"reasoning_effort": "low",
},
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
err := postRequestResponseJSON("http://127.0.0.1:9090/v1/chat/completions", &reqBody, &chatResp)
Expect(err).ToNot(HaveOccurred())
Expect(chatResp.Choices).ToNot(BeEmpty())
// The assistant content is the JSON snapshot of PredictOptions.Metadata.
var meta map[string]string
Expect(json.Unmarshal([]byte(chatResp.Choices[0].Message.Content), &meta)).To(Succeed(), "echoed metadata: %s", chatResp.Choices[0].Message.Content)
// Standalone keys reflect the per-request override (consumed by Python
// backends; consistent across backends).
Expect(meta).To(HaveKeyWithValue("enable_thinking", "false"))
Expect(meta).To(HaveKeyWithValue("reasoning_effort", "low"))
// The chat_template_kwargs blob (consumed by llama.cpp) merges the
// model-YAML kwarg with the coerced request metadata override.
Expect(meta).To(HaveKey("chat_template_kwargs"))
var ctk map[string]any
Expect(json.Unmarshal([]byte(meta["chat_template_kwargs"]), &ctk)).To(Succeed(), "chat_template_kwargs blob: %s", meta["chat_template_kwargs"])
Expect(ctk).To(HaveKeyWithValue("preserve_thinking", true)) // bool from model YAML
Expect(ctk).To(HaveKeyWithValue("enable_thinking", false)) // coerced "false" -> bool
Expect(ctk).To(HaveKeyWithValue("reasoning_effort", "low")) // non-bool stays string
})
// Agent Jobs: HTTP API for task/job scheduling. The underlying AgentPool
// service is exercised in core/services/agentpool/agent_jobs_test.go;
// these specs cover the /api/agent/* HTTP plumbing on top.

View File

@@ -123,6 +123,10 @@ var RouteFeatureRegistry = []RouteFeature{
{"GET", "/api/fine-tuning/jobs/:id/download", FeatureFineTuning},
{"POST", "/api/fine-tuning/datasets", FeatureFineTuning},
// PII analyze/redact service (the events log stays admin-gated in-handler)
{"POST", "/api/pii/analyze", FeaturePIIFilter},
{"POST", "/api/pii/redact", FeaturePIIFilter},
// Quantization
{"POST", "/api/quantization/jobs", FeatureQuantization},
{"GET", "/api/quantization/jobs", FeatureQuantization},
@@ -181,5 +185,6 @@ func APIFeatureMetas() []FeatureMeta {
{FeatureFaceRecognition, "Face Recognition", true},
{FeatureVoiceRecognition, "Voice Recognition", true},
{FeatureAudioTransform, "Audio Transform", true},
{FeaturePIIFilter, "PII Analyze / Redact", true},
}
}

View File

@@ -56,6 +56,10 @@ const (
FeatureFaceRecognition = "face_recognition"
FeatureVoiceRecognition = "voice_recognition"
FeatureAudioTransform = "audio_transform"
// FeaturePIIFilter gates the synchronous PII analyze/redact service
// (POST /api/pii/{analyze,redact}). Default ON like the other API
// features; the admin-only events log is gated separately in-handler.
FeaturePIIFilter = "pii_filter"
)
// AgentFeatures lists agent-related features (default OFF).
@@ -71,6 +75,7 @@ var APIFeatures = []string{
FeatureVAD, FeatureDetection, FeatureVideo, FeatureEmbeddings, FeatureSound,
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
FeaturePIIFilter,
}
// AllFeatures lists all known features (used by UI and validation).

View File

@@ -10,13 +10,11 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
openaiEndpoint "github.com/mudler/LocalAI/core/http/endpoints/openai"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/cloudproxy"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
@@ -30,7 +28,7 @@ import (
// @Param request body schema.AnthropicRequest true "query params"
// @Success 200 {object} schema.AnthropicResponse "Response"
// @Router /v1/messages [post]
func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient, piiRedactor *pii.Redactor, piiEvents pii.EventStore) echo.HandlerFunc {
func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient) echo.HandlerFunc {
return func(c echo.Context) error {
id := uuid.New().String()
@@ -53,7 +51,7 @@ func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evalu
// Cloud-proxy bail. Same shape as the OpenAI chat endpoint —
// forwards via the cloud-proxy gRPC backend.
if cfg.IsCloudProxyBackendPassthrough() {
return forwardCloudProxyAnthropicViaBackend(c, cfg, input, piiRedactor, piiEvents, ml, appConfig)
return forwardCloudProxyAnthropicViaBackend(c, cfg, input, ml, appConfig)
}
// Convert Anthropic messages to OpenAI format for internal processing
@@ -141,7 +139,7 @@ func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evalu
xlog.Debug("Anthropic Messages - Prompt (after templating)", "prompt", predInput)
if input.Stream {
return handleAnthropicStream(c, id, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, mcpExecutor, evaluator, piiRedactor, piiEvents)
return handleAnthropicStream(c, id, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, mcpExecutor, evaluator)
}
return handleAnthropicNonStream(c, id, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, mcpExecutor, evaluator)
@@ -330,36 +328,13 @@ func handleAnthropicNonStream(c echo.Context, id string, input *schema.Anthropic
return sendAnthropicError(c, 500, "api_error", "MCP iteration limit reached")
}
func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool, mcpExecutor mcpTools.ToolExecutor, evaluator *templates.Evaluator, piiRedactor *pii.Redactor, piiEvents pii.EventStore) error {
func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool, mcpExecutor mcpTools.ToolExecutor, evaluator *templates.Evaluator) error {
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
// Per-stream PII filter — same gating as the OpenAI chat path. The
// filter is wire-format-agnostic; we feed it the text portion of
// each text_delta and emit only what's safe to send. The filter
// holds back a tail of size MaxPatternLength-1 so a pattern split
// across chunk boundaries still gets masked. When PII is disabled
// for this model the filter is nil and emits flow unchanged.
var streamPIIFilter *pii.StreamFilter
if piiRedactor != nil && cfg.PIIIsEnabled() {
correlationID := c.Request().Header.Get("x-request-id")
userID := ""
if u := auth.GetUser(c); u != nil {
userID = u.ID
}
var overrides map[string]pii.Action
if raw := cfg.PIIPatternOverrides(); len(raw) > 0 {
overrides = make(map[string]pii.Action, len(raw))
for ovid, action := range raw {
switch pii.Action(action) {
case pii.ActionMask, pii.ActionBlock, pii.ActionAllow:
overrides[ovid] = pii.Action(action)
}
}
}
streamPIIFilter = pii.NewStreamFilter(piiRedactor, overrides, piiEvents, correlationID, userID)
}
// Response/output PII redaction is out of scope for now — redaction
// runs request-side only (the NER middleware).
// Send message_start event
messageStart := schema.AnthropicStreamEvent{
@@ -440,7 +415,6 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
if len(toolCalls) > toolCallsEmitted {
if !inToolCall && currentBlockIndex == 0 {
drainStreamPIIToText(c, streamPIIFilter, intPtr(currentBlockIndex))
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: intPtr(currentBlockIndex),
@@ -481,20 +455,14 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
}
if !inToolCall && token != "" {
out := token
if streamPIIFilter != nil {
out = streamPIIFilter.Push(token)
}
if out != "" {
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: out,
},
})
}
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: token,
},
})
}
return true
}
@@ -532,20 +500,14 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
// didn't already stream it (autoparser clears raw text, so
// accumulatedContent will be empty in that case).
if deltaContent != "" && !inToolCall && accumulatedContent == "" {
out := deltaContent
if streamPIIFilter != nil {
out = streamPIIFilter.Push(deltaContent)
}
if out != "" {
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: out,
},
})
}
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: deltaContent,
},
})
}
// Emit tool_use blocks from ChatDeltas
@@ -553,7 +515,6 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
collectedToolCalls = deltaToolCalls
if !inToolCall && currentBlockIndex == 0 {
drainStreamPIIToText(c, streamPIIFilter, intPtr(currentBlockIndex))
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: intPtr(currentBlockIndex),
@@ -657,9 +618,7 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
if !shouldUseFn && cfg.FunctionsConfig.AutomaticToolParsingFallback && accumulatedContent != "" && toolCallsEmitted == 0 {
parsed := functions.ParseFunctionCall(accumulatedContent, cfg.FunctionsConfig)
if len(parsed) > 0 {
// Close the text content block (after flushing any
// residual the streaming PII filter held back).
drainStreamPIIToText(c, streamPIIFilter, intPtr(currentBlockIndex))
// Close the text content block.
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: intPtr(currentBlockIndex),
@@ -699,12 +658,8 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
}
}
// No MCP tools to execute, close stream. drainStreamPIIToText
// flushes any residual the streaming PII filter held back as
// part of its trailing pattern-window before we close the
// text content block.
// No MCP tools to execute, close the text content block.
if !inToolCall {
drainStreamPIIToText(c, streamPIIFilter, intPtr(0))
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: intPtr(0),
@@ -752,30 +707,6 @@ func convertFuncsToOpenAITools(funcs functions.Functions) []functions.Tool {
func intPtr(i int) *int { return &i }
// drainStreamPIIToText flushes any residual the streaming PII filter
// has been holding back as part of its trailing pattern-window, and
// emits it as one final text_delta into the named block before the
// caller closes that block. Drain is idempotent: calling it twice on
// the same filter returns "" the second time. Safe to call with a nil
// filter (no-op).
func drainStreamPIIToText(c echo.Context, sf *pii.StreamFilter, index *int) {
if sf == nil {
return
}
residual := sf.Drain()
if residual == "" {
return
}
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: index,
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: residual,
},
})
}
func sendAnthropicSSE(c echo.Context, event schema.AnthropicStreamEvent) {
data, err := json.Marshal(event)
if err != nil {
@@ -973,17 +904,14 @@ func convertAnthropicTools(input *schema.AnthropicRequest, cfg *config.ModelConf
}
// forwardCloudProxyAnthropicViaBackend marshals the Anthropic request,
// constructs the streaming PII filter (when applicable), and hands the
// body off to the cloud-proxy gRPC backend. Model swap + upstream auth
// headers are applied inside the backend; the filter is built here
// because the auth/correlation context only exists in the echo handler.
func forwardCloudProxyAnthropicViaBackend(c echo.Context, cfg *config.ModelConfig, input *schema.AnthropicRequest, piiRedactor *pii.Redactor, piiEvents pii.EventStore, ml *model.ModelLoader, appConfig *config.ApplicationConfig) error {
// and hands the body off to the cloud-proxy gRPC backend. Model swap +
// upstream auth headers are applied inside the backend. Request-side PII
// redaction already ran in the middleware; the response is forwarded
// unmodified.
func forwardCloudProxyAnthropicViaBackend(c echo.Context, cfg *config.ModelConfig, input *schema.AnthropicRequest, ml *model.ModelLoader, appConfig *config.ApplicationConfig) error {
body, err := json.Marshal(input)
if err != nil {
return sendAnthropicError(c, 400, "invalid_request_error", "cloudproxy: marshal request: "+err.Error())
}
correlationID := c.Request().Header.Get("x-request-id")
streamFilter := cloudproxy.BuildStreamFilter(c, cfg, input.Stream, piiRedactor, piiEvents, correlationID)
return cloudproxy.ForwardViaBackend(c, cfg, body, streamFilter, ml, appConfig)
return cloudproxy.ForwardViaBackend(c, cfg, body, ml, appConfig)
}

View File

@@ -1,114 +0,0 @@
package anthropic
import (
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/services/routing/pii"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// drainStreamPIIToText is called from four sites in messages.go and is
// the load-bearing primitive for "the streaming filter has buffered
// some bytes that the request just ended on; flush them as a final
// text_delta event before closing the content block". A regression
// here would silently truncate the last few bytes of an assistant
// response on every PII-enabled stream — invisible without coverage.
// newTestFilter compiles the default patterns and returns a filter
// that holds back its trailing pattern-window; pushing a short string
// (shorter than holdLen) keeps the bytes inside Drain.
func newTestFilter() *pii.StreamFilter {
patterns, err := pii.Compile(pii.DefaultPatterns())
ExpectWithOffset(1, err).NotTo(HaveOccurred())
red := pii.NewRedactor(patterns)
return pii.NewStreamFilter(red, nil, nil, "", "")
}
// newTestContext builds a recording echo context — the recorder
// captures the SSE bytes drainStreamPIIToText writes.
func newTestContext() (echo.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{}"))
rec := httptest.NewRecorder()
return echo.New().NewContext(req, rec), rec
}
var _ = Describe("drainStreamPIIToText", func() {
It("is a no-op when the filter is nil", func() {
c, rec := newTestContext()
drainStreamPIIToText(c, nil, intPtr(0))
Expect(rec.Body.Len()).To(Equal(0), "nil filter wrote %d bytes: %q", rec.Body.Len(), rec.Body.String())
})
It("emits nothing when the drain is empty", func() {
// A filter with nothing buffered should not emit a phantom event;
// otherwise every non-PII response would close with an empty
// text_delta that pollutes downstream parsers.
sf := newTestFilter()
c, rec := newTestContext()
drainStreamPIIToText(c, sf, intPtr(0))
Expect(rec.Body.Len()).To(Equal(0), "empty drain wrote %d bytes: %q", rec.Body.Len(), rec.Body.String())
})
It("flushes residual buffered bytes as a text_delta event", func() {
sf := newTestFilter()
// Push less than holdLen so all bytes are retained until Drain.
// "tail" is short enough that no pattern is plausible.
out := sf.Push("tail")
Expect(out).To(Equal(""), "Push of short text emitted %q; want all bytes held", out)
c, rec := newTestContext()
drainStreamPIIToText(c, sf, intPtr(2))
body := rec.Body.String()
// Wire format: "event: content_block_delta\ndata: {…}\n\n"
Expect(body).To(ContainSubstring("event: content_block_delta"))
Expect(body).To(ContainSubstring(`"type":"content_block_delta"`))
Expect(body).To(ContainSubstring(`"index":2`))
Expect(body).To(ContainSubstring(`"text":"tail"`))
Expect(body).To(ContainSubstring(`"type":"text_delta"`))
Expect(strings.HasSuffix(body, "\n\n")).To(BeTrue(), "SSE event missing trailing blank line: %q", body)
})
It("is idempotent across consecutive drains", func() {
// Two consecutive Drains: the filter returns "" the second time,
// so the second drainStreamPIIToText must emit nothing. The
// production path in messages.go has at least four call sites
// that may overlap (currentBlockIndex==0 emergency path + the
// unconditional drain near the end of the stream); without
// idempotence we'd duplicate the residual on the wire.
sf := newTestFilter()
sf.Push("tail")
c1, rec1 := newTestContext()
drainStreamPIIToText(c1, sf, intPtr(0))
first := rec1.Body.Len()
Expect(first).NotTo(Equal(0), "first drain emitted nothing")
c2, rec2 := newTestContext()
drainStreamPIIToText(c2, sf, intPtr(0))
Expect(rec2.Body.Len()).To(Equal(0), "second drain wrote %d bytes; want idempotent no-op: %q", rec2.Body.Len(), rec2.Body.String())
})
It("masks redacted residual instead of leaking it", func() {
// The held tail must travel through the redactor on Drain. If
// the bytes happen to form a complete pattern at end-of-stream,
// the residual emit must contain the mask placeholder, not the
// raw value.
sf := newTestFilter()
// "alice@example.com" is 17 bytes. holdLen for default patterns
// is well above 17, so this stays buffered until Drain, which
// then redacts it.
out := sf.Push("alice@example.com")
Expect(out).To(Equal(""), "Push emitted bytes early: %q", out)
c, rec := newTestContext()
drainStreamPIIToText(c, sf, intPtr(0))
body := rec.Body.String()
Expect(body).NotTo(ContainSubstring("alice@example.com"), "raw email leaked in residual emit: %q", body)
Expect(body).To(ContainSubstring("[REDACTED:email]"), "residual emit missing mask placeholder: %q", body)
})
})

View File

@@ -100,15 +100,15 @@ var instructionDefs = []instructionDef{
},
{
Name: "pii-filtering",
Description: "Inspect and tune the regex PII filter applied to chat requests",
Description: "Inspect the NER-based 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, 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.",
Intro: "PII redaction is NER-based and request-side. A consuming model opts in with `pii: { enabled: true, detectors: [<model>] }` where each detector is a token-classification (token_classify) model. The detection policy lives on the detector model itself in a `pii_detection:` block: `{ min_score, default_action (mask|block|allow), entity_actions: { GROUP: action } }`. Multiple detectors union their hits; overlapping spans resolve to the strongest action (block > mask > allow). PII defaults OFF for non-proxy backends and ON for proxy-* (cloud passthroughs). Besides the inline path, two synchronous service endpoints expose the same engine without an inference request: POST /api/pii/analyze returns the detected entity spans (entity_type, source ner|pattern, start/end, score, action) without mutating the text, and POST /api/pii/redact applies the policy — returning redacted_text, or 400 (type pii_blocked) with the offending entities when a block action fires. Both take `{ text, detectors:[<model>...] }` (or `model` to inherit a consuming model's detectors), require the pii_filter feature (any authenticated user), and record audit events with an `origin` of pii_analyze / pii_redact. GET /api/pii/events returns recent redaction events filtered by correlation_id / user_id / pattern_id / origin (middleware|proxy|pii_analyze|pii_redact); events carry `<source>:<GROUP>` ids — e.g. `ner:EMAIL` for the neural detector, `pattern:ANTHROPIC_KEY` for the regex pattern tier — and an 8-char hash prefix, never the matched value (admin or local-user only). The legacy regex pattern tier and its endpoints (/api/pii/patterns, /test, /decide) were removed.",
},
{
Name: "middleware-admin",
Description: "Inspect and configure the routing-module middleware (PII filter and routing)",
Tags: []string{"middleware", "pii", "router"},
Intro: "GET /api/middleware/status is the single round-trip the /app/middleware admin page reads to render the current state: active PII patterns and their actions, every model's resolved enabled/override state, recent event count, and the active routing models with their classifier configurations. Admin-only (the synthetic local user is admin in no-auth mode). PUT /api/pii/patterns/:id changes a pattern's action in-process — TRANSIENT, lost on restart. To persist, edit --pii-config YAML. GET /api/router/decisions returns the routing decision log filtered by correlation_id / user_id / router_model. The same surface is exposed as MCP tools (`get_middleware_status`, `set_pii_pattern_action`, `get_router_decisions`) for agent-driven configuration.",
Intro: "GET /api/middleware/status is the single round-trip the /app/middleware admin page reads to render the current state: every model's resolved PII enabled state and the NER detector models it references, recent event count, and the active routing models with their classifier configurations. Admin-only (the synthetic local user is admin in no-auth mode). PII detection policy is edited on each detector model's `pii_detection:` block via the model-config tools/UI — there is no global pattern set to mutate. GET /api/router/decisions returns the routing decision log filtered by correlation_id / user_id / router_model. The same surface is exposed as MCP tools (`get_middleware_status`, `get_pii_events`, `get_router_decisions`) for agent-driven inspection.",
},
{
Name: "intelligent-routing",

View File

@@ -25,6 +25,10 @@ var knownPrefOnlyBackends = []schema.KnownBackend{
// Text LLM
// ds4: antirez/ds4 - single-model DeepSeek V4 Flash engine; auto-detected via DS4Importer
{Name: "ds4", Modality: "text", AutoDetect: false, Description: "antirez/ds4 DeepSeek V4 Flash engine (auto-detected; pref-only fallback)"},
// privacy-filter is now auto-detected via PrivacyFilterImporter (see
// core/gallery/importers/privacy-filter.go); the importer registry entry
// supersedes any pref-only line here, which the /backends/known merge would
// dedupe away.
{Name: "sglang", Modality: "text", AutoDetect: false, Description: "SGLang runtime (preference-only)"},
{Name: "tinygrad", Modality: "text", AutoDetect: false, Description: "tinygrad runtime (preference-only)"},
{Name: "trl", Modality: "text", AutoDetect: false, Description: "Transformers Reinforcement Learning (preference-only)"},
@@ -38,6 +42,7 @@ var knownPrefOnlyBackends = []schema.KnownBackend{
{Name: "qwen3-tts-cpp", Modality: "tts", AutoDetect: false, Description: "Qwen3 TTS C++ (preference-only)"},
{Name: "omnivoice-cpp", Modality: "tts", AutoDetect: false, Description: "OmniVoice C++ TTS with voice cloning and voice design (preference-only)"},
{Name: "faster-qwen3-tts", Modality: "tts", AutoDetect: false, Description: "Faster Qwen3 TTS (preference-only)"},
{Name: "supertonic", Modality: "tts", AutoDetect: false, Description: "Supertonic multilingual ONNX TTS (preference-only)"},
// Detection
{Name: "sam3-cpp", Modality: "detection", AutoDetect: false, Description: "SAM3 C++ object detection (preference-only)"},
// Audio transform (audio-in / audio-out, optional reference signal)

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