Compare commits

...

37 Commits

Author SHA1 Message Date
LocalAI [bot]
85f5267ed2 fix(llama-cpp): cap single-pass embedding batch to fit VRAM (#10695)
* fix(llama-cpp): cap single-pass embedding batch to fit VRAM

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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


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

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

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

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

Fixes #10622

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-07-06 09:23:10 +02:00
LocalAI [bot]
2348bdc16d chore: ⬆️ Update ggml-org/llama.cpp to 2da668617612d2df773f966e3b0ee22dc2beef7b (#10694)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 01:46:47 +02:00
walcz-de
2ccc67bc7f feat(agents): native Prometheus metrics for agent chat runs (#10689)
Operators need a scrape-friendly signal for agent-turn health (completing,
erroring, cancelled, duration) — log-derived counters proved brittle (ANSI/
timezone parsing, restart gaps). Adds localai_agent_runs_total{agent,outcome}
and localai_agent_run_seconds histogram, recorded at the Chat() response
handoff (single choke point of the local execution path). Lazy meter init,
same pattern as the PII events counter (#10641).

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-07-06 01:06:15 +02:00
LocalAI [bot]
0a6c62bb59 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to 73fe0c67bbf0898ba2999535e0680a02a7f8537d (#10683)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 01:05:43 +02:00
LocalAI [bot]
1297356e29 chore: ⬆️ Update ServeurpersoCom/omnivoice.cpp to daedb763fd442e0916eb130a479fdd74947291c0 (#10682)
⬆️ Update ServeurpersoCom/omnivoice.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 01:05:25 +02:00
LocalAI [bot]
3f36b1dbed chore: ⬆️ Update CrispStrobe/CrispASR to 09df654e304947f7521e1f52992ceacccf03c300 (#10693)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-06 00:32:28 +02:00
LocalAI [bot]
783222baf4 docs: ⬆️ update docs version mudler/LocalAI (#10680)
⬆️ Update docs version mudler/LocalAI

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

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

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

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

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 10:20:19 +02:00
LocalAI [bot]
deb43e56c0 chore(model-gallery): ⬆️ update checksum (#10686)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 10:20:02 +02:00
LocalAI [bot]
33869da527 chore: ⬆️ Update ggml-org/llama.cpp to 665892536dfb1b7532161e3182304bd35c33e768 (#10681)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 10:19:36 +02:00
LocalAI [bot]
8059117c2d chore: ⬆️ Update CrispStrobe/CrispASR to 1109cb3fcae2e242c2b3d42ec0e3fd6e813f2ce7 (#10685)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-05 09:29:18 +02:00
LocalAI [bot]
b0959d4756 feat(api): add GET /v1/models/capabilities endpoint (#10687)
Additive superset of /v1/models that enriches each model entry with the
capabilities it supports plus its input/output modalities
(text / image / audio / video). Clients that only understand /v1/models
are unaffected -- they simply never call the new route.

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

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

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

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

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

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

Refs #10677


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

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

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

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


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-04 09:08:20 +02:00
LocalAI [bot]
817136c20e chore: ⬆️ Update CrispStrobe/CrispASR to f35185b876fc482fcb2053a81a2697936ed5fcc0 (#10670)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-04 08:17:02 +02:00
LocalAI [bot]
8396ce1388 chore: ⬆️ Update ggml-org/llama.cpp to d4cff114c0084f1fbc9b4c62717eca8fb2ae494a (#10671)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-04 08:16:41 +02:00
LocalAI [bot]
348f3c87c0 fix(gpu-libs): bundle hipBLASLt TensileLibrary data so ROCm backends stop falling back (#10660) (#10672) the
The ROCm packager copied rocBLAS kernel data (rocblas/library/*.dat) into the
bundled lib/ dir and run.sh pointed ROCBLAS_TENSILE_LIBPATH at it, but the
parallel hipBLASLt data dir (hipblaslt/library/TensileLibrary_lazy_gfx*.dat)
was never packaged and no HIPBLASLT_TENSILE_LIBPATH was set. The bundled
libhipblaslt.so therefore resolved its per-arch kernel data relative to itself,
found nothing, and silently fell back to slow generic kernels, logging:

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

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

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

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


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-04 08:14:12 +02:00
LocalAI [bot]
13310905a3 chore: ⬆️ Update ikawrakow/ik_llama.cpp to bbc7de475178dd0535c16ad85f204a2529806c9d (#10669)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 23:35:41 +02:00
LocalAI [bot]
2cbb3c96b3 fix(gallery): block SSRF in gallery config URL fetch (#10665) (#10673)
POST /models/apply with an empty "id" fetches the attacker-supplied
"url" gallery config directly via http.Client, with no check that the
URL resolves to a public IP. In the default Docker deployment no API key
is configured, so any network-reachable client can coerce LocalAI into
issuing requests to internal services or cloud-metadata endpoints (and
exfiltrate a small slice of the response through the job error message).

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


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

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

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

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

  localai_pii_events_total{kind, origin, action, direction}

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

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

Signed-off-by: stefanwalcz <stefan.walcz@walcz.de>
2026-07-03 20:36:15 +00:00
LocalAI [bot]
bfd6c09d88 chore(model gallery): 🤖 add 1 new models via gallery agent (#10663)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 18:02:09 +02:00
Richard Palethorpe
eb32cd9073 feat(realtime): eager blocking pipeline warm-up + /backend/load API (#10662)
Realtime sessions previously lazy-loaded each pipeline sub-model (VAD,
transcription, LLM, TTS) on first use, so every cold session paid a
per-request model-load stall and load errors only surfaced mid-stream.

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-03 12:13:37 +02:00
LocalAI [bot]
715d4ed8e5 chore: ⬆️ Update ggml-org/llama.cpp to fdb1db877c526ec90f668eca1b858da5dba85560 (#10647)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 00:46:56 +02:00
LocalAI [bot]
9fcc9c0d43 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 87fc8701ff4da81a7d2a91ec0695f95eb3066a47 (#10649)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 00:46:41 +02:00
LocalAI [bot]
3c67b5b746 chore: ⬆️ Update CrispStrobe/CrispASR to 9a26976a8c8cf5af0afcdd04463cf8ba91e96a54 (#10648)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 00:46:25 +02:00
LocalAI [bot]
bea66fd84e chore: ⬆️ Update leejet/stable-diffusion.cpp to 2574f5936571645f784b77623e1f09bad97d948a (#10650)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 00:46:10 +02:00
LocalAI [bot]
f7a5dfd5ae chore: ⬆️ Update vllm-metal (darwin) to v0.3.0.dev20260701212152 (#10646)
⬆️ Update vllm-project/vllm-metal (darwin)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 00:45:36 +02:00
LocalAI [bot]
6bcaf30c14 chore: ⬆️ Update localai-org/privacy-filter.cpp to 735a6c28607ee82afc3a670383f41b55266a3b9a (#10628)
⬆️ Update localai-org/privacy-filter.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-03 00:45:17 +02:00
LocalAI [bot]
ef15b4bfda fix(vllm): install ROCm vLLM from the AMD wheel index on Python 3.12 (#10651)
* fix(vllm): install ROCm vLLM from the AMD wheel index on Python 3.12

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

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

Fixes #10642

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-03 00:04:44 +02:00
112 changed files with 3741 additions and 534 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,14 @@ else ifeq ($(BUILD_TYPE),openblas)
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DGGML_HIPBLAS=ON
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
ROCM_HOME ?= /opt/rocm
ROCM_PATH ?= /opt/rocm
export CXX=$(ROCM_HOME)/llvm/bin/clang++
export CC=$(ROCM_HOME)/llvm/bin/clang
AMDGPU_TARGETS ?= gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(OS),Darwin)

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# omnivoice.cpp version
OMNIVOICE_REPO?=https://github.com/ServeurpersoCom/omnivoice.cpp
OMNIVOICE_VERSION?=0f37401bebe9b20c0160a888e592108fc1d17607
OMNIVOICE_VERSION?=daedb763fd442e0916eb130a479fdd74947291c0
SO_TARGET?=libgomnivoicecpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
@@ -24,7 +24,14 @@ else ifeq ($(BUILD_TYPE),openblas)
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DGGML_HIPBLAS=ON
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
ROCM_HOME ?= /opt/rocm
ROCM_PATH ?= /opt/rocm
export CXX=$(ROCM_HOME)/llvm/bin/clang++
export CC=$(ROCM_HOME)/llvm/bin/clang
AMDGPU_TARGETS ?= gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(OS),Darwin)

View File

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

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# qwentts.cpp version
QWEN3TTS_REPO?=https://github.com/ServeurpersoCom/qwentts.cpp
QWEN3TTS_CPP_VERSION?=9dbe7ea26a01b30fccb117ae5e86807c1dc23d42
QWEN3TTS_CPP_VERSION?=73fe0c67bbf0898ba2999535e0680a02a7f8537d
SO_TARGET?=libgoqwen3ttscpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
@@ -24,7 +24,14 @@ else ifeq ($(BUILD_TYPE),openblas)
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DGGML_HIPBLAS=ON
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
ROCM_HOME ?= /opt/rocm
ROCM_PATH ?= /opt/rocm
export CXX=$(ROCM_HOME)/llvm/bin/clang++
export CC=$(ROCM_HOME)/llvm/bin/clang
AMDGPU_TARGETS ?= gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(OS),Darwin)

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# stablediffusion.cpp (ggml)
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
STABLEDIFFUSION_GGML_VERSION?=3590aa8d626e671a1b1dc84506ea2932a243a480
STABLEDIFFUSION_GGML_VERSION?=2574f5936571645f784b77623e1f09bad97d948a
CMAKE_ARGS+=-DGGML_MAX_NAME=128

View File

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

View File

@@ -29,7 +29,14 @@ else ifeq ($(BUILD_TYPE),openblas)
else ifeq ($(BUILD_TYPE),clblas)
CMAKE_ARGS+=-DGGML_CLBLAST=ON -DCLBlast_DIR=/some/path
else ifeq ($(BUILD_TYPE),hipblas)
CMAKE_ARGS+=-DGGML_HIPBLAS=ON -DVIBEVOICE_GGML_HIPBLAS=ON
# This ggml only understands GGML_HIP (GGML_HIPBLAS was removed upstream),
# so passing GGML_HIPBLAS silently produced a CPU-only build (see #10666).
ROCM_HOME ?= /opt/rocm
ROCM_PATH ?= /opt/rocm
export CXX=$(ROCM_HOME)/llvm/bin/clang++
export CC=$(ROCM_HOME)/llvm/bin/clang
AMDGPU_TARGETS ?= gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1151,gfx1200,gfx1201
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON -DVIBEVOICE_GGML_VULKAN=ON
else ifeq ($(OS),Darwin)

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,21 @@ if [ "x${BUILD_PROFILE}" == "xcpu" ]; then
EXTRA_PIP_INSTALL_FLAGS+=" --index-strategy=unsafe-best-match"
fi
# AMD ROCm: vLLM ships prebuilt ROCm wheels, but on a DEDICATED index
# (https://wheels.vllm.ai/rocm/), NOT PyPI, and ONLY for CPython 3.12. On any
# other Python the installer silently falls back to the CUDA-only PyPI wheel,
# which is unusable on an AMD GPU (import fails, so the backend never finds the
# vllm module). Force Python 3.12 before the venv is created (matches the
# intel/l4t13 cp312 bump); the hipblas branch below pulls vllm from the ROCm
# wheel index. unsafe-best-match lets uv consult that index and PyPI together.
# https://docs.vllm.ai/en/latest/getting_started/installation/gpu.html?device=rocm
if [ "x${BUILD_TYPE}" == "xhipblas" ]; then
PYTHON_VERSION="3.12"
PYTHON_PATCH="12"
PY_STANDALONE_TAG="20251120"
EXTRA_PIP_INSTALL_FLAGS+=" --index-strategy=unsafe-best-match"
fi
# cublas13 pulls the vLLM wheel from a per-tag cu130 index (PyPI's vllm wheel
# is built against CUDA 12 and won't load on cu130). uv's default per-package
# first-match strategy would still pick the PyPI wheel, so allow it to consult
@@ -104,7 +119,7 @@ if [ "$(uname -s)" = "Darwin" ]; then
# can rewrite it. Darwin therefore follows vllm-metal and can lag the Linux
# vllm pin (requirements-cublas13-after.txt, bumped independently against
# vllm/vllm) until vllm-metal supports a newer vLLM.
VLLM_METAL_VERSION="v0.3.0.dev20260701132215"
VLLM_METAL_VERSION="v0.3.0.dev20260704102955"
# The coupled vLLM source version is whatever this vllm-metal release builds
# against -- it declares it in its own installer as `vllm_v=`. Derive it from
@@ -194,6 +209,22 @@ elif [ "x${BUILD_TYPE}" == "xintel" ]; then
export CMAKE_PREFIX_PATH="$(python -c 'import site; print(site.getsitepackages()[0])'):${CMAKE_PREFIX_PATH:-}"
VLLM_TARGET_DEVICE=xpu uv pip install ${EXTRA_PIP_INSTALL_FLAGS:-} --no-deps .
popd
# AMD ROCm: install vllm from its dedicated ROCm wheel index instead of the
# CUDA-only PyPI wheel. installRequirements brings the base ROCm
# torch/transformers (requirements-hipblas.txt), then we pull vllm (plus the
# matching ROCm torch, via --upgrade) from wheels.vllm.ai/rocm. This is the
# method upstream prescribes for AMD; the Python-3.12 pin is set above.
# There is intentionally no requirements-hipblas-after.txt: a bare `vllm`
# there would resolve to the CUDA wheel, and installRequirements never loads
# a ${BUILD_TYPE}-after file for hipblas anyway (BUILD_TYPE == BUILD_PROFILE).
# https://docs.vllm.ai/en/latest/getting_started/installation/gpu.html?device=rocm
elif [ "x${BUILD_TYPE}" == "xhipblas" ]; then
installRequirements
# --upgrade reconciles the base ROCm torch to whatever the vllm ROCm wheel
# pins; --extra-index-url adds the ROCm wheel repository on top of PyPI.
uv pip install ${EXTRA_PIP_INSTALL_FLAGS:-} \
--extra-index-url https://wheels.vllm.ai/rocm/ --upgrade vllm
# FROM_SOURCE=true on a CPU build skips the prebuilt vllm wheel in
# requirements-cpu-after.txt and compiles vllm locally against the host's
# actual CPU. Not used by default because it takes ~30-40 minutes, but

View File

@@ -1 +0,0 @@
vllm

View File

@@ -207,21 +207,7 @@ func (l *Launcher) StartLocalAI() error {
}
// Build command arguments
dataPath := l.GetDataPath()
args := []string{
"run",
"--models-path", l.config.ModelsPath,
"--backends-path", l.config.BackendsPath,
"--address", l.config.Address,
"--log-level", l.config.LogLevel,
// Keep persistent data and dynamic config under the launcher's data
// directory (~/.localai) rather than letting the server resolve them
// to ${basepath}/{data,configuration}. ${basepath} expands to the
// launcher process's CWD (often the user's home root), which puts
// ~/data and ~/configuration outside ~/.localai. See #10610.
"--data-path", filepath.Join(dataPath, "data"),
"--localai-config-dir", filepath.Join(dataPath, "configuration"),
}
args := l.BuildRunArgs()
l.localaiCmd = exec.CommandContext(l.ctx, binaryPath, args...)
@@ -406,6 +392,32 @@ func (l *Launcher) GetWebUIURL() string {
return address
}
// BuildRunArgs assembles the argument list passed to `local-ai run`.
//
// Storage paths are anchored to the launcher's data directory instead of the
// server's own defaults. The server resolves data/config to ${basepath}
// (the launcher process CWD, often the user's home root) and generated-content
// /uploads to shared /tmp paths. On a shared /tmp (macOS routes /tmp to
// /private/tmp for every user) the first account to run LocalAI creates
// /tmp/generated with 0750 perms, so any other account then fails startup with
// "mkdir /tmp/generated/content: permission denied". Keeping every writable
// path under the per-user data directory avoids both the misplacement (#10610)
// and the cross-user /tmp collision.
func (l *Launcher) BuildRunArgs() []string {
dataPath := l.GetDataPath()
return []string{
"run",
"--models-path", l.config.ModelsPath,
"--backends-path", l.config.BackendsPath,
"--address", l.config.Address,
"--log-level", l.config.LogLevel,
"--data-path", filepath.Join(dataPath, "data"),
"--localai-config-dir", filepath.Join(dataPath, "configuration"),
"--generated-content-path", filepath.Join(dataPath, "generated"),
"--upload-path", filepath.Join(dataPath, "uploads"),
}
}
// GetDataPath returns the path where LocalAI data and logs are stored
func (l *Launcher) GetDataPath() string {
// LocalAI typically stores data in the current working directory or a models directory

View File

@@ -149,6 +149,41 @@ var _ = Describe("Launcher", func() {
})
})
Describe("BuildRunArgs", func() {
// Regression for the macOS "mkdir /tmp/generated/content: permission denied"
// startup failure: the launcher must redirect generated-content and upload
// paths under its own data directory instead of letting the server fall back
// to the shared /tmp defaults, which collide across users on a shared /tmp.
It("should keep generated-content and upload paths under the data directory", func() {
config := launcherInstance.GetConfig()
config.ModelsPath = filepath.Join(tempDir, "models")
launcherInstance.SetConfig(config)
dataPath := launcherInstance.GetDataPath()
args := launcherInstance.BuildRunArgs()
assertFlagValue := func(flag, expected string) {
idx := -1
for i, a := range args {
if a == flag {
idx = i
break
}
}
Expect(idx).To(BeNumerically(">=", 0), "expected %s to be present in run args", flag)
Expect(idx+1).To(BeNumerically("<", len(args)), "expected a value after %s", flag)
Expect(args[idx+1]).To(Equal(expected))
}
assertFlagValue("--generated-content-path", filepath.Join(dataPath, "generated"))
assertFlagValue("--upload-path", filepath.Join(dataPath, "uploads"))
// The bug was the server resolving these to shared /tmp paths.
for _, a := range args {
Expect(a).ToNot(HavePrefix("/tmp/"), "run args must not reference shared /tmp paths, got %s", a)
}
})
})
Describe("Logs", func() {
It("should return empty logs initially", func() {
logs := launcherInstance.GetLogs()

View File

@@ -57,10 +57,17 @@ For documentation and support:
),
kong.UsageOnError(),
kong.Vars{
"basepath": kong.ExpandPath("."),
"galleries": `[{"name":"localai", "url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`,
"backends": `[{"name":"localai", "url":"github:mudler/LocalAI/backend/index.yaml@master"}]`,
"version": internal.PrintableVersion(),
"basepath": kong.ExpandPath("."),
// Per-user temp locations for ephemeral writable content. A fixed
// shared name under /tmp collides across accounts on multi-user hosts
// (notably macOS, where /tmp is the shared /private/tmp for everyone),
// failing startup with "mkdir /tmp/generated/content: permission
// denied". See cli.DefaultGeneratedContentPath.
"generatedcontentpath": cli.DefaultGeneratedContentPath(),
"uploadpath": cli.DefaultUploadPath(),
"galleries": `[{"name":"localai", "url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`,
"backends": `[{"name":"localai", "url":"github:mudler/LocalAI/backend/index.yaml@master"}]`,
"version": internal.PrintableVersion(),
},
)
ctx, err := k.Parse(os.Args[1:])

View File

@@ -473,20 +473,13 @@ func New(opts ...config.AppOption) (*Application, error) {
if options.LoadToMemory != nil && !options.SingleBackend {
for _, m := range options.LoadToMemory {
cfg, err := application.ModelConfigLoader().LoadModelConfigFileByNameDefaultOptions(m, options)
if err != nil {
xlog.Debug("Auto loading model into memory from file", "model", m)
// Same path as POST /backend/load: a realtime pipeline model expands
// to its sub-models, and load failures are recorded as model_load
// traces.
if _, err := backend.PreloadModelByName(options.Context, application.ModelConfigLoader(), application.ModelLoader(), options, m); err != nil {
return nil, err
}
xlog.Debug("Auto loading model into memory from file", "model", m, "file", cfg.Model)
o := backend.ModelOptions(*cfg, options)
var backendErr error
_, backendErr = application.ModelLoader().Load(o...)
if backendErr != nil {
return nil, backendErr
}
}
}

View File

@@ -47,6 +47,28 @@ func needsThinkingProbe(c *config.ModelConfig) bool {
c.ReasoningConfig.DisableReasoningTagPrefill == nil)
}
// persistProbedReasoning writes the post-probe reasoning slots (and media
// marker) from probed back into the loader's persisted config for modelName,
// skipping any reasoning slot the probe was not actually allowed to fill.
// persistDisableReasoning/persistDisableTagPrefill must be snapshotted from
// probed's reasoning slots *before* the probe ran: a slot that already
// carried a value at that point was populated by request-time
// ApplyReasoningEffort, not by backend detection, and persisting it would
// masquerade as an operator's explicit reasoning.disable (see #10622).
func persistProbedReasoning(cl *config.ModelConfigLoader, modelName string, probed *config.ModelConfig, persistDisableReasoning, persistDisableTagPrefill bool) {
cl.UpdateModelConfig(modelName, func(cfg *config.ModelConfig) {
if persistDisableReasoning {
cfg.ReasoningConfig.DisableReasoning = probed.ReasoningConfig.DisableReasoning
}
if persistDisableTagPrefill {
cfg.ReasoningConfig.DisableReasoningTagPrefill = probed.ReasoningConfig.DisableReasoningTagPrefill
}
if probed.MediaMarker != "" {
cfg.MediaMarker = probed.MediaMarker
}
})
}
// HasChatDeltaContent returns true if any chat delta carries content or reasoning text.
// Used to decide whether to prefer C++ autoparser deltas over Go-side tag extraction.
func (t TokenUsage) HasChatDeltaContent() bool {
@@ -127,15 +149,19 @@ func ModelInference(ctx context.Context, s string, messages schema.Messages, ima
needsMarkerProbe := c.MediaMarker == ""
if shouldProbeThinking || needsMarkerProbe {
modelOpts := grpcModelOpts(*c, o.SystemState.Model.ModelsPath)
// DetectThinkingSupportFromBackend only fills reasoning slots that are
// still nil, so a slot that already carries a value here was populated by
// request-time ApplyReasoningEffort (e.g. a `reasoning_effort: none`
// default), not by backend detection. Persisting such a request-scoped
// value would masquerade as an operator's explicit reasoning.disable and
// permanently defeat future per-request reasoning_effort overrides
// (see #10622). Only persist the slots the probe is actually allowed to
// fill.
persistDisableReasoning := c.ReasoningConfig.DisableReasoning == nil
persistDisableTagPrefill := c.ReasoningConfig.DisableReasoningTagPrefill == nil
config.DetectThinkingSupportFromBackend(ctx, c, inferenceModel, modelOpts)
// Update the config in the loader so it persists for future requests
cl.UpdateModelConfig(c.Name, func(cfg *config.ModelConfig) {
cfg.ReasoningConfig.DisableReasoning = c.ReasoningConfig.DisableReasoning
cfg.ReasoningConfig.DisableReasoningTagPrefill = c.ReasoningConfig.DisableReasoningTagPrefill
if c.MediaMarker != "" {
cfg.MediaMarker = c.MediaMarker
}
})
persistProbedReasoning(cl, c.Name, c, persistDisableReasoning, persistDisableTagPrefill)
}
var protoMessages []*proto.Message

View File

@@ -1,6 +1,8 @@
package backend
import (
"os"
"github.com/mudler/LocalAI/core/config"
"github.com/gpustack/gguf-parser-go/util/ptr"
@@ -27,3 +29,90 @@ var _ = Describe("thinking probe gating", func() {
Expect(needsThinkingProbe(cfg)).To(BeFalse())
})
})
var _ = Describe("persistProbedReasoning", func() {
const modelName = "probe-test"
// newLoaderWithConfig seeds a ModelConfigLoader with a single model config
// parsed from yamlBody, mirroring how the loader is populated from disk.
newLoaderWithConfig := func(yamlBody string) *config.ModelConfigLoader {
tmp, err := os.CreateTemp("", "persist-probed-reasoning-*.yaml")
Expect(err).ToNot(HaveOccurred())
defer func() { _ = os.Remove(tmp.Name()) }()
_, err = tmp.WriteString(yamlBody)
Expect(err).ToNot(HaveOccurred())
Expect(tmp.Close()).To(Succeed())
cl := config.NewModelConfigLoader("")
Expect(cl.ReadModelConfig(tmp.Name())).To(Succeed())
return cl
}
It("persists a reasoning slot the probe was allowed to fill (was nil beforehand)", func() {
cl := newLoaderWithConfig("name: probe-test\nbackend: llama-cpp\n")
probed := &config.ModelConfig{}
probed.Name = modelName
probed.ReasoningConfig.DisableReasoning = ptr.To(false) // backend detected: supports thinking
probed.ReasoningConfig.DisableReasoningTagPrefill = ptr.To(true)
persistProbedReasoning(cl, modelName, probed, true, true)
cfg, ok := cl.GetModelConfig(modelName)
Expect(ok).To(BeTrue())
Expect(cfg.ReasoningConfig.DisableReasoning).ToNot(BeNil())
Expect(*cfg.ReasoningConfig.DisableReasoning).To(BeFalse())
Expect(cfg.ReasoningConfig.DisableReasoningTagPrefill).ToNot(BeNil())
Expect(*cfg.ReasoningConfig.DisableReasoningTagPrefill).To(BeTrue())
})
It("does not persist a slot that already carried a request-scoped value before the probe ran", func() {
cl := newLoaderWithConfig("name: probe-test\nbackend: llama-cpp\n")
probed := &config.ModelConfig{}
probed.Name = modelName
// Simulates ApplyReasoningEffort("none") having set this on the
// request-scoped copy before the probe ran - not a genuine backend
// detection, so it must never reach the persisted config (#10622).
probed.ReasoningConfig.DisableReasoning = ptr.To(true)
persistProbedReasoning(cl, modelName, probed, false, false)
cfg, ok := cl.GetModelConfig(modelName)
Expect(ok).To(BeTrue())
Expect(cfg.ReasoningConfig.DisableReasoning).To(BeNil())
Expect(cfg.ReasoningConfig.DisableReasoningTagPrefill).To(BeNil())
})
It("preserves an operator's explicit persisted disable when the guard is false", func() {
cl := newLoaderWithConfig("name: probe-test\nbackend: llama-cpp\nreasoning:\n disable: true\n")
probed := &config.ModelConfig{}
probed.Name = modelName
// Even if the request-scoped copy ends up holding a different value,
// persistDisableReasoning=false must keep the operator's own setting.
probed.ReasoningConfig.DisableReasoning = ptr.To(false)
persistProbedReasoning(cl, modelName, probed, false, false)
cfg, ok := cl.GetModelConfig(modelName)
Expect(ok).To(BeTrue())
Expect(cfg.ReasoningConfig.DisableReasoning).ToNot(BeNil())
Expect(*cfg.ReasoningConfig.DisableReasoning).To(BeTrue())
})
It("persists the media marker regardless of the reasoning guards", func() {
cl := newLoaderWithConfig("name: probe-test\nbackend: llama-cpp\n")
probed := &config.ModelConfig{}
probed.Name = modelName
probed.MediaMarker = "<__media__>"
persistProbedReasoning(cl, modelName, probed, false, false)
cfg, ok := cl.GetModelConfig(modelName)
Expect(ok).To(BeTrue())
Expect(cfg.MediaMarker).To(Equal("<__media__>"))
})
})

View File

@@ -52,6 +52,22 @@ func ModelLoadTraceObserver(appConfig *config.ApplicationConfig) func(model.Back
}
}
// PreloadModel warms a model into memory without running any inference, so the
// first real request doesn't pay the backend's cold-start load cost. It uses
// the same ModelOptions + ml.Load path the modality functions use, so a
// subsequent inference call hits the loader cache instead of reloading. Load
// failures are recorded and returned; callers that warm models opportunistically
// (e.g. realtime session warm-up) typically log and continue, since the lazy
// path will retry on first use.
func PreloadModel(ctx context.Context, ml *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) error {
opts := ModelOptions(modelConfig, appConfig, model.WithContext(ctx))
if _, err := ml.Load(opts...); err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return err
}
return nil
}
// recordModelLoadFailure records a backend trace when model loading fails.
func recordModelLoadFailure(appConfig *config.ApplicationConfig, modelName, backend string, err error, data map[string]any) {
if !appConfig.EnableTracing {
@@ -207,13 +223,24 @@ func EffectiveContextSize(c config.ModelConfig) int {
return DefaultContextSize
}
// localGPU resolves the device that will run the model, for single-pass batch
// sizing. It is a package var so tests inject a deterministic device; production
// reads config.LocalGPU, whose detection is sync.Once-cached in xsysinfo — so the
// per-request call from the router's prompt trimmer (modelTokenTrim) stays cheap.
var localGPU = config.LocalGPU
// EffectiveBatchSize is the single-decode batch the backend will run with.
// Score, embedding and rerank all process the whole input in one pass: score
// decodes prompt+candidate (asserts n_tokens <= n_batch), and embedding/rerank
// pool over the full sequence in one physical batch (n_ubatch). So the batch
// is sized to the context anything that fits the context fits one pass,
// pool over the full sequence in one physical batch (n_ubatch). Ideally the batch
// covers the whole context so any input that fits the context fits one pass,
// avoiding both the GGML_ASSERT crash and the "input is too large to process"
// error. Explicit `batch:` always wins.
// error — BUT a full ctx-sized n_ubatch makes the per-device CUDA compute buffer
// multi-GiB (it scales ~ n_ubatch * n_ctx and can't be split across GPUs), so a
// large-context embedding model aborts on load with free VRAM to spare (#10485).
// So we cap the batch to the largest that fits the per-device VRAM headroom; an
// input longer than that cap is the accepted tradeoff (it can't be pooled in one
// pass, but the load no longer OOMs). Explicit `batch:` always wins.
func EffectiveBatchSize(c config.ModelConfig) int {
if c.Batch != 0 {
return c.Batch
@@ -222,7 +249,7 @@ func EffectiveBatchSize(c config.ModelConfig) int {
c.HasUsecases(config.FLAG_EMBEDDINGS) ||
c.HasUsecases(config.FLAG_RERANK)
if ctx := EffectiveContextSize(c); singlePass && ctx > DefaultBatchSize {
return ctx
return config.SinglePassBatchForContext(localGPU(), ctx)
}
return DefaultBatchSize
}

View File

@@ -103,6 +103,19 @@ var _ = Describe("grpcModelOpts NBatch", func() {
threads := 1
ctx := 4096
// The single-pass batch is now VRAM-aware, so inject a deterministic GPU with
// ample per-device VRAM: at these small contexts the compute buffer fits
// easily, so EffectiveBatchSize returns the full context (the pre-#10485
// behaviour these cases assert). Without injection the value would depend on
// the CI host's real (often unknown) VRAM.
const gib = uint64(1) << 30
var origLocalGPU func() config.GPU
BeforeEach(func() {
origLocalGPU = localGPU
localGPU = func() config.GPU { return config.GPU{VRAM: 119 * gib} }
})
AfterEach(func() { localGPU = origLocalGPU })
It("defaults to 512 for an ordinary model", func() {
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}}
opts := grpcModelOpts(cfg, "/tmp/models")
@@ -162,6 +175,61 @@ var _ = Describe("grpcModelOpts NBatch", func() {
})
})
// Guards the VRAM-aware cap on the single-pass (embedding/score/rerank) batch:
// a large context must not turn n_ubatch into a multi-GiB compute buffer that
// aborts the load on a device with free VRAM (issue #10485). The GPU is injected
// via the localGPU package var so the cap is deterministic without a real device.
var _ = Describe("EffectiveBatchSize VRAM cap", func() {
const gib = uint64(1) << 30
embeddings := config.FLAG_EMBEDDINGS
threads := 1
var origLocalGPU func() config.GPU
BeforeEach(func() { origLocalGPU = localGPU })
AfterEach(func() { localGPU = origLocalGPU })
singlePassCfg := func(ctx int) config.ModelConfig {
return config.ModelConfig{
Threads: &threads,
LLMConfig: config.LLMConfig{ContextSize: &ctx},
KnownUsecases: &embeddings,
}
}
It("caps a large embedding context to a batch below the context but at least the default", func() {
// Reproduces qwen3-embedding-4b: context 40960 on a modest 20 GiB card.
// Full-context n_ubatch=40960 aborts; the cap must fit the VRAM headroom.
localGPU = func() config.GPU { return config.GPU{VRAM: 20 * gib} }
batch := EffectiveBatchSize(singlePassCfg(40960))
Expect(batch).To(BeNumerically(">=", DefaultBatchSize))
Expect(batch).To(BeNumerically("<", 40960))
})
It("keeps an explicit batch even with a large context and small VRAM", func() {
localGPU = func() config.GPU { return config.GPU{VRAM: 20 * gib} }
cfg := singlePassCfg(40960)
cfg.Batch = 512
Expect(EffectiveBatchSize(cfg)).To(Equal(512))
})
It("returns the full context when per-device VRAM is unknown", func() {
// Unknown VRAM (CPU / detection gap) preserves the original single-pass
// behavior: batch follows context. The VRAM cap is a downward safety that
// only engages when the per-device ceiling is known — clamping here would
// re-break single-pass pooling and over-trim inputs, with no OOM benefit on
// CPU where the compute buffer lives in system RAM.
localGPU = func() config.GPU { return config.GPU{VRAM: 0} }
Expect(EffectiveBatchSize(singlePassCfg(40960))).To(Equal(40960))
})
It("returns the default batch for a non-single-pass model regardless of VRAM", func() {
localGPU = func() config.GPU { return config.GPU{VRAM: 20 * gib} }
ctx := 40960
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}}
Expect(EffectiveBatchSize(cfg)).To(Equal(DefaultBatchSize))
})
})
// 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

122
core/backend/preload.go Normal file
View File

@@ -0,0 +1,122 @@
package backend
import (
"context"
"errors"
"fmt"
"sync"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// PreloadModelByName loads the named model into memory so the first request
// that uses it pays no cold-start load cost — the inverse of shutting a model
// down. If the model is a realtime pipeline (its config declares a `pipeline:`
// block), each configured sub-model (VAD, transcription, LLM, TTS,
// sound_detection, voice_recognition) is loaded concurrently instead of the
// pipeline stub, which has no backend of its own. It returns the model names
// actually loaded and a joined error naming each sub-model that failed (nil on
// full success); a partial pipeline load reports both the loaded names and the
// failures so the caller can surface exactly what is and isn't resident.
// Compaction's summary_model is deliberately left cold: it is only invoked off
// the response path, so it can stay lazy.
func PreloadModelByName(ctx context.Context, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, name string) ([]string, error) {
cfg, err := cl.LoadModelConfigFileByNameDefaultOptions(name, appConfig)
if err != nil {
return nil, err
}
stages, err := pipelineStages(cl, &cfg.Pipeline, ml.ModelPath)
if err != nil {
return nil, err
}
if len(stages) == 0 {
// Not a pipeline: load the model's own backend directly.
if err := PreloadModel(ctx, ml, *cfg, appConfig); err != nil {
return nil, err
}
return []string{cfg.Name}, nil
}
return PreloadStages(ctx, ml, appConfig, stages)
}
// PreloadStage names one pipeline sub-model to preload and the resolved config
// to load it from (nil = stage absent, skipped). Role labels the pipeline slot
// in errors and logs.
type PreloadStage struct {
Role string
Cfg *config.ModelConfig
}
// loadStage is PreloadModel behind a seam so PreloadStages can be unit-tested
// without spawning real backends.
var loadStage = PreloadModel
// pipelineStages resolves each populated pipeline stage to its concrete model
// config, following a single alias hop — the same resolution the realtime
// pipeline itself uses. A stage that fails to resolve is a misconfiguration,
// so it fails fast rather than being deferred to load. A pipeline with no
// stages set returns nil, which callers treat as "not a pipeline".
func pipelineStages(cl *config.ModelConfigLoader, p *config.Pipeline, modelPath string) ([]PreloadStage, error) {
voiceRec := ""
if p.VoiceRecognition != nil {
voiceRec = p.VoiceRecognition.Model
}
var stages []PreloadStage
for _, s := range []struct{ role, name string }{
{"vad", p.VAD},
{"transcription", p.Transcription},
{"llm", p.LLM},
{"tts", p.TTS},
{"sound_detection", p.SoundDetection},
{"voice_recognition", voiceRec},
} {
if s.name == "" {
continue
}
cfg, err := cl.LoadResolvedModelConfig(s.name, modelPath)
if err != nil {
return nil, fmt.Errorf("%s (%s): %w", s.role, s.name, err)
}
stages = append(stages, PreloadStage{Role: s.role, Cfg: cfg})
}
return stages, nil
}
// PreloadStages loads every present stage at once and waits for all of them, so
// a pipeline warms in the time of its slowest stage rather than the sum. Absent
// (nil-config) stages are skipped. A failed stage does not cancel the others —
// they all run to completion so the joined error names every broken stage at
// once, alongside the names that did load.
func PreloadStages(ctx context.Context, ml *model.ModelLoader, appConfig *config.ApplicationConfig, stages []PreloadStage) ([]string, error) {
var (
wg sync.WaitGroup
mu sync.Mutex
loaded []string
errs []error
)
for _, s := range stages {
if s.Cfg == nil {
continue
}
wg.Add(1)
go func(s PreloadStage) {
defer wg.Done()
if err := loadStage(ctx, ml, *s.Cfg, appConfig); err != nil {
xlog.Warn("preload: failed to load pipeline sub-model", "stage", s.Role, "model", s.Cfg.Name, "error", err)
mu.Lock()
errs = append(errs, fmt.Errorf("%s (%s): %w", s.Role, s.Cfg.Name, err))
mu.Unlock()
return
}
xlog.Debug("preload: loaded pipeline sub-model", "stage", s.Role, "model", s.Cfg.Name)
mu.Lock()
loaded = append(loaded, s.Cfg.Name)
mu.Unlock()
}(s)
}
wg.Wait()
return loaded, errors.Join(errs...)
}

View File

@@ -0,0 +1,146 @@
package backend
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("pipelineStages", func() {
seed := func(dir string, names ...string) *config.ModelConfigLoader {
for _, n := range names {
yaml := "name: " + n + "\nbackend: fake-backend\n"
Expect(os.WriteFile(filepath.Join(dir, n+".yaml"), []byte(yaml), 0o644)).To(Succeed())
}
cl := config.NewModelConfigLoader(dir)
Expect(cl.LoadModelConfigsFromPath(dir)).To(Succeed())
return cl
}
It("resolves only the populated stages, in load order", func() {
dir := GinkgoT().TempDir()
cl := seed(dir, "vad-m", "stt-m", "llm-m", "tts-m")
stages, err := pipelineStages(cl, &config.Pipeline{
VAD: "vad-m",
Transcription: "stt-m",
LLM: "llm-m",
TTS: "tts-m",
}, dir)
Expect(err).ToNot(HaveOccurred())
roles := make([]string, len(stages))
names := make([]string, len(stages))
for i, s := range stages {
roles[i] = s.Role
names[i] = s.Cfg.Name
}
Expect(roles).To(Equal([]string{"vad", "transcription", "llm", "tts"}))
Expect(names).To(Equal([]string{"vad-m", "stt-m", "llm-m", "tts-m"}))
})
It("skips unset stages and includes sound_detection and voice_recognition when set", func() {
dir := GinkgoT().TempDir()
cl := seed(dir, "stt-m", "ced", "spk")
stages, err := pipelineStages(cl, &config.Pipeline{
Transcription: "stt-m",
SoundDetection: "ced",
VoiceRecognition: &config.PipelineVoiceRecognition{Model: "spk"},
}, dir)
Expect(err).ToNot(HaveOccurred())
roles := make([]string, len(stages))
for i, s := range stages {
roles[i] = s.Role
}
Expect(roles).To(ConsistOf("transcription", "sound_detection", "voice_recognition"))
})
It("returns nil for a pipeline with no stages (not a pipeline)", func() {
dir := GinkgoT().TempDir()
cl := seed(dir)
stages, err := pipelineStages(cl, &config.Pipeline{}, dir)
Expect(err).ToNot(HaveOccurred())
Expect(stages).To(BeNil())
})
})
var _ = Describe("PreloadStages", func() {
var (
mu sync.Mutex
seen []string
)
// stubLoader swaps the loadStage seam for a recorder so no real backends
// are spawned; errFor injects per-model failures.
stubLoader := func(errFor map[string]error) {
loadStage = func(_ context.Context, _ *model.ModelLoader, cfg config.ModelConfig, _ *config.ApplicationConfig) error {
mu.Lock()
seen = append(seen, cfg.Name)
mu.Unlock()
return errFor[cfg.Name]
}
}
BeforeEach(func() {
seen = nil
})
AfterEach(func() {
loadStage = PreloadModel
})
mkStage := func(role, name string) PreloadStage {
return PreloadStage{Role: role, Cfg: &config.ModelConfig{Name: name}}
}
It("loads every present stage, skips absent (nil-config) ones, and returns the loaded names", func() {
stubLoader(nil)
loaded, err := PreloadStages(context.Background(), nil, nil, []PreloadStage{
mkStage("vad", "vad-m"),
{Role: "transcription"}, // absent stage
mkStage("llm", "llm-m"),
})
Expect(err).ToNot(HaveOccurred())
Expect(loaded).To(ConsistOf("vad-m", "llm-m"))
// Barrier: every stage has run by the time PreloadStages returns, so
// reading seen without the lock here is safe.
Expect(seen).To(ConsistOf("vad-m", "llm-m"))
})
It("reports a joined error naming each failed stage while still loading the rest", func() {
stubLoader(map[string]error{
"vad-m": errors.New("vad boom"),
"tts-m": errors.New("tts boom"),
})
loaded, err := PreloadStages(context.Background(), nil, nil, []PreloadStage{
mkStage("vad", "vad-m"),
mkStage("llm", "llm-m"),
mkStage("tts", "tts-m"),
})
// Every stage ran (a failure does not cancel the others)...
Expect(seen).To(ConsistOf("vad-m", "llm-m", "tts-m"))
// ...the stage that loaded fine is reported as loaded...
Expect(loaded).To(ConsistOf("llm-m"))
// ...and the joined error names every broken stage and its cause.
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("vad (vad-m)"))
Expect(err.Error()).To(ContainSubstring("vad boom"))
Expect(err.Error()).To(ContainSubstring("tts (tts-m)"))
Expect(err.Error()).To(ContainSubstring("tts boom"))
Expect(err.Error()).ToNot(ContainSubstring("llm"))
})
})

View File

@@ -35,8 +35,8 @@ type RunCMD struct {
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH,BACKEND_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends used for inferencing" group:"backends"`
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
GeneratedContentPath string `env:"LOCALAI_GENERATED_CONTENT_PATH,GENERATED_CONTENT_PATH" type:"path" default:"/tmp/generated/content" help:"Location for generated content (e.g. images, audio, videos)" group:"storage"`
UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"/tmp/localai/upload" help:"Path to store uploads from files api" group:"storage"`
GeneratedContentPath string `env:"LOCALAI_GENERATED_CONTENT_PATH,GENERATED_CONTENT_PATH" type:"path" default:"${generatedcontentpath}" help:"Location for generated content (e.g. images, audio, videos)" group:"storage"`
UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"${uploadpath}" help:"Path to store uploads from files api" group:"storage"`
DataPath string `env:"LOCALAI_DATA_PATH" type:"path" default:"${basepath}/data" help:"Path for persistent data (collectiondb, agent state, tasks, jobs). Separates mutable data from configuration" group:"storage"`
LocalaiConfigDir string `env:"LOCALAI_CONFIG_DIR" type:"path" default:"${basepath}/configuration" help:"Directory for dynamic loading of certain configuration files (currently api_keys.json and external_backends.json)" group:"storage"`
LocalaiConfigDirPollInterval time.Duration `env:"LOCALAI_CONFIG_DIR_POLL_INTERVAL" help:"Typically the config path picks up changes automatically, but if your system has broken fsnotify events, set this to an interval to poll the LocalAI Config Dir (example: 1m)" group:"storage"`
@@ -186,6 +186,31 @@ type RunCMD struct {
PIIDefaultDetectors []string `env:"LOCALAI_PII_DEFAULT_DETECTORS" help:"Instance-wide default PII/secret detector model names applied to any PII-enabled model (chiefly cloud-proxy / MITM models) that names no pii.detectors of its own. Comma-separated, e.g. privacy-filter-nemotron,secret-filter. Takes precedence over the value persisted via the Middleware UI." group:"middleware"`
}
// userScopedTempDir returns a temp directory namespaced to the current user.
//
// The generated-content and upload directories are ephemeral, so they live
// under the OS temp dir - but a fixed shared name like /tmp/generated is a trap
// on any multi-user host. macOS routes /tmp to the shared /private/tmp for every
// account, so whichever user starts LocalAI first creates the parent with 0750
// perms and every other account then fails startup with
// "mkdir /tmp/generated/content: permission denied" (the same happens on Linux
// once a stale root-owned /tmp/generated is left behind). Scoping to the current
// UID gives each account its own tree so they never collide.
func userScopedTempDir() string {
return filepath.Join(os.TempDir(), fmt.Sprintf("localai-%d", os.Getuid()))
}
// DefaultGeneratedContentPath returns the default location for backend-generated
// content (images, audio, videos).
func DefaultGeneratedContentPath() string {
return filepath.Join(userScopedTempDir(), "generated", "content")
}
// DefaultUploadPath returns the default location for uploads from the files API.
func DefaultUploadPath() string {
return filepath.Join(userScopedTempDir(), "upload")
}
func (r *RunCMD) Run(ctx *cliContext.Context) error {
warnDeprecatedFlags()

View File

@@ -0,0 +1,50 @@
package cli
import (
"fmt"
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Regression for the startup failure observed when a second OS account (or a
// leftover root-owned directory) already created the shared /tmp locations:
//
// unable to create ImageDir: "mkdir /tmp/generated/content: permission denied"
//
// The historical defaults (/tmp/generated/content and /tmp/localai/upload) are
// shared across every user of a machine. On macOS /tmp is routed to the shared
// /private/tmp for all accounts, so the first account to run LocalAI creates the
// parent with 0750 perms and locks everyone else out. The defaults must instead
// be scoped to the current user so unrelated accounts never collide.
var _ = Describe("default writable paths", func() {
userScope := fmt.Sprintf("localai-%d", os.Getuid())
Describe("DefaultGeneratedContentPath", func() {
It("is scoped to the current user under the OS temp dir", func() {
p := DefaultGeneratedContentPath()
Expect(p).To(HavePrefix(os.TempDir()))
Expect(p).To(ContainSubstring(userScope))
Expect(p).To(HaveSuffix(filepath.Join("generated", "content")))
})
It("is not the historical shared path", func() {
Expect(DefaultGeneratedContentPath()).ToNot(Equal("/tmp/generated/content"))
})
})
Describe("DefaultUploadPath", func() {
It("is scoped to the current user under the OS temp dir", func() {
p := DefaultUploadPath()
Expect(p).To(HavePrefix(os.TempDir()))
Expect(p).To(ContainSubstring(userScope))
Expect(p).To(HaveSuffix("upload"))
})
It("is not the historical shared path", func() {
Expect(DefaultUploadPath()).ToNot(Equal("/tmp/localai/upload"))
})
})
})

144
core/config/context_fit.go Normal file
View File

@@ -0,0 +1,144 @@
package config
import (
gguf "github.com/gpustack/gguf-parser-go"
"github.com/mudler/LocalAI/pkg/xsysinfo"
"github.com/mudler/xlog"
)
// contextFitHeadroomDivisor reserves a slice of per-device VRAM as headroom when
// deciding whether an auto-derived context fits. The gguf-parser footprint
// already covers weights + KV + compute buffer, but a live load also pays for
// allocator fragmentation, the CUDA/HIP context, and whatever else shares the
// card, so we require the estimate to leave at least 1/divisor of the device
// free. /5 (~20% headroom) mirrors the SWA full-cache gate's margin.
const contextFitHeadroomDivisor = 5
// contextFitCandidates is the descending set of context windows tried when the
// DefaultAutoContextSize cap itself does not fit per-device VRAM. Only the rare
// big-model-on-tiny-card case reaches this walk; it is capped at the base
// choice and floored at DefaultContextSize, and returns the first (largest)
// candidate that fits.
var contextFitCandidates = []int{8192, 6144, 4096}
// perDeviceVRAM reports the smallest per-GPU VRAM ceiling in bytes (0 = unknown
// or no GPU). It is a package var so tests can inject a deterministic value —
// detection does a live GPU probe. Per-device (not summed) is the right budget:
// with all layers offloaded to a single device the whole footprint must fit that
// one card, and a multi-GPU host is bounded by its smallest card. This mirrors
// localGPU's use of MinPerGPUVRAM in hardware_defaults.go.
var perDeviceVRAM = func() uint64 {
v, _ := xsysinfo.MinPerGPUVRAM()
return v
}
// estimateContextVRAM returns the estimated per-device VRAM footprint (bytes) of
// running f fully offloaded at ctx tokens — weights + KV cache + compute buffer.
// It returns 0 when it cannot produce an estimate (nil file, no tensors, or a
// parser panic), which the caller treats as "cannot confirm a smaller fit" and
// so keeps the conservative cap rather than clamping on a bogus number. It is a
// package var so tests can stub it (a fabricated GGUF carries no tensors and
// estimates to ~0).
var estimateContextVRAM = func(f *gguf.GGUFFile, ctx int) (footprint uint64) {
if f == nil {
return 0
}
if ctx <= 0 {
ctx = DefaultContextSize
}
// The gguf-parser estimator panics on degenerate / partially-parsed GGUFs;
// treat any failure as "unknown" so config loading never crashes on a model
// the parser mis-handles.
defer func() {
if r := recover(); r != nil {
xlog.Debug("[context_fit] per-device VRAM estimate failed; treating as unknown", "error", r)
footprint = 0
}
}()
// Offload all layers (LocalAI's DefaultNGPULayers default; the estimator
// clamps to the model's block count) so the estimate reflects a fully
// GPU-resident model. NonUMA is the discrete-GPU figure (larger than the UMA
// one), which keeps the fit check conservative on unified-memory hosts — they
// have ample memory to clear it anyway.
est := f.EstimateLLaMACppRun(
gguf.WithLLaMACppContextSize(int32(ctx)),
gguf.WithLLaMACppOffloadLayers(uint64(DefaultNGPULayers)),
)
sum := est.Summarize(true, 0, 0)
if len(sum.Items) == 0 {
return 0
}
var total uint64
for _, v := range sum.Items[0].VRAMs {
total += uint64(v.NonUMA)
}
return total
}
// contextFitsVRAM reports whether an estimated footprint fits a per-device VRAM
// ceiling with headroom (VRAM must exceed the footprint by ~1/divisor). Unknown
// inputs (0) are treated as "cannot confirm" so a detection or estimate gap does
// not clamp the context.
func contextFitsVRAM(footprint, vram uint64) bool {
if footprint == 0 || vram == 0 {
return false
}
return vram >= footprint+footprint/contextFitHeadroomDivisor
}
// autoContextSize picks the default context to use for f when the user did not
// set context_size. The choice is deliberately conservative, NOT
// VRAM-maximizing:
//
// 1. Base cap: min(trainedMax, DefaultAutoContextSize). A small model keeps its
// trained window; a long-context model (128k / 256k / 1M) is capped so its
// KV cache does not default to a size no consumer GPU can hold. This applies
// always, including CPU / unknown-VRAM hosts.
// 2. VRAM is only a downward safety: when a per-device VRAM ceiling IS detected
// and even the base cap would not fit it (with headroom), step down through
// contextFitCandidates to the largest window that fits, floored at
// DefaultContextSize. When VRAM is unknown we skip this — the base cap is
// already safe and we must not regress CPU / detection-gap hosts.
//
// trainedMax <= 0 means the estimate yielded nothing usable; the caller keeps
// its existing DefaultContextSize fallback in that case, so this is only called
// with a positive trainedMax.
func autoContextSize(f *gguf.GGUFFile, trainedMax int) int {
chosen := trainedMax
if chosen > DefaultAutoContextSize {
chosen = DefaultAutoContextSize
}
vram := perDeviceVRAM()
if vram == 0 {
// No per-device VRAM detected (CPU-only, unified memory reporting nothing,
// or a detection gap). The bug is GPU OOM-on-load, so with no GPU budget to
// reason about we must not clamp — the base cap already bounds long-context
// models.
return chosen
}
if contextFitsVRAM(estimateContextVRAM(f, chosen), vram) {
return chosen
}
// The base cap does not fit this card. Walk candidates downward and take the
// largest that fits, never below DefaultContextSize.
for _, cand := range contextFitCandidates {
if cand > chosen || cand < DefaultContextSize {
continue
}
if contextFitsVRAM(estimateContextVRAM(f, cand), vram) {
xlog.Debug("[context_fit] capped auto context to fit per-device VRAM",
"context", cand, "base_cap", chosen, "vram_gib", vram>>30)
return cand
}
}
// Nothing fit (an unusually large model on a tiny card): fall back to the
// floor. The backend still clamps n_gpu_layers to what fits, so a partial
// offload can keep the model loadable rather than aborting outright.
xlog.Debug("[context_fit] no candidate context fit per-device VRAM; using floor",
"context", DefaultContextSize, "base_cap", chosen, "vram_gib", vram>>30)
return DefaultContextSize
}

View File

@@ -0,0 +1,101 @@
package config
import (
gguf "github.com/gpustack/gguf-parser-go"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// These specs exercise the auto-derived default context. The detection seams
// (perDeviceVRAM, estimateContextVRAM) are package vars so a deterministic VRAM
// ceiling and footprint can be injected without a real GPU or model file — the
// same pattern hardware_defaults_internal_test.go uses for localGPU.
var _ = Describe("Auto-derived default context (VRAM-aware cap)", func() {
const gib = uint64(1) << 30
var (
origVRAM func() uint64
origEstimate func(f *gguf.GGUFFile, ctx int) uint64
)
BeforeEach(func() {
origVRAM = perDeviceVRAM
origEstimate = estimateContextVRAM
})
AfterEach(func() {
perDeviceVRAM = origVRAM
estimateContextVRAM = origEstimate
})
Context("autoContextSize", func() {
It("caps a long-context model at DefaultAutoContextSize when VRAM is ample", func() {
// 1M-context model on an 80 GiB card: we do NOT chase the trained max,
// we keep the conservative 8k cap (users opt into more via context_size).
perDeviceVRAM = func() uint64 { return 80 * gib }
estimateContextVRAM = func(_ *gguf.GGUFFile, _ int) uint64 { return gib } // trivially fits
Expect(autoContextSize(nil, 1048576)).To(Equal(DefaultAutoContextSize))
})
It("keeps a small model's trained window instead of inflating it", func() {
// trained 4096 < 8192: min() keeps 4096, it is not raised to the cap.
perDeviceVRAM = func() uint64 { return 80 * gib }
estimateContextVRAM = func(_ *gguf.GGUFFile, _ int) uint64 { return gib }
Expect(autoContextSize(nil, 4096)).To(Equal(4096))
})
It("steps below the cap when even 8k would not fit a tiny card", func() {
// A large model on a 2 GiB card where the 8k footprint overflows but a
// smaller context fits: choose the largest that fits, never below the
// floor. Footprint grows with context so the walk finds a fit.
perDeviceVRAM = func() uint64 { return 2 * gib }
estimateContextVRAM = func(_ *gguf.GGUFFile, ctx int) uint64 {
return gib + uint64(ctx)*100000
}
chosen := autoContextSize(nil, 1048576)
Expect(chosen).To(BeNumerically("<", DefaultAutoContextSize))
Expect(chosen).To(BeNumerically(">=", DefaultContextSize))
// The chosen context's footprint must actually fit the card with headroom.
Expect(contextFitsVRAM(estimateContextVRAM(nil, chosen), 2*gib)).To(BeTrue())
})
It("falls back to the floor when nothing fits", func() {
// Even DefaultContextSize does not fit: return the floor and let the
// backend clamp n_gpu_layers to what it can (partial offload) rather
// than defaulting to a window guaranteed to abort.
perDeviceVRAM = func() uint64 { return 1 * gib }
estimateContextVRAM = func(_ *gguf.GGUFFile, _ int) uint64 { return 100 * gib }
Expect(autoContextSize(nil, 1048576)).To(Equal(DefaultContextSize))
})
It("does not clamp when per-device VRAM is unknown", func() {
// CPU-only / detection gap: no GPU budget to reason about, so we must
// not regress — keep the conservative base cap regardless of estimate.
perDeviceVRAM = func() uint64 { return 0 }
estimateContextVRAM = func(_ *gguf.GGUFFile, _ int) uint64 { return 999 * gib }
Expect(autoContextSize(nil, 1048576)).To(Equal(DefaultAutoContextSize))
})
})
Context("guessGGUFFromFile", func() {
It("never overrides an explicitly configured context_size", func() {
// A fabricated GGUF is enough: the context branch is skipped entirely
// when the user pinned context_size, so the estimate is never consulted.
explicit := 262144
cfg := &ModelConfig{LLMConfig: LLMConfig{ContextSize: &explicit}}
f := &gguf.GGUFFile{
Header: gguf.GGUFHeader{
MetadataKV: gguf.GGUFMetadataKVs{
{
Key: "general.architecture",
ValueType: gguf.GGUFMetadataValueTypeString,
Value: "llama",
},
},
},
}
guessGGUFFromFile(cfg, f, 0)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(262144))
})
})
})

View File

@@ -18,6 +18,18 @@ const (
// safe default beats a tiny, surprising window that truncates real prompts.
DefaultContextSize = 4096
// DefaultAutoContextSize caps the context we auto-derive from a GGUF when the
// user did not set context_size. The GGUF importer used to default a model's
// context to its full trained window (n_ctx_train). For long-context models
// (128k / 256k / 1M) that KV cache cannot fit a consumer GPU and the backend
// aborts on load (exitCode=-1) even though the model file is fine. So instead
// of shooting for the trained max, we keep a modest default: a small model
// (trained < this) keeps its trained window, while a long-context model caps
// here. Users who want the full window raise context_size explicitly. This is
// a conservative default, not a VRAM-maximizing one — VRAM is only used to
// step further DOWN when even this cap would not fit (see context_fit.go).
DefaultAutoContextSize = 8192
// DefaultNGPULayers means "offload all layers"; the backend (fit_params)
// clamps to what actually fits in device memory.
DefaultNGPULayers = 99999999

View File

@@ -28,9 +28,14 @@ func reservedNonChatModel(cfg *ModelConfig) bool {
func guessGGUFFromFile(cfg *ModelConfig, f *gguf.GGUFFile, defaultCtx int) {
if defaultCtx == 0 && cfg.ContextSize == nil {
ctxSize := f.EstimateLLaMACppRun().ContextSize
if ctxSize > 0 {
cSize := int(ctxSize)
// trainedMax is the model's full trained context window (n_ctx_train).
// Defaulting a model to it unbounded is what OOMs long-context models at
// load: a 128k / 256k / 1M KV cache cannot fit a consumer GPU and the
// backend aborts (exitCode=-1). autoContextSize instead caps to a modest
// default and only steps below it when detected per-device VRAM demands.
trainedMax := int(f.EstimateLLaMACppRun().ContextSize)
if trainedMax > 0 {
cSize := autoContextSize(f, trainedMax)
cfg.ContextSize = &cSize
} else {
defaultCtx = DefaultContextSize
@@ -67,16 +72,6 @@ func guessGGUFFromFile(cfg *ModelConfig, f *gguf.GGUFFile, defaultCtx int) {
ApplyMTPDefaults(cfg, n)
}
// Sliding-window-attention models (Gemma 2/3, Cohere2, Llama 4, ...) ship
// with a reduced SWA KV cache by default, which cannot reuse a prompt
// prefix across requests and so defeats the cross-request prefix cache
// (cache_reuse) we enable in serving_defaults.go. Enable the full SWA cache
// for these models so the prefix survives; skipped for dense models and
// when the user already pinned an SWA cache option.
if w, ok := HasSlidingWindowAttention(f); ok {
ApplySWAFullDefault(cfg, w)
}
// Thinking support detection is done after model load via DetectThinkingSupportFromBackend
// template estimations

View File

@@ -149,6 +149,51 @@ func largeContextForDevice(g GPU, ctx int) bool {
return extra > g.VRAM/blackwellBatchHeadroomDivisor
}
// SinglePassBatchForContext caps the physical batch (n_batch / n_ubatch) for a
// single-pass load — embedding, score and rerank all decode/pool the whole input
// in ONE physical batch, so they want a batch >= the input length to avoid the
// GGML_ASSERT(n_tokens <= n_batch) abort and the "input is too large to process"
// error. The naive choice is batch == context, but n_ubatch == context turns the
// per-device CUDA compute buffer (which scales ~ n_ubatch * n_ctx and is NOT
// split across GPUs) into multi-GiB of scratch that must fit on a SINGLE card, so
// a large-context embedding model aborts on load (exitCode=-1) even with plenty
// of free VRAM — the same #10485 root cause the Blackwell batch boost guards
// against, which the single-pass path previously bypassed entirely.
//
// So instead of the full context we return the LARGEST batch whose compute buffer
// fits the per-device VRAM headroom (VRAM / blackwellBatchHeadroomDivisor),
// clamped to [DefaultPhysicalBatch, ctx]. The tradeoff: an input longer than the
// returned cap can no longer be pooled in a single pass — but a batch that OOMs
// the device processes nothing at all.
//
// g.VRAM must be the PER-DEVICE ceiling (smallest device on a multi-GPU host).
// VRAM 0 (unknown — CPU-only or a detection gap) returns the full context,
// preserving the original single-pass behavior (batch follows context): the cap
// is a DOWNWARD safety that only engages when the per-device ceiling is known.
// Returning a smaller batch on unknown VRAM would re-break single-pass pooling
// (n_tokens > n_batch) and over-trim score/embed/rerank inputs, with no OOM
// benefit on CPU where the buffer lives in system RAM.
func SinglePassBatchForContext(g GPU, ctx int) int {
if ctx <= DefaultPhysicalBatch {
return DefaultPhysicalBatch
}
if g.VRAM == 0 {
return ctx
}
perBatchCell := uint64(ctx) * computeBufferBytesPerCell
if perBatchCell == 0 {
return DefaultPhysicalBatch
}
batchCap := int(g.VRAM / blackwellBatchHeadroomDivisor / perBatchCell)
if batchCap < DefaultPhysicalBatch {
return DefaultPhysicalBatch
}
if batchCap > ctx {
return ctx
}
return batchCap
}
// 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
@@ -254,6 +299,14 @@ var localGPU = func() GPU {
}
}
// LocalGPU exposes the locally-detected device descriptor to other packages
// (e.g. core/backend's single-pass batch sizing) so they resolve the same
// per-device VRAM this package's heuristics reason about. It goes through the
// injectable localGPU var, so a config-package test seam also affects callers.
func LocalGPU() GPU {
return localGPU()
}
// 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).

View File

@@ -46,3 +46,41 @@ var _ = Describe("SetDefaults hardware defaults (single-instance)", func() {
Expect(cfg.Batch).To(Equal(1024))
})
})
// SinglePassBatchForContext is the VRAM-aware cap for the single-pass
// (embedding/score/rerank) batch — the compute buffer scales ~ n_ubatch * n_ctx
// and must fit a single device, so a large context can't take the full context
// as its batch (issue #10485).
var _ = Describe("SinglePassBatchForContext", func() {
const gib = uint64(1) << 30
It("returns the default when the context is at or below the default batch", func() {
Expect(SinglePassBatchForContext(GPU{VRAM: 119 * gib}, DefaultPhysicalBatch)).To(Equal(DefaultPhysicalBatch))
Expect(SinglePassBatchForContext(GPU{VRAM: 119 * gib}, 256)).To(Equal(DefaultPhysicalBatch))
})
It("returns the full context when the compute buffer fits ample VRAM", func() {
// 4096 ctx on 119 GiB: the compute buffer is tiny, so the batch covers
// the whole context (single-pass pooling in one physical batch).
Expect(SinglePassBatchForContext(GPU{VRAM: 119 * gib}, 4096)).To(Equal(4096))
})
It("caps below the context when a large context would overflow the VRAM headroom", func() {
batch := SinglePassBatchForContext(GPU{VRAM: 20 * gib}, 40960)
Expect(batch).To(BeNumerically(">=", DefaultPhysicalBatch))
Expect(batch).To(BeNumerically("<", 40960))
// The compute buffer for the capped batch must fit VRAM/headroom.
Expect(uint64(batch) * 40960 * computeBufferBytesPerCell).To(BeNumerically("<=", (20*gib)/blackwellBatchHeadroomDivisor))
})
It("never caps below the default batch even when VRAM is very tight", func() {
Expect(SinglePassBatchForContext(GPU{VRAM: 1 * gib}, 40960)).To(Equal(DefaultPhysicalBatch))
})
It("returns the full context (unclamped) when per-device VRAM is unknown", func() {
// Unknown VRAM (CPU / detection gap) preserves the original single-pass
// behavior — the cap is a downward safety that only engages when VRAM is
// known. Clamping here would over-trim score/embed/rerank inputs.
Expect(SinglePassBatchForContext(GPU{VRAM: 0}, 40960)).To(Equal(40960))
})
})

View File

@@ -599,6 +599,13 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Component: "toggle",
Order: 89,
},
"pipeline.disable_warmup": {
Section: "pipeline",
Label: "Disable Warmup",
Description: "Turn off eager pre-loading of the pipeline's sub-models at realtime session start. By default LocalAI loads every configured sub-model backend (VAD, transcription, LLM, TTS, sound detection, voice recognition) before the session starts and blocks until they are ready, so the first turn pays no cold-start cost and a model that fails to load is reported at session start instead of mid-call. Enable this to restore the lazy 'load on first use' behavior — session start no longer waits on loading and load errors surface on the first turn instead. Useful to keep idle sessions from holding model memory they may never use.",
Component: "toggle",
Order: 90,
},
// --- Functions ---
"function.grammar.parallel_calls": {

View File

@@ -0,0 +1,197 @@
package config
// This file is the single source of truth for deriving a model's user-facing
// capabilities and input/output modalities from its ModelConfig. Both the
// OpenAI-compatible /v1/models/capabilities endpoint and the Ollama-compatible
// /api/tags|/api/show surface consume these, so the vocabulary stays consistent
// across clients. Keep the detection heuristics here rather than duplicating
// them per endpoint.
// VisionSupported reports whether the model can accept image inputs.
//
// We deliberately avoid HasUsecases(FLAG_VISION): GuessUsecases has no
// FLAG_VISION branch and reports true for any chat model, so it would paint
// vision onto text-only models. Instead we look for explicit signals: the
// declared KnownUsecases bit, a multimodal projector, or a template/backend
// multimodal marker.
func (c *ModelConfig) VisionSupported() bool {
if c.KnownUsecases != nil && (*c.KnownUsecases&FLAG_VISION) == FLAG_VISION {
return true
}
if c.MMProj != "" {
return true
}
if c.TemplateConfig.Multimodal != "" {
return true
}
if c.MediaMarker != "" {
return true
}
return false
}
// ToolSupported reports whether the model is wired up for tool / function
// calling. We look for any of the explicit knobs LocalAI uses to drive
// function-call extraction (regex match, response regex, grammar triggers, XML
// format) or the auto-detected tool-format markers the llama.cpp backend
// populates during model load.
func (c *ModelConfig) ToolSupported() bool {
fc := c.FunctionsConfig
if fc.ToolFormatMarkers != nil && fc.ToolFormatMarkers.FormatType != "" {
return true
}
if len(fc.JSONRegexMatch) > 0 || len(fc.ResponseRegex) > 0 {
return true
}
if fc.XMLFormatPreset != "" || fc.XMLFormat != nil {
return true
}
if len(fc.GrammarConfig.GrammarTriggers) > 0 || fc.GrammarConfig.SchemaType != "" {
return true
}
return false
}
// ThinkingSupported reports whether the model has reasoning / thinking enabled.
// LocalAI sets DisableReasoning=false (or leaves thinking markers configured)
// when the backend probe reports that the model supports thinking.
func (c *ModelConfig) ThinkingSupported() bool {
rc := c.ReasoningConfig
if rc.DisableReasoning != nil && !*rc.DisableReasoning {
return true
}
if len(rc.ThinkingStartTokens) > 0 || len(rc.TagPairs) > 0 {
// Explicit thinking markers imply support unless explicitly disabled.
return rc.DisableReasoning == nil || !*rc.DisableReasoning
}
return false
}
// AudioInputSupported reports whether a chat/generation model accepts audio as
// input (e.g. vLLM omni models). The signal is the vLLM per-prompt audio limit;
// there is no FLAG_* for "chat model that hears audio", which is exactly why a
// plain usecase list can't express it. Transcription models are handled
// separately in InputModalities via FLAG_TRANSCRIPT.
func (c *ModelConfig) AudioInputSupported() bool {
return c.LimitMMPerPrompt.LimitAudioPerPrompt > 0
}
// VideoInputSupported reports whether a chat/generation model accepts video as
// input. The signal is the vLLM per-prompt video limit. Note this is distinct
// from FLAG_VIDEO, which denotes video *generation* (diffusers) — an output
// modality, not an input one.
func (c *ModelConfig) VideoInputSupported() bool {
return c.LimitMMPerPrompt.LimitVideoPerPrompt > 0
}
// Capabilities returns the ordered list of capability strings the model
// supports, using the canonical usecase vocabulary (chat, vision, transcript,
// tts, embeddings, image, video, ...) plus the modifier capabilities "tools"
// and "thinking". Vision is resolved via VisionSupported (not HasUsecases) to
// avoid the guess-heuristic false positive.
func (c *ModelConfig) Capabilities() []string {
chat := c.HasUsecases(FLAG_CHAT)
completion := c.HasUsecases(FLAG_COMPLETION)
var caps []string
add := func(cond bool, name string) {
if cond {
caps = append(caps, name)
}
}
add(chat, UsecaseChat)
add(completion, UsecaseCompletion)
add(c.HasUsecases(FLAG_EDIT), UsecaseEdit)
add(c.HasUsecases(FLAG_EMBEDDINGS), UsecaseEmbeddings)
add(c.HasUsecases(FLAG_RERANK), UsecaseRerank)
// Vision is only meaningful as an image-understanding modifier on a chat/
// completion model. Gating on (chat||completion) matches the Ollama surface
// and avoids a false positive when config defaults hydrate a MediaMarker on
// a non-chat model (e.g. a pure ASR/TTS backend).
add((chat || completion) && c.VisionSupported(), UsecaseVision)
// tools/thinking are modifiers on the chat/completion surface.
add((chat || completion) && c.ToolSupported(), "tools")
add((chat || completion) && c.ThinkingSupported(), "thinking")
add(c.HasUsecases(FLAG_TRANSCRIPT), UsecaseTranscript)
add(c.HasUsecases(FLAG_TTS), UsecaseTTS)
add(c.HasUsecases(FLAG_SOUND_GENERATION), UsecaseSoundGeneration)
add(c.HasUsecases(FLAG_IMAGE), UsecaseImage)
add(c.HasUsecases(FLAG_VIDEO), UsecaseVideo)
add(c.HasUsecases(FLAG_VAD), UsecaseVAD)
add(c.HasUsecases(FLAG_DETECTION), UsecaseDetection)
add(c.HasUsecases(FLAG_DEPTH), UsecaseDepth)
add(c.HasUsecases(FLAG_AUDIO_TRANSFORM), UsecaseAudioTransform)
add(c.HasUsecases(FLAG_DIARIZATION), UsecaseDiarization)
add(c.HasUsecases(FLAG_SOUND_CLASSIFICATION), UsecaseSoundClassification)
add(c.HasUsecases(FLAG_REALTIME_AUDIO), UsecaseRealtimeAudio)
add(c.HasUsecases(FLAG_FACE_RECOGNITION), UsecaseFaceRecognition)
add(c.HasUsecases(FLAG_SPEAKER_RECOGNITION), UsecaseSpeakerRecognition)
return caps
}
// InputModalities returns the set of modalities (text, image, audio, video) the
// model accepts as input, ordered text→image→audio→video. This is what an
// attachment router consults to decide whether an image/audio/video file can be
// handed to the active model directly.
func (c *ModelConfig) InputModalities() []string {
imageGen := c.HasUsecases(FLAG_IMAGE)
videoGen := c.HasUsecases(FLAG_VIDEO)
chatish := c.HasUsecases(FLAG_CHAT) || c.HasUsecases(FLAG_COMPLETION)
textIn := chatish || c.HasUsecases(FLAG_EDIT) ||
c.HasUsecases(FLAG_EMBEDDINGS) || c.HasUsecases(FLAG_RERANK) || c.HasUsecases(FLAG_TOKENIZE) ||
c.HasUsecases(FLAG_TTS) || c.HasUsecases(FLAG_SOUND_GENERATION) || imageGen || videoGen
// Image input via a chat model requires vision (gated on chat, like the
// Ollama surface); detection/depth/face models consume images directly.
imageIn := (chatish && c.VisionSupported()) || c.LimitMMPerPrompt.LimitImagePerPrompt > 0 ||
c.HasUsecases(FLAG_DETECTION) || c.HasUsecases(FLAG_DEPTH) || c.HasUsecases(FLAG_FACE_RECOGNITION)
audioIn := c.AudioInputSupported() || c.HasUsecases(FLAG_TRANSCRIPT) || c.HasUsecases(FLAG_AUDIO_TRANSFORM) ||
c.HasUsecases(FLAG_REALTIME_AUDIO) || c.HasUsecases(FLAG_VAD) || c.HasUsecases(FLAG_DIARIZATION) ||
c.HasUsecases(FLAG_SOUND_CLASSIFICATION) || c.HasUsecases(FLAG_SPEAKER_RECOGNITION)
videoIn := c.VideoInputSupported()
var mods []string
if textIn {
mods = append(mods, "text")
}
if imageIn {
mods = append(mods, "image")
}
if audioIn {
mods = append(mods, "audio")
}
if videoIn {
mods = append(mods, "video")
}
return mods
}
// OutputModalities returns the set of modalities (text, image, audio, video)
// the model produces, ordered text→image→audio→video.
func (c *ModelConfig) OutputModalities() []string {
textOut := c.HasUsecases(FLAG_CHAT) || c.HasUsecases(FLAG_COMPLETION) || c.HasUsecases(FLAG_EDIT) ||
c.HasUsecases(FLAG_TRANSCRIPT)
imageOut := c.HasUsecases(FLAG_IMAGE)
audioOut := c.HasUsecases(FLAG_TTS) || c.HasUsecases(FLAG_SOUND_GENERATION) ||
c.HasUsecases(FLAG_AUDIO_TRANSFORM) || c.HasUsecases(FLAG_REALTIME_AUDIO)
videoOut := c.HasUsecases(FLAG_VIDEO)
var mods []string
if textOut {
mods = append(mods, "text")
}
if imageOut {
mods = append(mods, "image")
}
if audioOut {
mods = append(mods, "audio")
}
if videoOut {
mods = append(mods, "video")
}
return mods
}

View File

@@ -0,0 +1,103 @@
package config
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func usecaseBits(flags ModelConfigUsecase) *ModelConfigUsecase {
return &flags
}
var _ = Describe("Model capabilities derivation", func() {
Describe("VisionSupported", func() {
It("is false for a plain text chat model", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT), Backend: "llama.cpp"}
Expect(cfg.VisionSupported()).To(BeFalse())
})
It("is true when the FLAG_VISION bit is declared", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT | FLAG_VISION), Backend: "llama.cpp"}
Expect(cfg.VisionSupported()).To(BeTrue())
})
It("is true when an mmproj projector is set", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT), Backend: "llama.cpp"}
cfg.MMProj = "mmproj.gguf" // promoted field from the embedded options struct
Expect(cfg.VisionSupported()).To(BeTrue())
})
It("does not fall for the GuessUsecases FLAG_VISION false positive", func() {
// A chat model with a chat template would make HasUsecases(FLAG_VISION)
// return true via the guess heuristic; VisionSupported must not.
cfg := &ModelConfig{Backend: "llama.cpp"}
cfg.TemplateConfig.Chat = "{{.Input}}"
Expect(cfg.VisionSupported()).To(BeFalse())
})
})
Describe("AudioInputSupported / VideoInputSupported", func() {
It("detects vLLM omni audio input via limit_mm_per_prompt", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT), Backend: "vllm"}
cfg.LimitMMPerPrompt.LimitAudioPerPrompt = 1
Expect(cfg.AudioInputSupported()).To(BeTrue())
Expect(cfg.VideoInputSupported()).To(BeFalse())
})
It("detects vLLM omni video input via limit_mm_per_prompt", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT), Backend: "vllm"}
cfg.LimitMMPerPrompt.LimitVideoPerPrompt = 2
Expect(cfg.VideoInputSupported()).To(BeTrue())
})
})
Describe("Capabilities + modalities", func() {
It("a text-only chat model exposes chat and text-only modalities", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT), Backend: "llama.cpp"}
Expect(cfg.Capabilities()).To(ContainElement(UsecaseChat))
Expect(cfg.Capabilities()).NotTo(ContainElement(UsecaseVision))
Expect(cfg.Capabilities()).NotTo(ContainElement(UsecaseTranscript))
Expect(cfg.InputModalities()).To(Equal([]string{"text"}))
Expect(cfg.OutputModalities()).To(Equal([]string{"text"}))
})
It("a vision chat model accepts text+image input", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT | FLAG_VISION), Backend: "llama.cpp"}
Expect(cfg.Capabilities()).To(ContainElements(UsecaseChat, UsecaseVision))
Expect(cfg.InputModalities()).To(Equal([]string{"text", "image"}))
Expect(cfg.OutputModalities()).To(Equal([]string{"text"}))
})
It("an omni chat model accepts text+audio input without an audio capability flag", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_CHAT), Backend: "vllm"}
cfg.LimitMMPerPrompt.LimitAudioPerPrompt = 1
// audio-in is a modality, not a usecase string — this is exactly the
// case a plain capability list cannot express.
Expect(cfg.Capabilities()).To(ContainElement(UsecaseChat))
Expect(cfg.InputModalities()).To(Equal([]string{"text", "audio"}))
})
It("a transcription model reads audio and writes text", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_TRANSCRIPT), Backend: "parakeet-cpp"}
Expect(cfg.Capabilities()).To(Equal([]string{UsecaseTranscript}))
Expect(cfg.InputModalities()).To(Equal([]string{"audio"}))
Expect(cfg.OutputModalities()).To(Equal([]string{"text"}))
})
It("an image-generation model reads text and writes an image", func() {
// stablediffusion-ggml is image-only; plain "stablediffusion" is also
// in GuessUsecases' video-backend list, so it would report video too.
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_IMAGE), Backend: "stablediffusion-ggml"}
Expect(cfg.Capabilities()).To(Equal([]string{UsecaseImage}))
Expect(cfg.InputModalities()).To(Equal([]string{"text"}))
Expect(cfg.OutputModalities()).To(Equal([]string{"image"}))
})
It("a TTS model reads text and writes audio", func() {
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_TTS), Backend: "piper"}
Expect(cfg.Capabilities()).To(ContainElement(UsecaseTTS))
Expect(cfg.InputModalities()).To(Equal([]string{"text"}))
Expect(cfg.OutputModalities()).To(Equal([]string{"audio"}))
})
})
})

View File

@@ -656,6 +656,18 @@ type Pipeline struct {
// to benefit. A client session.update still overrides type and eagerness
// per session; retranscribe is server-side only. Unset keeps server_vad.
TurnDetection PipelineTurnDetection `yaml:"turn_detection,omitempty" json:"turn_detection,omitempty"`
// DisableWarmup turns off eager pre-loading of the pipeline's sub-models at
// realtime session start. By default (false) LocalAI loads every configured
// sub-model backend (VAD, transcription, LLM, TTS, sound detection, voice
// recognition) into memory (concurrently) before the
// session is announced and blocks until they are ready, so the first turn
// pays no cold-start cost and a model that fails to load surfaces as an error
// at session start rather than mid-call. Set true to restore the lazy "load
// on first use" behavior — session start no longer blocks on loading and
// load errors surface on first use instead (e.g. to keep idle sessions from
// holding model memory they may never use).
DisableWarmup bool `yaml:"disable_warmup,omitempty" json:"disable_warmup,omitempty"`
}
// PipelineCompaction configures summarize-then-drop for a realtime pipeline.

View File

@@ -155,6 +155,25 @@ func (bcl *ModelConfigLoader) LoadModelConfigFileByNameDefaultOptions(modelName
ModelPath(appConfig.SystemState.Model.ModelsPath))
}
// LoadResolvedModelConfig loads a model config by name and follows a single
// alias hop, so a caller that references an alias (e.g. a pipeline with
// `llm: default`) gets the alias target's full config (Backend, Model, ...)
// rather than the alias stub with an empty Backend. Without this the alias
// survives unresolved into model loading and fails downstream — notably in
// distributed mode with "backend name is empty". Mirrors the top-level alias
// resolution in core/http/middleware/request.go.
func (bcl *ModelConfigLoader) LoadResolvedModelConfig(modelName, modelPath string) (*ModelConfig, error) {
cfg, err := bcl.LoadModelConfigFileByName(modelName, modelPath)
if err != nil {
return nil, err
}
resolved, _, err := bcl.ResolveAlias(cfg)
if err != nil {
return nil, err
}
return resolved, nil
}
// This format is currently only used when reading a single file at startup, passed in via ApplicationConfig.ConfigFile
func (bcl *ModelConfigLoader) LoadMultipleModelConfigsSingleFile(file string, opts ...ConfigLoaderOption) error {
bcl.Lock()

View File

@@ -1,4 +1,4 @@
package openai
package config_test
import (
"os"
@@ -10,14 +10,14 @@ import (
"github.com/mudler/LocalAI/core/config"
)
// loadPipelineSubModel must resolve a pipeline sub-model that references an
// alias (e.g. `llm: default`) one hop to the alias target's full config — so
// the effective backend is the target's backend, not the empty backend of the
// alias stub. This mirrors the top-level alias resolution done in
// core/http/middleware/request.go, which the realtime pipeline previously
// LoadResolvedModelConfig must resolve a model that references an alias
// (e.g. a pipeline with `llm: default`) one hop to the alias target's full
// config — so the effective backend is the target's backend, not the empty
// backend of the alias stub. This mirrors the top-level alias resolution done
// in core/http/middleware/request.go, which the realtime pipeline previously
// skipped (failing in distributed mode with "backend name is empty").
var _ = Describe("loadPipelineSubModel", func() {
It("resolves a sub-model alias one hop to the target's config", func() {
var _ = Describe("LoadResolvedModelConfig", func() {
It("resolves an alias one hop to the target's config", func() {
tmpDir := GinkgoT().TempDir()
// A real model config with a concrete backend.
@@ -38,13 +38,13 @@ alias: real-llm
Expect(cl.LoadModelConfigsFromPath(tmpDir)).To(Succeed())
// Resolving the alias must follow the hop to the target's full config.
resolved, err := loadPipelineSubModel(cl, "default", tmpDir)
resolved, err := cl.LoadResolvedModelConfig("default", tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(resolved.IsAlias()).To(BeFalse())
Expect(resolved.Backend).To(Equal("llama-cpp"))
// A non-alias name must load unchanged.
direct, err := loadPipelineSubModel(cl, "real-llm", tmpDir)
direct, err := cl.LoadResolvedModelConfig("real-llm", tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(direct.Backend).To(Equal("llama-cpp"))
Expect(direct.Name).To(Equal("real-llm"))

View File

@@ -1,56 +0,0 @@
package config
import (
gguf "github.com/gpustack/gguf-parser-go"
"github.com/mudler/xlog"
)
// swaCacheOptionNames lists the backend option keys that control the
// sliding-window-attention KV cache. If the user pinned any of these we leave
// the SWA cache alone instead of forcing swa_full.
var swaCacheOptionNames = []string{"swa_full", "n_swa"}
// HasSlidingWindowAttention reports whether the parsed GGUF describes a
// sliding-window-attention (SWA) model — Gemma 2/3, Cohere2, Llama 4 and the
// like. The gguf-parser library normalizes the per-architecture
// `<arch>.attention.sliding_window` metadata key into
// GGUFArchitecture.AttentionSlidingWindow, applying the same family-specific
// rules llama.cpp uses (e.g. Phi-3 carries the key but does not actually run
// SWA, and is normalized to 0). A non-zero window means the model interleaves
// SWA layers, so the returned size is also the diagnostic value we log.
func HasSlidingWindowAttention(f *gguf.GGUFFile) (uint64, bool) {
if f == nil {
return 0, false
}
w := f.Architecture().AttentionSlidingWindow
return w, w > 0
}
// ApplySWAFullDefault enables the full-size SWA KV cache (swa_full:true) for a
// sliding-window model, unless the user already pinned an SWA cache option.
//
// Why: llama.cpp defaults to a reduced SWA KV cache sized to the sliding window
// (memory-light), but that reduced cache cannot preserve a prompt prefix across
// requests. So for SWA models the cross-request prefix cache we enable in
// serving_defaults.go (cache_reuse) is silently defeated — every turn
// reprocesses the entire prompt. Setting swa_full:true makes llama.cpp keep the
// full KV cache so the shared prefix is actually reused.
//
// The tradeoff is memory: the full SWA cache scales with context_size, so this
// is gated to models that are genuinely SWA (never applied to dense models,
// where it would only waste memory) and never overrides an explicit user
// choice. `slidingWindow` is the value read from the GGUF and is used only for
// the diagnostic log line.
func ApplySWAFullDefault(cfg *ModelConfig, slidingWindow uint64) {
if cfg == nil || slidingWindow == 0 {
return
}
if backendOptionSet(cfg.Options, swaCacheOptionNames...) {
xlog.Debug("[swa] sliding-window model but an SWA cache option is already set; leaving user choice intact",
"name", cfg.Name, "sliding_window", slidingWindow)
return
}
cfg.Options = append(cfg.Options, "swa_full:true")
xlog.Debug("[swa] enabling swa_full for sliding-window model so the cross-request prompt-prefix cache survives (reduced SWA cache cannot reuse a prefix across requests)",
"name", cfg.Name, "sliding_window", slidingWindow)
}

View File

@@ -1,120 +0,0 @@
package config_test
import (
. "github.com/mudler/LocalAI/core/config"
gguf "github.com/gpustack/gguf-parser-go"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// ggufWithSlidingWindow fabricates a minimal in-memory GGUF carrying the given
// `general.architecture` and `<arch>.attention.sliding_window` so the SWA
// detection can be exercised without a real model file. A window of 0 omits the
// key, modelling a dense (non-SWA) model.
func ggufWithSlidingWindow(arch string, window uint32) *gguf.GGUFFile {
kvs := gguf.GGUFMetadataKVs{
{
Key: "general.architecture",
ValueType: gguf.GGUFMetadataValueTypeString,
Value: arch,
},
}
if window > 0 {
kvs = append(kvs, gguf.GGUFMetadataKV{
Key: arch + ".attention.sliding_window",
ValueType: gguf.GGUFMetadataValueTypeUint32,
Value: window,
})
}
return &gguf.GGUFFile{
Header: gguf.GGUFHeader{MetadataKV: kvs},
}
}
var _ = Describe("SWA full-cache auto-default", func() {
Context("HasSlidingWindowAttention", func() {
It("returns false on a nil GGUF file", func() {
w, ok := HasSlidingWindowAttention(nil)
Expect(ok).To(BeFalse())
Expect(w).To(BeZero())
})
It("detects a sliding-window model (Gemma 3 style)", func() {
w, ok := HasSlidingWindowAttention(ggufWithSlidingWindow("gemma3", 1024))
Expect(ok).To(BeTrue())
Expect(w).To(Equal(uint64(1024)))
})
It("detects Gemma 2 even without an explicit key (family default window)", func() {
// gguf-parser applies llama.cpp's family rules: gemma2 defaults the
// sliding window to 4096 when the metadata key is absent.
w, ok := HasSlidingWindowAttention(ggufWithSlidingWindow("gemma2", 0))
Expect(ok).To(BeTrue())
Expect(w).To(Equal(uint64(4096)))
})
It("reports a dense model as non-SWA", func() {
w, ok := HasSlidingWindowAttention(ggufWithSlidingWindow("llama", 0))
Expect(ok).To(BeFalse())
Expect(w).To(BeZero())
})
It("treats Phi-3 as non-SWA even when the key is present", func() {
// Phi-3 carries attention.sliding_window but does not actually run
// SWA; gguf-parser normalizes it to 0 to match llama.cpp.
w, ok := HasSlidingWindowAttention(ggufWithSlidingWindow("phi3", 2048))
Expect(ok).To(BeFalse())
Expect(w).To(BeZero())
})
})
Context("ApplySWAFullDefault", func() {
It("enables swa_full for a sliding-window model when unset", func() {
cfg := &ModelConfig{Name: "gemma3"}
ApplySWAFullDefault(cfg, 1024)
Expect(cfg.Options).To(ContainElement("swa_full:true"))
})
It("is a no-op for a dense model (window 0)", func() {
cfg := &ModelConfig{Name: "llama"}
ApplySWAFullDefault(cfg, 0)
Expect(cfg.Options).To(BeEmpty())
})
It("preserves an explicit swa_full:false", func() {
cfg := &ModelConfig{Name: "gemma3", Options: []string{"swa_full:false"}}
ApplySWAFullDefault(cfg, 1024)
Expect(cfg.Options).To(Equal([]string{"swa_full:false"}))
})
It("preserves an explicit swa_full:true without duplicating it", func() {
cfg := &ModelConfig{Name: "gemma3", Options: []string{"swa_full:true"}}
ApplySWAFullDefault(cfg, 1024)
Expect(cfg.Options).To(Equal([]string{"swa_full:true"}))
})
It("respects the n_swa alias", func() {
cfg := &ModelConfig{Name: "gemma3", Options: []string{"n_swa:512"}}
ApplySWAFullDefault(cfg, 1024)
Expect(cfg.Options).To(Equal([]string{"n_swa:512"}))
})
It("preserves unrelated options already on the config", func() {
cfg := &ModelConfig{
Name: "gemma3",
Options: []string{"use_jinja:true", "cache_reuse:256"},
}
ApplySWAFullDefault(cfg, 1024)
Expect(cfg.Options).To(Equal([]string{
"use_jinja:true",
"cache_reuse:256",
"swa_full:true",
}))
})
It("tolerates a nil config", func() {
Expect(func() { ApplySWAFullDefault(nil, 1024) }).ToNot(Panic())
})
})
})

View File

@@ -15,14 +15,35 @@ import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/LocalAI/pkg/xsync"
"github.com/mudler/xlog"
"gopkg.in/yaml.v3"
)
// validateGalleryConfigURL guards the gallery config fetch against SSRF. A
// gallery config URL can be attacker-controlled (e.g. POST /models/apply with
// an empty id fetches it directly), so a plain http(s) URL must not be allowed
// to reach private, loopback, link-local or cloud-metadata addresses. Other
// schemes (huggingface://, github:, oci://, ollama://, file://) resolve to
// fixed public services or local files and are not a network-SSRF vector, so
// they are left untouched.
// See https://github.com/mudler/LocalAI/issues/10665
func validateGalleryConfigURL(rawURL string) error {
lower := strings.ToLower(strings.TrimSpace(rawURL))
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
return utils.ValidateExternalURL(rawURL)
}
return nil
}
func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) {
var config T
if err := validateGalleryConfigURL(url); err != nil {
xlog.Error("refusing to fetch gallery config", "error", err, "url", url)
return config, err
}
uri := downloader.URI(url)
err := uri.ReadWithCallback(basePath, func(url string, d []byte) error {
return yaml.Unmarshal(d, &config)
@@ -36,6 +57,10 @@ func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) {
func GetGalleryConfigFromURLWithContext[T any](ctx context.Context, url string, basePath string) (T, error) {
var config T
if err := validateGalleryConfigURL(url); err != nil {
xlog.Error("refusing to fetch gallery config", "error", err, "url", url)
return config, err
}
uri := downloader.URI(url)
err := uri.ReadWithAuthorizationAndCallback(ctx, basePath, "", func(url string, d []byte) error {
return yaml.Unmarshal(d, &config)

View File

@@ -1,6 +1,10 @@
package gallery_test
import (
"context"
"net/http"
"net/http/httptest"
. "github.com/mudler/LocalAI/core/gallery"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -19,4 +23,49 @@ var _ = Describe("Gallery API tests", func() {
Expect(e.Name).To(Equal("gpt4all-j"))
})
})
// SSRF guard: a user-supplied gallery config URL (e.g. POST /models/apply
// with an empty id) must not be able to reach internal network addresses.
// See https://github.com/mudler/LocalAI/issues/10665
Context("SSRF protection on config URLs", func() {
var server *httptest.Server
BeforeEach(func() {
// A reachable internal server that would happily serve a valid
// gallery config. Without the SSRF guard the fetch succeeds; the
// guard must block it before the request ever leaves the process.
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("name: internal-ssrf\nfiles: []\n"))
}))
})
AfterEach(func() {
server.Close()
})
It("blocks fetching a config from a loopback address", func() {
_, err := GetGalleryConfigFromURL[ModelConfig](server.URL, "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not allowed"))
})
It("blocks fetching a config from a loopback address (context variant)", func() {
_, err := GetGalleryConfigFromURLWithContext[ModelConfig](context.Background(), server.URL, "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not allowed"))
})
It("blocks well-known internal hostnames and metadata endpoints", func() {
for _, u := range []string{
"http://localhost/secret",
"http://10.0.0.1/config.yaml",
"http://192.168.1.1/config.yaml",
"http://169.254.169.254/latest/meta-data/",
} {
_, err := GetGalleryConfigFromURL[ModelConfig](u, "")
Expect(err).To(HaveOccurred(), "expected %s to be rejected", u)
}
})
})
})

View File

@@ -202,6 +202,11 @@ func (m *OAuthManager) CallbackHandler(providerName string, db *gorm.DB, adminEm
userInfo, err = fetchGitHubUserInfoAsOAuth(ctx, token.AccessToken)
}
if err != nil {
// Surface the real cause server-side: ID-token verify failures (issuer/
// audience mismatch behind a reverse proxy), a missing id_token, claim
// parse errors, or the GitHub userinfo HTTP status/body. The client still
// gets the generic message below; details go to logs only. See #10677.
xlog.Error("OAuth callback: failed to resolve user info", "provider", providerName, "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch user info"})
}

View File

@@ -0,0 +1,54 @@
package localai
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// LoadModelEndpoint pre-loads a model into memory by name — the inverse of
// /backend/shutdown. For a realtime pipeline model every configured sub-model
// (VAD, transcription, LLM, TTS, sound_detection, voice_recognition) is loaded; for a regular
// model its own backend is loaded. The call blocks until loading finishes so
// clients can drive warm-up explicitly and learn up front whether a model
// fails to load.
// @Summary Pre-load a model into memory
// @Description Loads the named model (or, for a realtime pipeline, all of its sub-models) into memory so subsequent requests pay no cold-start cost. The inverse of /backend/shutdown.
// @Tags monitoring
// @Accept json
// @Produce json
// @Param request body schema.ModelLoadRequest true "Model to load"
// @Success 200 {object} schema.ModelLoadResponse "Model loaded"
// @Failure 400 {object} schema.ModelLoadResponse "Missing model name"
// @Failure 500 {object} schema.ModelLoadResponse "Load failed (Loaded lists any sub-models that did load)"
// @Router /backend/load [post]
func LoadModelEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
input := new(schema.ModelLoadRequest)
if err := c.Bind(input); err != nil {
return err
}
if input.Model == "" {
return c.JSON(http.StatusBadRequest, schema.ModelLoadResponse{Message: "model is required"})
}
loaded, err := backend.PreloadModelByName(c.Request().Context(), cl, ml, appConfig, input.Model)
if err != nil {
xlog.Error("failed to pre-load model", "model", input.Model, "loaded", loaded, "error", err)
return c.JSON(http.StatusInternalServerError, schema.ModelLoadResponse{
Loaded: loaded,
Message: "failed to load model: " + err.Error(),
})
}
return c.JSON(http.StatusOK, schema.ModelLoadResponse{
Loaded: loaded,
Message: "model loaded",
})
}
}

View File

@@ -0,0 +1,102 @@
package localai_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LoadModelEndpoint (/backend/load)", func() {
var (
app *echo.Echo
tempDir string
configLoader *config.ModelConfigLoader
modelLoader *model.ModelLoader
appConfig *config.ApplicationConfig
)
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/backend/load", bytes.NewBufferString(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
return rec
}
decode := func(rec *httptest.ResponseRecorder) schema.ModelLoadResponse {
var resp schema.ModelLoadResponse
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
return resp
}
writeConfig := func(name, contents string) {
Expect(os.WriteFile(filepath.Join(tempDir, name+".yaml"), []byte(contents), 0o600)).To(Succeed())
}
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "backend-load-test-*")
Expect(err).NotTo(HaveOccurred())
systemState, err := system.GetSystemState(system.WithModelPath(tempDir))
Expect(err).NotTo(HaveOccurred())
appConfig = config.NewApplicationConfig(config.WithSystemState(systemState))
configLoader = config.NewModelConfigLoader(tempDir)
modelLoader = model.NewModelLoader(systemState) // no backends installed
app = echo.New()
app.POST("/backend/load", LoadModelEndpoint(configLoader, modelLoader, appConfig))
})
AfterEach(func() {
_ = os.RemoveAll(tempDir)
})
It("rejects a request with no model name", func() {
rec := post(`{}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(decode(rec).Message).To(ContainSubstring("model is required"))
})
It("reports a load failure for a regular model with nothing loaded", func() {
writeConfig("solo", "name: solo\n")
rec := post(`{"model":"solo"}`)
Expect(rec.Code).To(Equal(http.StatusInternalServerError))
resp := decode(rec)
Expect(resp.Loaded).To(BeEmpty())
Expect(resp.Message).To(ContainSubstring("failed to load model"))
})
It("expands a pipeline model and reports each sub-model that failed to load", func() {
writeConfig("voicebot", "name: voicebot\npipeline:\n vad: vad-m\n transcription: stt-m\n llm: llm-m\n tts: tts-m\n")
writeConfig("vad-m", "name: vad-m\n")
writeConfig("stt-m", "name: stt-m\n")
writeConfig("llm-m", "name: llm-m\n")
writeConfig("tts-m", "name: tts-m\n")
rec := post(`{"model":"voicebot"}`)
Expect(rec.Code).To(Equal(http.StatusInternalServerError))
resp := decode(rec)
Expect(resp.Message).To(ContainSubstring("failed to load model"))
// The pipeline stub itself is never loaded; its sub-models are what the
// endpoint tries, so the error names them rather than "voicebot".
Expect(resp.Message).To(ContainSubstring("vad-m"))
Expect(resp.Message).ToNot(ContainSubstring("voicebot"))
})
})

View File

@@ -51,6 +51,9 @@ func (stubClient) EditModelConfig(_ context.Context, _ string, _ map[string]any)
return nil
}
func (stubClient) ReloadModels(_ context.Context) error { return nil }
func (stubClient) LoadModel(_ context.Context, model string) ([]string, error) {
return []string{model}, nil
}
func (stubClient) SetAlias(_ context.Context, _, _ string) error {
return nil
}

View File

@@ -49,62 +49,23 @@ func modelCapabilities(cfg *config.ModelConfig) []string {
return caps
}
// hasVisionSupport reports whether the model can accept image inputs. We avoid
// cfg.HasUsecases(FLAG_VISION) because GuessUsecases has no FLAG_VISION case
// and returns true for any chat model — see core/config/model_config.go. Instead
// we look for explicit signals: KnownUsecases bit, multimodal projector, or
// template/backend-reported multimodal markers.
// hasVisionSupport reports whether the model can accept image inputs.
// The detection heuristic is the canonical config.ModelConfig.VisionSupported —
// kept as a thin wrapper here so the Ollama capability mapping reads cleanly.
func hasVisionSupport(cfg *config.ModelConfig) bool {
if cfg.KnownUsecases != nil && (*cfg.KnownUsecases&config.FLAG_VISION) == config.FLAG_VISION {
return true
}
if cfg.MMProj != "" {
return true
}
if cfg.TemplateConfig.Multimodal != "" {
return true
}
if cfg.MediaMarker != "" {
return true
}
return false
return cfg.VisionSupported()
}
// hasToolSupport reports whether the model is wired up for tool / function calling.
// We look for any of the explicit configuration knobs LocalAI uses to drive
// function-call extraction (regex match, response regex, grammar triggers, XML
// format) or for the auto-detected tool-format markers populated by the
// llama.cpp backend during model load.
// hasToolSupport reports whether the model is wired up for tool / function
// calling. Delegates to the canonical config.ModelConfig.ToolSupported.
func hasToolSupport(cfg *config.ModelConfig) bool {
fc := cfg.FunctionsConfig
if fc.ToolFormatMarkers != nil && fc.ToolFormatMarkers.FormatType != "" {
return true
}
if len(fc.JSONRegexMatch) > 0 || len(fc.ResponseRegex) > 0 {
return true
}
if fc.XMLFormatPreset != "" || fc.XMLFormat != nil {
return true
}
if len(fc.GrammarConfig.GrammarTriggers) > 0 || fc.GrammarConfig.SchemaType != "" {
return true
}
return false
return cfg.ToolSupported()
}
// hasThinkingSupport reports whether the model has reasoning / thinking enabled.
// LocalAI sets DisableReasoning=false (or leaves thinking markers configured)
// when the backend probe reports that the model supports thinking.
// Delegates to the canonical config.ModelConfig.ThinkingSupported.
func hasThinkingSupport(cfg *config.ModelConfig) bool {
rc := cfg.ReasoningConfig
if rc.DisableReasoning != nil && !*rc.DisableReasoning {
return true
}
if len(rc.ThinkingStartTokens) > 0 || len(rc.TagPairs) > 0 {
// Explicit thinking markers imply support unless explicitly disabled.
return rc.DisableReasoning == nil || !*rc.DisableReasoning
}
return false
return cfg.ThinkingSupported()
}
// quantRegex matches GGUF-style quantization suffixes (Q4_K_M, Q8_0, IQ3_XS, F16, ...).

View File

@@ -21,48 +21,11 @@ func ListModelsEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader, ap
authDB = db[0]
}
return func(c echo.Context) error {
// If blank, no filter is applied.
filter := c.QueryParam("filter")
// By default, exclude any loose files that are already referenced by a configuration file.
var policy galleryop.LooseFilePolicy
excludeConfigured := c.QueryParam("excludeConfigured")
if excludeConfigured == "" || excludeConfigured == "true" {
policy = galleryop.SKIP_IF_CONFIGURED
} else {
policy = galleryop.ALWAYS_INCLUDE // This replicates current behavior. TODO: give more options to the user?
}
filterFn, err := config.BuildNameFilterFn(filter)
modelNames, err := listVisibleModelNames(c, bcl, ml, authDB)
if err != nil {
return err
}
modelNames, err := galleryop.ListModels(bcl, ml, filterFn, policy)
if err != nil {
return err
}
// Filter models by user's allowlist if auth is enabled
if authDB != nil {
if user := auth.GetUser(c); user != nil && user.Role != auth.RoleAdmin {
perm, err := auth.GetCachedUserPermissions(c, authDB, user.ID)
if err == nil && perm.AllowedModels.Enabled {
allowed := map[string]bool{}
for _, m := range perm.AllowedModels.Models {
allowed[m] = true
}
filtered := make([]string, 0, len(modelNames))
for _, m := range modelNames {
if allowed[m] {
filtered = append(filtered, m)
}
}
modelNames = filtered
}
}
}
// Map from a slice of names to a slice of OpenAIModel response objects
dataModels := []schema.OpenAIModel{}
for _, m := range modelNames {
@@ -75,3 +38,53 @@ func ListModelsEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader, ap
})
}
}
// listVisibleModelNames resolves the model names visible to the caller, applying
// the same query filters (filter, excludeConfigured) and per-user allowlist as
// the OpenAI models listing. Shared by ListModelsEndpoint and
// ListModelCapabilitiesEndpoint so both stay consistent.
func listVisibleModelNames(c echo.Context, bcl *config.ModelConfigLoader, ml *model.ModelLoader, authDB *gorm.DB) ([]string, error) {
// If blank, no filter is applied.
filter := c.QueryParam("filter")
// By default, exclude any loose files that are already referenced by a configuration file.
var policy galleryop.LooseFilePolicy
excludeConfigured := c.QueryParam("excludeConfigured")
if excludeConfigured == "" || excludeConfigured == "true" {
policy = galleryop.SKIP_IF_CONFIGURED
} else {
policy = galleryop.ALWAYS_INCLUDE // This replicates current behavior. TODO: give more options to the user?
}
filterFn, err := config.BuildNameFilterFn(filter)
if err != nil {
return nil, err
}
modelNames, err := galleryop.ListModels(bcl, ml, filterFn, policy)
if err != nil {
return nil, err
}
// Filter models by user's allowlist if auth is enabled
if authDB != nil {
if user := auth.GetUser(c); user != nil && user.Role != auth.RoleAdmin {
perm, err := auth.GetCachedUserPermissions(c, authDB, user.ID)
if err == nil && perm.AllowedModels.Enabled {
allowed := map[string]bool{}
for _, m := range perm.AllowedModels.Models {
allowed[m] = true
}
filtered := make([]string, 0, len(modelNames))
for _, m := range modelNames {
if allowed[m] {
filtered = append(filtered, m)
}
}
modelNames = filtered
}
}
}
return modelNames, nil
}

View File

@@ -0,0 +1,50 @@
package openai
import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
model "github.com/mudler/LocalAI/pkg/model"
"gorm.io/gorm"
)
// ListModelCapabilitiesEndpoint is a LocalAI-specific extension of the OpenAI
// models listing. It returns the same set of models as /v1/models but enriches
// each entry with the capabilities and input/output modalities the model
// supports, so clients can decide whether an image/audio/video attachment can be
// handed to a given model directly (or must be converted/transcribed first).
//
// It is purely additive: clients that don't know about it keep using /v1/models
// and see no change.
// @Summary List available models enriched with capabilities and input/output modalities.
// @Tags models
// @Success 200 {object} schema.ModelCapabilitiesResponse "Response"
// @Router /v1/models/capabilities [get]
func ListModelCapabilitiesEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, db ...*gorm.DB) echo.HandlerFunc {
var authDB *gorm.DB
if len(db) > 0 {
authDB = db[0]
}
return func(c echo.Context) error {
modelNames, err := listVisibleModelNames(c, bcl, ml, authDB)
if err != nil {
return err
}
dataModels := []schema.ModelCapabilities{}
for _, m := range modelNames {
entry := schema.ModelCapabilities{ID: m, Object: "model"}
if cfg, ok := bcl.GetModelConfig(m); ok {
entry.Capabilities = cfg.Capabilities()
entry.InputModalities = cfg.InputModalities()
entry.OutputModalities = cfg.OutputModalities()
}
dataModels = append(dataModels, entry)
}
return c.JSON(200, schema.ModelCapabilitiesResponse{
Object: "list",
Data: dataModels,
})
}
}

View File

@@ -0,0 +1,119 @@
package openai
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ListModelCapabilitiesEndpoint", func() {
var (
e *echo.Echo
tmpDir string
bcl *config.ModelConfigLoader
ml *model.ModelLoader
appConf *config.ApplicationConfig
)
BeforeEach(func() {
var err error
e = echo.New()
tmpDir, err = os.MkdirTemp("", "models-caps-test-*")
Expect(err).NotTo(HaveOccurred())
st, err := system.GetSystemState(system.WithModelPath(tmpDir))
Expect(err).NotTo(HaveOccurred())
ml = model.NewModelLoader(st)
bcl = config.NewModelConfigLoader(tmpDir)
appConf = config.NewApplicationConfig()
})
AfterEach(func() {
_ = os.RemoveAll(tmpDir)
})
writeConfig := func(name, yaml string) {
path := filepath.Join(tmpDir, name+".yaml")
Expect(os.WriteFile(path, []byte(yaml), 0o644)).To(Succeed())
Expect(bcl.ReadModelConfig(path)).To(Succeed())
}
// call exercises the endpoint with auth disabled (no auth DB), which is the
// standard deployment path. The per-user allowlist branch is shared verbatim
// with ListModelsEndpoint (listVisibleModelNames) and covered there.
call := func() schema.ModelCapabilitiesResponse {
req := httptest.NewRequest(http.MethodGet, "/v1/models/capabilities", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := ListModelCapabilitiesEndpoint(bcl, ml, appConf)
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
var resp schema.ModelCapabilitiesResponse
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
return resp
}
entryFor := func(resp schema.ModelCapabilitiesResponse, id string) *schema.ModelCapabilities {
for i := range resp.Data {
if resp.Data[i].ID == id {
return &resp.Data[i]
}
}
return nil
}
It("returns the list envelope even with no models", func() {
resp := call()
Expect(resp.Object).To(Equal("list"))
})
It("enriches a vision chat model with capabilities and image input modality", func() {
writeConfig("vlm", `
name: vlm
backend: llama-cpp
known_usecases:
- FLAG_CHAT
- FLAG_VISION
template:
chat: "{{ .Input }}"
parameters:
model: qwen2.5-vl-Q4_K_M.gguf
`)
entry := entryFor(call(), "vlm")
Expect(entry).NotTo(BeNil())
Expect(entry.Object).To(Equal("model"))
Expect(entry.Capabilities).To(ContainElements("chat", "vision"))
Expect(entry.InputModalities).To(ContainElements("text", "image"))
Expect(entry.OutputModalities).To(ContainElement("text"))
})
It("marks a parakeet model as an audio-in/text-out transcription model", func() {
writeConfig("parakeet", `
name: parakeet
backend: parakeet-cpp
known_usecases:
- FLAG_TRANSCRIPT
parameters:
model: parakeet-tdt-0.6b
`)
entry := entryFor(call(), "parakeet")
Expect(entry).NotTo(BeNil())
Expect(entry.Capabilities).To(ContainElement("transcript"))
Expect(entry.InputModalities).To(Equal([]string{"audio"}))
Expect(entry.OutputModalities).To(Equal([]string{"text"}))
Expect(entry.Capabilities).NotTo(ContainElement("chat"))
})
})

View File

@@ -7,6 +7,7 @@ import (
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"os"
@@ -266,6 +267,12 @@ type Model interface {
// grpcerrors.IsLiveTranscriptionUnsupported.
TranscribeLive(ctx context.Context, language string, onEvent func(backend.LiveTranscriptionEvent)) (backend.LiveTranscriptionSession, error)
PredictConfig() *config.ModelConfig
// Warmup eagerly loads the pipeline's sub-model backends into memory so the
// first realtime turn doesn't pay each backend's cold-start load cost. Loads
// run concurrently; Warmup blocks until they all finish and returns a joined
// error naming every stage that failed to load (nil if all succeeded), so a
// caller can surface model-load failures at session start instead of mid-call.
Warmup(ctx context.Context) error
}
var upgrader = websocket.Upgrader{
@@ -583,18 +590,8 @@ func runRealtimeSession(application *application.Application, t Transport, model
}
session.ModelInterface = m
if session.SummaryModel != "" {
summaryModelName := session.SummaryModel
sid := sessionID
session.summarizerFactory = func() (Model, error) {
summaryCfg, lerr := application.ModelConfigLoader().LoadModelConfigFileByNameDefaultOptions(summaryModelName, application.ApplicationConfig())
if lerr != nil {
return nil, fmt.Errorf("load summary model config %q: %w", summaryModelName, lerr)
}
return newModel(&summaryCfg.Pipeline, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), evaluator, buildRealtimeRoutingContext(application, sid))
}
}
// The voice gate is built before the warm-up below so its
// speaker-recognition model can warm alongside the pipeline stages.
if cfg.Pipeline.VoiceGateEnabled() {
gate, gerr := newVoiceGate(
*cfg.Pipeline.VoiceRecognition,
@@ -612,6 +609,47 @@ func runRealtimeSession(application *application.Application, t Transport, model
xlog.Info("realtime voice recognition gate enabled", "mode", gate.cfg.Mode, "when", gate.cfg.When)
}
// Warm the pipeline's sub-model backends before announcing the session.
// Loads run concurrently but we block here until they all finish, so a model
// that fails to load (missing weights, bad backend, OOM) surfaces as an error
// at session start rather than stalling — or failing — mid-call on the first
// turn (VAD on the first audio chunk, STT at end-of-speech, LLM on the first
// reply, TTS on the first spoken output). On success the backends are already
// resident, so the first turn pays no cold-start cost. Opt out per pipeline
// with `pipeline.disable_warmup: true` to restore lazy load-on-first-use
// (errors then surface on first use instead of at session start).
if !cfg.Pipeline.DisableWarmup {
warmErr := make(chan error, 1)
go func() { warmErr <- m.Warmup(context.Background()) }()
// The voice-gate model warms concurrently with the pipeline stages: an
// enforced gate blocks each utterance on speaker resolution, so its
// cold-start would otherwise land on the first turn too. (Compaction's
// summary_model stays lazy — it only runs off the response path.)
var gateErr error
if session.voiceGate != nil {
_, gateErr = backend.PreloadStages(context.Background(), application.ModelLoader(), application.ApplicationConfig(), []backend.PreloadStage{
{Role: "voice_recognition", Cfg: session.voiceGate.recCfg},
})
}
if err := errors.Join(<-warmErr, gateErr); err != nil {
xlog.Error("realtime warmup failed", "model", model, "error", err)
sendError(t, "model_load_error", "Failed to load pipeline models: "+err.Error(), "", "")
return
}
}
if session.SummaryModel != "" {
summaryModelName := session.SummaryModel
sid := sessionID
session.summarizerFactory = func() (Model, error) {
summaryCfg, lerr := application.ModelConfigLoader().LoadModelConfigFileByNameDefaultOptions(summaryModelName, application.ApplicationConfig())
if lerr != nil {
return nil, fmt.Errorf("load summary model config %q: %w", summaryModelName, lerr)
}
return newModel(&summaryCfg.Pipeline, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), evaluator, buildRealtimeRoutingContext(application, sid))
}
}
// Store the session and notify the transport (for WebRTC audio track handling)
sessionLock.Lock()
sessions[sessionID] = session
@@ -1125,6 +1163,21 @@ func updateSession(session *Session, update *types.SessionUnion, cl *config.Mode
return err
}
session.ModelInterface = m
// A session.update that swaps the model/voice rebuilds the pipeline, so
// warm the new backends too (unless opted out) — otherwise the next turn
// pays the cold-start load the original session warm-up already avoided.
// Unlike session start this stays non-blocking: updateSession runs under
// the global sessionLock, so blocking on a multi-second load here would
// stall every other session. Load errors are logged (and still surface on
// first use); per-stage failures are already warned inside
// backend.PreloadStages.
if !session.ModelConfig.Pipeline.DisableWarmup {
go func() {
if err := m.Warmup(context.Background()); err != nil {
xlog.Error("realtime warmup failed after session.update", "error", err)
}
}()
}
}
if rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.TurnDetectionSet {

View File

@@ -174,6 +174,8 @@ func (m *fakeModel) TranscribeLive(_ context.Context, _ string, onEvent func(bac
func (m *fakeModel) PredictConfig() *config.ModelConfig { return m.cfg }
func (m *fakeModel) Warmup(ctx context.Context) error { return nil }
// fakeLiveSession records what semantic_vad fed and closed; closeEvents are
// replayed through onEvent during Close, mimicking the backend's finalize
// flush (trailing delta + Final) landing before Close returns.

View File

@@ -110,6 +110,15 @@ func (m *transcriptOnlyModel) PredictConfig() *config.ModelConfig {
return nil
}
func (m *transcriptOnlyModel) Warmup(ctx context.Context) error {
_, err := backend.PreloadStages(ctx, m.modelLoader, m.appConfig, []backend.PreloadStage{
{Role: "vad", Cfg: m.VADConfig},
{Role: "transcription", Cfg: m.TranscriptionConfig},
{Role: "sound_detection", Cfg: m.SoundDetectionConfig},
})
return err
}
func (m *wrappedModel) VAD(ctx context.Context, request *schema.VADRequest) (*schema.VADResponse, error) {
return backend.VAD(request, ctx, m.modelLoader, m.appConfig, *m.VADConfig)
}
@@ -360,6 +369,17 @@ func (m *wrappedModel) PredictConfig() *config.ModelConfig {
return m.LLMConfig
}
func (m *wrappedModel) Warmup(ctx context.Context) error {
_, err := backend.PreloadStages(ctx, m.modelLoader, m.appConfig, []backend.PreloadStage{
{Role: "vad", Cfg: m.VADConfig},
{Role: "transcription", Cfg: m.TranscriptionConfig},
{Role: "llm", Cfg: m.LLMConfig},
{Role: "tts", Cfg: m.TTSConfig},
{Role: "sound_detection", Cfg: m.SoundDetectionConfig},
})
return err
}
// wavStreamHeaderBytes is the size of the WAV header that backend.ModelTTSStream
// emits as its first audio callback; the sample rate lives at byte offset 24.
const wavStreamHeaderBytes = 44
@@ -440,7 +460,7 @@ func loadSoundDetectionConfig(pipeline *config.Pipeline, cl *config.ModelConfigL
if pipeline.SoundDetection == "" {
return nil, nil
}
cfg, err := loadPipelineSubModel(cl, pipeline.SoundDetection, ml.ModelPath)
cfg, err := cl.LoadResolvedModelConfig(pipeline.SoundDetection, ml.ModelPath)
if err != nil {
return nil, fmt.Errorf("failed to load sound detection config: %w", err)
}
@@ -451,7 +471,7 @@ func loadSoundDetectionConfig(pipeline *config.Pipeline, cl *config.ModelConfigL
}
func newTranscriptionOnlyModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) (Model, *config.ModelConfig, error) {
cfgVAD, err := loadPipelineSubModel(cl, pipeline.VAD, ml.ModelPath)
cfgVAD, err := cl.LoadResolvedModelConfig(pipeline.VAD, ml.ModelPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to load backend config: %w", err)
@@ -461,7 +481,7 @@ func newTranscriptionOnlyModel(pipeline *config.Pipeline, cl *config.ModelConfig
return nil, nil, fmt.Errorf("failed to validate config: %w", err)
}
cfgSST, err := loadPipelineSubModel(cl, pipeline.Transcription, ml.ModelPath)
cfgSST, err := cl.LoadResolvedModelConfig(pipeline.Transcription, ml.ModelPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to load backend config: %w", err)
@@ -550,30 +570,11 @@ func buildRealtimeRoutingContext(a *application.Application, sessionID string) *
}
}
// loadPipelineSubModel loads a pipeline sub-model config by name and follows a
// single alias hop, so a pipeline that references an alias (e.g. `llm: default`)
// gets the alias target's full config (Backend, Model, ...) rather than the
// alias stub with an empty Backend. Without this the alias survives unresolved
// into model loading and fails downstream — notably in distributed mode with
// "backend name is empty". Mirrors the top-level alias resolution in
// core/http/middleware/request.go.
func loadPipelineSubModel(cl *config.ModelConfigLoader, name, modelPath string) (*config.ModelConfig, error) {
cfg, err := cl.LoadModelConfigFileByName(name, modelPath)
if err != nil {
return nil, err
}
resolved, _, err := cl.ResolveAlias(cfg)
if err != nil {
return nil, err
}
return resolved, nil
}
// returns and loads either a wrapped model or a model that support audio-to-audio
func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, evaluator *templates.Evaluator, routing *RealtimeRoutingContext) (Model, error) {
xlog.Debug("Creating new model pipeline model", "pipeline", pipeline)
cfgVAD, err := loadPipelineSubModel(cl, pipeline.VAD, ml.ModelPath)
cfgVAD, err := cl.LoadResolvedModelConfig(pipeline.VAD, ml.ModelPath)
if err != nil {
return nil, fmt.Errorf("failed to load backend config: %w", err)
@@ -584,7 +585,7 @@ func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model
}
// TODO: Do we always need a transcription model? It can be disabled. Note that any-to-any instruction following models don't transcribe as such, so if transcription is required it is a separate process
cfgSST, err := loadPipelineSubModel(cl, pipeline.Transcription, ml.ModelPath)
cfgSST, err := cl.LoadResolvedModelConfig(pipeline.Transcription, ml.ModelPath)
if err != nil {
return nil, fmt.Errorf("failed to load backend config: %w", err)
@@ -616,7 +617,7 @@ func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model
xlog.Debug("Loading a wrapped model")
// Otherwise we want to return a wrapped model, which is a "virtual" model that re-uses other models to perform operations
cfgLLM, err := loadPipelineSubModel(cl, pipeline.LLM, ml.ModelPath)
cfgLLM, err := cl.LoadResolvedModelConfig(pipeline.LLM, ml.ModelPath)
if err != nil {
return nil, fmt.Errorf("failed to load backend config: %w", err)
@@ -631,7 +632,7 @@ func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model
applyPipelineReasoning(cfgLLM, *pipeline)
applyPipelineThinking(cfgLLM, *pipeline)
cfgTTS, err := loadPipelineSubModel(cl, pipeline.TTS, ml.ModelPath)
cfgTTS, err := cl.LoadResolvedModelConfig(pipeline.TTS, ml.ModelPath)
if err != nil {
return nil, fmt.Errorf("failed to load backend config: %w", err)

View File

@@ -21,6 +21,7 @@ type namedEmbedding struct {
// drive the realtime pipeline.
type voiceGate struct {
cfg config.PipelineVoiceRecognition // normalized
recCfg *config.ModelConfig // resolved speaker-recognition model, for warm-up
registry voicerecognition.Registry // identify mode (nil otherwise)
refEmbeds []namedEmbedding // verify mode, pre-embedded refs
refAudios []config.VoiceReference // verify + anti-spoofing: ref paths
@@ -72,7 +73,9 @@ func newVoiceGate(
return nil, err
}
recCfg, err := cl.LoadModelConfigFileByName(cfg.Model, ml.ModelPath)
// Resolved like every other pipeline sub-model (one alias hop), so an
// aliased voice_recognition model gets its target's backend.
recCfg, err := cl.LoadResolvedModelConfig(cfg.Model, ml.ModelPath)
if err != nil {
return nil, fmt.Errorf("voice_recognition: failed to load model %q: %w", cfg.Model, err)
}
@@ -82,6 +85,7 @@ func newVoiceGate(
g := &voiceGate{
cfg: cfg,
recCfg: recCfg,
registry: registry,
embedFn: func(ctx context.Context, wavPath string) ([]float32, error) {
res, err := backend.VoiceEmbed(ctx, wavPath, ml, appConfig, *recCfg)

View File

@@ -0,0 +1,64 @@
package openai
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
)
// Warmup delegates to backend.PreloadStages (its concurrency, nil-skipping and
// error-joining semantics are pinned in core/backend). These specs pin the
// wiring instead: each realtime model type must warm exactly its configured
// stages under the right pipeline-role labels. No backends are installed, so
// every attempted stage fails to load — the joined error is the proof of which
// stages were attempted and how they were labeled.
var _ = Describe("realtime model Warmup wiring", func() {
newLoader := func() (*model.ModelLoader, *config.ApplicationConfig) {
systemState, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir()))
Expect(err).ToNot(HaveOccurred())
appConfig := config.NewApplicationConfig(config.WithSystemState(systemState))
return model.NewModelLoader(systemState), appConfig
}
It("wrappedModel warms every configured stage under its pipeline role", func() {
ml, appConfig := newLoader()
m := &wrappedModel{
VADConfig: &config.ModelConfig{Name: "vad-m"},
TranscriptionConfig: &config.ModelConfig{Name: "stt-m"},
LLMConfig: &config.ModelConfig{Name: "llm-m"},
TTSConfig: &config.ModelConfig{Name: "tts-m"},
SoundDetectionConfig: &config.ModelConfig{Name: "ced-m"},
modelLoader: ml,
appConfig: appConfig,
}
err := m.Warmup(context.Background())
Expect(err).To(HaveOccurred())
for _, stage := range []string{"vad (vad-m)", "transcription (stt-m)", "llm (llm-m)", "tts (tts-m)", "sound_detection (ced-m)"} {
Expect(err.Error()).To(ContainSubstring(stage))
}
})
It("transcriptOnlyModel warms its stages and skips absent ones", func() {
ml, appConfig := newLoader()
m := &transcriptOnlyModel{
VADConfig: &config.ModelConfig{Name: "vad-m"},
TranscriptionConfig: &config.ModelConfig{Name: "stt-m"},
// SoundDetectionConfig nil: an absent stage must be skipped, not
// fail the warm-up.
modelLoader: ml,
appConfig: appConfig,
}
err := m.Warmup(context.Background())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("vad (vad-m)"))
Expect(err.Error()).To(ContainSubstring("transcription (stt-m)"))
Expect(err.Error()).ToNot(ContainSubstring("sound_detection"))
})
})

View File

@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -29,6 +30,8 @@ const testModel = "Qwen3-VL-2B-Instruct-Q4_K_M"
var _ = Describe("Open Responses API", func() {
var app *echo.Echo
var localApp *application.Application
var localModelDir string
var c context.Context
var cancel context.CancelFunc
@@ -38,28 +41,47 @@ var _ = Describe("Open Responses API", func() {
Context("API with ephemeral models", func() {
BeforeEach(func(sc SpecContext) {
var err error
// This suite exercises the /v1/responses HTTP/protocol contract
// (Content-Type, SSE framing, response envelope, error shapes),
// not real inference — so it runs against the same prebuilt
// mock-backend the rest of the http suite uses instead of
// downloading a real model. Skip cleanly when it isn't built.
if mockBackendPath == "" {
Skip("mock-backend binary not built; run 'make build-mock-backend'")
}
backendPath := os.Getenv("BACKENDS_PATH")
var err error
c, cancel = context.WithCancel(context.Background())
// Isolated model dir carrying a single config named after testModel
// but served by the mock backend, so the responses endpoint can
// resolve and load the model without any real backend build.
localModelDir, err = os.MkdirTemp("", "openresponses-models-")
Expect(err).ToNot(HaveOccurred())
mockModelYAML := "name: " + testModel + "\n" +
"backend: mock-backend\n" +
"parameters:\n" +
" model: mock-model.bin\n"
Expect(os.WriteFile(filepath.Join(localModelDir, testModel+".yaml"), []byte(mockModelYAML), 0644)).To(Succeed())
systemState, err := system.GetSystemState(
system.WithBackendPath(backendPath),
system.WithModelPath(modelDir),
system.WithBackendPath(backendDir),
system.WithModelPath(localModelDir),
)
Expect(err).ToNot(HaveOccurred())
application, err := application.New(
localApp, err = application.New(
append(commonOpts,
config.WithContext(c),
config.WithSystemState(systemState),
config.WithApiKeys([]string{apiKey}),
config.WithModelsURL("https://huggingface.co/unsloth/Qwen3-VL-2B-Instruct-GGUF"),
)...)
Expect(err).ToNot(HaveOccurred())
localApp.ModelLoader().SetExternalBackend("mock-backend", mockBackendPath)
app, err = API(application)
app, err = API(localApp)
Expect(err).ToNot(HaveOccurred())
go func() {
@@ -80,14 +102,24 @@ var _ = Describe("Open Responses API", func() {
})
AfterEach(func(sc SpecContext) {
// Synchronous app shutdown first — context-cancel cleanup is async
// and races test-binary exit, orphaning mock-backend children.
if localApp != nil {
_ = localApp.Shutdown()
localApp = nil
}
cancel()
if app != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := app.Shutdown(ctx)
Expect(err).ToNot(HaveOccurred())
app = nil
}
if localModelDir != "" {
_ = os.RemoveAll(localModelDir)
localModelDir = ""
}
})
Context("HTTP Protocol Compliance", func() {
@@ -969,13 +1001,16 @@ var _ = Describe("Open Responses API", func() {
Expect(ok).To(BeTrue())
Expect(itemID).ToNot(BeEmpty())
// Now create a new response with item_reference
// Now create a new response with item_reference. Per the OpenAI
// Responses spec (and this server's parser in
// endpoints/openresponses/responses.go) an item_reference carries
// the referenced item in the "id" field, not "item_id".
reqBody2 := map[string]any{
"model": testModel,
"input": []any{
map[string]any{
"type": "item_reference",
"item_id": itemID,
"type": "item_reference",
"id": itemID,
},
map[string]any{
"type": "message",
@@ -1005,8 +1040,8 @@ var _ = Describe("Open Responses API", func() {
"model": testModel,
"input": []any{
map[string]any{
"type": "item_reference",
"item_id": "nonexistent_item_id",
"type": "item_reference",
"id": "nonexistent_item_id",
},
},
}

View File

@@ -0,0 +1,133 @@
import { test, expect } from './coverage-fixtures.js'
// Seeds two-message chat into localStorage so we don't need a live model.
async function seedChat(page, history) {
await page.addInitScript((h) => {
const chat = {
id: 'seed1', name: 'Seeded Chat', model: 'test-model',
history: h, systemPrompt: '', mcpMode: false, mcpServers: [],
clientMCPServers: [], temperature: null, topP: null, topK: null,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
contextSize: null, createdAt: Date.now(), updatedAt: Date.now(),
}
localStorage.setItem('localai_chats_data', JSON.stringify({
chats: [chat], activeChatId: 'seed1', lastSaved: Date.now(),
}))
}, history)
}
async function mockModels(page) {
await page.route('**/api/models/capabilities', (route) => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }),
}))
await page.route('**/api/operations', (route) => route.fulfill({
contentType: 'application/json', body: JSON.stringify({ operations: [] }),
}))
}
const TWO_TURNS = [
{ role: 'user', content: 'first question' },
{ role: 'assistant', content: 'first answer' },
{ role: 'user', content: 'second question' },
{ role: 'assistant', content: 'second answer' },
]
test('duplicate creates an independent copy and switches to it', async ({ page }) => {
await mockModels(page)
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
// Open the chats menu (Ctrl/Cmd+K) and duplicate the seeded chat.
// Wait for the menu trigger to mount so its global keydown listener is armed
// before we dispatch the shortcut.
await page.getByTitle('Conversations (Ctrl/Cmd+K)').waitFor()
await page.keyboard.press('Control+k')
await page.getByTitle('Duplicate chat').first().click()
// A new active chat named "Seeded Chat (fork)" with the same 4 messages.
await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)')
await expect(page.locator('.chat-message-user')).toHaveCount(2)
await expect(page.locator('.chat-message-assistant')).toHaveCount(2)
})
async function mockCompletion(page, replyText) {
await page.route('**/v1/chat/completions', (route) => {
const sse =
`data: ${JSON.stringify({ choices: [{ delta: { content: replyText } }] })}\n\n` +
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n` +
`data: [DONE]\n\n`
route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse })
})
}
test('retry regenerates the first answer and drops the later turn', async ({ page }) => {
await mockModels(page)
// Capture the outbound request body so we can assert the model receives the
// truncated history (not the stale downstream turns).
let sentMessages = null
await page.route('**/v1/chat/completions', (route) => {
sentMessages = route.request().postDataJSON()?.messages || []
const sse =
`data: ${JSON.stringify({ choices: [{ delta: { content: 'REGENERATED first answer' } }] })}\n\n` +
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n` +
`data: [DONE]\n\n`
route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse })
})
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
// Hover the FIRST assistant message and click its retry button.
const firstAssistant = page.locator('.chat-message-assistant').first()
await firstAssistant.hover()
await firstAssistant.getByTitle('Regenerate').click()
// History is truncated to the first user turn, then the new answer streams in;
// the second Q/A turn is gone.
await expect(page.locator('.chat-message-assistant')).toContainText(['REGENERATED first answer'])
await expect(page.locator('.chat-message-user')).toHaveCount(1)
await expect(page.locator('.chat-message-assistant')).toHaveCount(1)
// The OUTBOUND payload must also be truncated: the resent user turn is present,
// but the downstream turn and the stale first answer must be gone.
const contents = (sentMessages || []).map(m =>
typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
)
expect(contents.join('\n')).toContain('first question')
expect(contents.join('\n')).not.toContain('second question')
expect(contents.join('\n')).not.toContain('first answer')
})
test('copy chat puts the whole conversation on the clipboard', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await mockModels(page)
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
// Wait for the menu trigger to mount so its global keydown listener is armed
// before we dispatch the shortcut (same mount-race guard as the duplicate test).
await page.getByTitle('Conversations (Ctrl/Cmd+K)').waitFor()
await page.keyboard.press('Control+k')
await page.getByTitle('Copy chat').first().click()
const clip = await page.evaluate(() => navigator.clipboard.readText())
expect(clip).toContain('# Seeded Chat')
expect(clip).toContain('first answer')
expect(clip).toContain('second answer')
})
test('branch from the first answer forks history up to that point', async ({ page }) => {
await mockModels(page)
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
const firstAssistant = page.locator('.chat-message-assistant').first()
await firstAssistant.hover()
await firstAssistant.getByTitle('Branch from here').click()
// New active chat "Seeded Chat (fork)" contains only the first Q/A turn.
await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)')
await expect(page.locator('.chat-message-user')).toHaveCount(1)
await expect(page.locator('.chat-message-assistant')).toHaveCount(1)
await expect(page.locator('.chat-message-assistant')).toContainText(['first answer'])
})

View File

@@ -72,6 +72,7 @@
"actions": {
"copy": "Copy",
"regenerate": "Regenerate",
"branch": "Branch from here",
"jumpToLatest": "Jump to latest"
},
"streaming": {
@@ -100,7 +101,9 @@
"toasts": {
"selectModel": "Please select a model",
"copied": "Copied to clipboard",
"copyFailed": "Could not copy to clipboard"
"copyFailed": "Could not copy to clipboard",
"chatCopied": "Chat copied to clipboard",
"forked": "Created a new chat"
},
"menu": {
"trigger": "Chats",
@@ -110,6 +113,8 @@
"noMatch": "No conversations match your search",
"noConversations": "No conversations yet",
"rename": "Rename",
"duplicate": "Duplicate chat",
"copyChat": "Copy chat",
"exportMarkdown": "Export as Markdown",
"deleteChat": "Delete chat",
"newChat": "New chat",

View File

@@ -6304,6 +6304,9 @@ select.input {
.home-wizard {
max-width: 48rem;
width: 100%;
/* .home-page is a stretch column flex; a max-width child would otherwise
pin to the left cross-start edge. Center it. */
margin: 0 auto;
}
.home-wizard-hero {
text-align: center;

View File

@@ -24,6 +24,8 @@ const ChatsMenu = forwardRef(function ChatsMenu({
onDeleteAll,
onRename,
onExport,
onCopyChat,
onDuplicate,
}, ref) {
const { t } = useTranslation('chat')
const [open, setOpen] = useState(false)
@@ -230,6 +232,24 @@ const ChatsMenu = forwardRef(function ChatsMenu({
>
<i className="fas fa-pen" />
</button>
{onDuplicate && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDuplicate(chat); setOpen(false) }}
title={t('menu.duplicate')}
>
<i className="fas fa-clone" />
</button>
)}
{(chat.history?.length || 0) > 0 && onCopyChat && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCopyChat(chat) }}
title={t('menu.copyChat')}
>
<i className="fas fa-clipboard" />
</button>
)}
{(chat.history?.length || 0) > 0 && onExport && (
<button
type="button"

View File

@@ -141,6 +141,24 @@ export function useChat(initialModel = '') {
return chat
}, [])
const forkChat = useCallback((chatId, uptoIndex) => {
const src = chats.find(c => c.id === chatId)
if (!src) return null
const end = typeof uptoIndex === 'number' ? uptoIndex : src.history.length
const forked = {
...src,
id: generateId(),
name: `${src.name} (fork)`,
history: structuredClone(src.history.slice(0, end)),
tokenUsage: { prompt: 0, completion: 0, total: 0 },
createdAt: Date.now(),
updatedAt: Date.now(),
}
setChats(prev => [forked, ...prev])
setActiveChatId(forked.id)
return forked
}, [chats])
const switchChat = useCallback((chatId) => {
setActiveChatId(chatId)
setStreamingContent('')
@@ -260,8 +278,12 @@ export function useChat(initialModel = '') {
if (chat?.systemPrompt) {
messages.push({ role: 'system', content: chat.systemPrompt })
}
// Filter out thinking/reasoning/tool_call/tool_result messages
const historyForApi = (chat?.history || []).filter(m =>
// Filter out thinking/reasoning/tool_call/tool_result messages.
// options.baseHistory lets callers (e.g. mid-conversation retry) pass the
// intended truncated history synchronously; the closure `chat` still holds
// the stale pre-truncation state because setChats only schedules an update.
const baseHistory = options.baseHistory || chat?.history || []
const historyForApi = baseHistory.filter(m =>
m.role !== 'thinking' && m.role !== 'reasoning' && m.role !== 'tool_call' && m.role !== 'tool_result'
)
messages.push(...historyForApi, { role: 'user', content: messageContent })
@@ -793,6 +815,7 @@ export function useChat(initialModel = '') {
tokensPerSecond,
maxTokensPerSecond,
addChat,
forkChat,
switchChat,
deleteChat,
deleteAllChats,

View File

@@ -33,7 +33,7 @@ function getLastMessagePreview(chat) {
return ''
}
function exportChatAsMarkdown(chat) {
function serializeChatAsMarkdown(chat) {
let md = `# ${chat.name}\n\n`
md += `Model: ${chat.model || 'Unknown'}\n`
md += `Date: ${new Date(chat.createdAt).toLocaleString()}\n\n---\n\n`
@@ -47,7 +47,11 @@ function exportChatAsMarkdown(chat) {
md += `<details><summary>Thinking</summary>\n\n${msg.content}\n\n</details>\n\n`
}
}
const blob = new Blob([md], { type: 'text/markdown' })
return md
}
function downloadChatAsMarkdown(chat) {
const blob = new Blob([serializeChatAsMarkdown(chat)], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -294,7 +298,7 @@ export default function Chat() {
const {
chats, activeChat, activeChatId, isStreaming, streamingChatId, streamingContent,
streamingReasoning, streamingToolCalls, tokensPerSecond, maxTokensPerSecond,
addChat, switchChat, deleteChat, deleteAllChats, renameChat, updateChatSettings,
addChat, forkChat, switchChat, deleteChat, deleteAllChats, renameChat, updateChatSettings,
sendMessage, stopGeneration, clearHistory, getContextUsagePercent, addMessage,
} = useChat(urlModel || '')
@@ -795,34 +799,27 @@ export default function Chat() {
await sendMessage(msg, files, mcpOptions)
}, [input, files, activeChat, sendMessage, addToast, getToolsForLLM, isClientTool, executeTool, hasAppUI, getAppResource, getToolDefinition])
const handleRegenerate = useCallback(async () => {
const handleRegenerate = useCallback(async (targetIndex) => {
if (!activeChat || isStreaming) return
const history = activeChat.history
let lastUserMsg = null
let lastUserFiles = null
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].role === 'user') {
lastUserMsg = typeof history[i].content === 'string' ? history[i].content : history[i].content?.[0]?.text || ''
lastUserFiles = history[i].files || []
break
}
const end = typeof targetIndex === 'number' ? targetIndex : history.length
// Nearest user message at or before the target answer.
let userIdx = -1
for (let i = Math.min(end, history.length) - 1; i >= 0; i--) {
if (history[i].role === 'user') { userIdx = i; break }
}
if (!lastUserMsg) return
// Remove everything after and including the last user message
const newHistory = []
let foundLastUser = false
for (let i = history.length - 1; i >= 0; i--) {
if (!foundLastUser && history[i].role === 'user') {
foundLastUser = true
continue
}
if (foundLastUser) {
newHistory.unshift(history[i])
}
}
updateChatSettings(activeChat.id, { history: newHistory })
await sendMessage(lastUserMsg, lastUserFiles)
if (userIdx === -1) return
const userMsg = typeof history[userIdx].content === 'string'
? history[userIdx].content
: history[userIdx].content?.[0]?.text || ''
const userFiles = history[userIdx].files || []
// Drop the user turn and everything after it; sendMessage re-appends it.
// Thread the truncated history through explicitly: updateChatSettings only
// schedules a state update, so sendMessage's closure would otherwise read
// the stale pre-truncation history for the outbound API payload.
const baseHistory = history.slice(0, userIdx)
updateChatSettings(activeChat.id, { history: baseHistory })
await sendMessage(userMsg, userFiles, { baseHistory })
}, [activeChat, isStreaming, sendMessage, updateChatSettings])
const handleKeyDown = (e) => {
@@ -852,6 +849,11 @@ export default function Chat() {
}
}
const copyChatAsMarkdown = async (chat) => {
const ok = await copyToClipboard(serializeChatAsMarkdown(chat))
addToast(ok ? t('toasts.chatCopied') : t('toasts.copyFailed'), ok ? 'success' : 'error', ok ? 2000 : 3000)
}
const contextPercent = getContextUsagePercent()
// Recent chats for the empty state — exclude the current chat and any
@@ -892,7 +894,9 @@ export default function Chat() {
onDelete={deleteChat}
onDeleteAll={promptDeleteAll}
onRename={renameChat}
onExport={(chat) => exportChatAsMarkdown(chat)}
onExport={(chat) => downloadChatAsMarkdown(chat)}
onCopyChat={(chat) => copyChatAsMarkdown(chat)}
onDuplicate={(chat) => { if (forkChat(chat.id)) addToast(t('toasts.forked'), 'success', 2000) }}
/>
{activeChat.localaiAssistant && (
<span
@@ -1184,11 +1188,19 @@ export default function Chat() {
<button onClick={() => copyMessage(msg.content)} title={t('actions.copy')}>
<i className="fas fa-copy" />
</button>
{msg.role === 'assistant' && i === activeChat.history.length - 1 && !isStreaming && (
<button onClick={handleRegenerate} title={t('actions.regenerate')}>
{msg.role === 'assistant' && !isStreaming && (
<button onClick={() => handleRegenerate(i)} title={t('actions.regenerate')}>
<i className="fas fa-rotate" />
</button>
)}
{msg.role === 'assistant' && !isStreaming && (
<button
onClick={() => { forkChat(activeChat.id, i + 1); addToast(t('toasts.forked'), 'success', 2000) }}
title={t('actions.branch')}
>
<i className="fas fa-code-branch" />
</button>
)}
</div>
</div>
</div>

View File

@@ -146,6 +146,7 @@ export default function Manage() {
const [distributedMode, setDistributedMode] = useState(false)
const [togglingModels, setTogglingModels] = useState(new Set())
const [pinningModels, setPinningModels] = useState(new Set())
const [loadingModels, setLoadingModels] = useState(new Set())
// Expanded row state — keyed by `${tab}:${id}` so switching tabs doesn't
// collide and a single row is open at a time per tab.
const [expandedKey, setExpandedKey] = useState(null)
@@ -313,6 +314,26 @@ export default function Manage() {
})
}
// Pre-load a model (or all of a realtime pipeline's sub-models) into memory.
// The /backend/load call blocks until loading finishes, so the menu item shows
// a loading state while in flight and reports the outcome on completion.
const handleLoadModel = async (modelName) => {
setLoadingModels(prev => new Set(prev).add(modelName))
try {
await backendControlApi.load({ model: modelName })
addToast(`Loaded ${modelName}`, 'success')
setTimeout(fetchLoadedModels, 500)
} catch (err) {
addToast(`Failed to load: ${err.message}`, 'error')
} finally {
setLoadingModels(prev => {
const next = new Set(prev)
next.delete(modelName)
return next
})
}
}
const handleDeleteModel = (modelName) => {
setConfirmDialog({
title: 'Delete Model',
@@ -687,6 +708,11 @@ export default function Manage() {
label: model.disabled ? 'Enable model' : 'Disable model',
onClick: () => handleToggleModel(model.id, model.disabled),
disabled: togglingModels.has(model.id) },
{ key: 'load', icon: 'fa-bolt',
label: loadingModels.has(model.id) ? 'Loading…' : 'Load into memory',
onClick: () => handleLoadModel(model.id),
hidden: isRunning || !!model.disabled,
disabled: loadingModels.has(model.id) },
{ key: 'stop', icon: 'fa-stop', label: 'Stop model',
onClick: () => handleStopModel(model.id), hidden: !isRunning },
{ key: 'pin', icon: 'fa-thumbtack',

View File

@@ -352,6 +352,9 @@ export const realtimeApi = {
// Backend control
export const backendControlApi = {
shutdown: (body) => postJSON(API_CONFIG.endpoints.backendShutdown, body),
// Pre-load a model (or all of a realtime pipeline's sub-models) into memory.
// body: { model: "<name>" }. Inverse of shutdown.
load: (body) => postJSON(API_CONFIG.endpoints.backendLoad, body),
}
// System info

View File

@@ -106,6 +106,7 @@ export const API_CONFIG = {
video: '/video',
backendMonitor: '/backend/monitor',
backendShutdown: '/backend/shutdown',
backendLoad: '/backend/load',
modelsApply: '/models/apply',
modelsDelete: (name) => `/models/delete/${name}`,
modelsAvailable: '/models/available',

View File

@@ -207,9 +207,14 @@ func RegisterLocalAIRoutes(router *echo.Echo,
backendMonitorService := monitoring.NewBackendMonitorService(ml, cl, appConfig) // Split out for now
router.GET("/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService), adminMiddleware)
router.POST("/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService), adminMiddleware)
// /backend/load is the inverse of /backend/shutdown: pre-load a model (or all
// of a realtime pipeline's sub-models) into memory so clients can drive
// warm-up explicitly instead of paying the cold-start cost on first use.
router.POST("/backend/load", localai.LoadModelEndpoint(cl, ml, appConfig), adminMiddleware)
// The v1/* urls are exactly the same as above - makes local e2e testing easier if they are registered.
router.GET("/v1/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService), adminMiddleware)
router.POST("/v1/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService), adminMiddleware)
router.POST("/v1/backend/load", localai.LoadModelEndpoint(cl, ml, appConfig), adminMiddleware)
// Traces and backend logs (monitoring)
router.GET("/api/traces", localai.GetAPITracesEndpoint(), adminMiddleware)
@@ -245,6 +250,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
"metrics": "/metrics",
"backend_monitor": "/backend/monitor",
"backend_shutdown": "/backend/shutdown",
"backend_load": "/backend/load",
"system": "/system",
"version": "/version",
"traces": "/api/traces",
@@ -266,25 +272,27 @@ func RegisterLocalAIRoutes(router *echo.Echo,
"version": internal.PrintableVersion(),
// Flat endpoint list for backwards compatibility
"endpoints": map[string]any{
"models": "/v1/models",
"chat_completions": "/v1/chat/completions",
"completions": "/v1/completions",
"embeddings": "/v1/embeddings",
"config_metadata": "/api/models/config-metadata",
"config_json": "/api/models/config-json/:name",
"config_patch": "/api/models/config-json/:name",
"autocomplete": "/api/models/config-metadata/autocomplete/:provider",
"vram_estimate": "/api/models/vram-estimate",
"tts": "/tts",
"transcription": "/v1/audio/transcriptions",
"image_generation": "/v1/images/generations",
"swagger": "/swagger/index.html",
"instructions": "/api/instructions",
"models": "/v1/models",
"models_capabilities": "/v1/models/capabilities",
"chat_completions": "/v1/chat/completions",
"completions": "/v1/completions",
"embeddings": "/v1/embeddings",
"config_metadata": "/api/models/config-metadata",
"config_json": "/api/models/config-json/:name",
"config_patch": "/api/models/config-json/:name",
"autocomplete": "/api/models/config-metadata/autocomplete/:provider",
"vram_estimate": "/api/models/vram-estimate",
"tts": "/tts",
"transcription": "/v1/audio/transcriptions",
"image_generation": "/v1/images/generations",
"swagger": "/swagger/index.html",
"instructions": "/api/instructions",
},
// Categorized endpoint groups for structured discovery
"endpoint_groups": map[string]any{
"openai_compatible": map[string]string{
"models": "/v1/models",
"models_capabilities": "/v1/models/capabilities",
"chat_completions": "/v1/chat/completions",
"completions": "/v1/completions",
"embeddings": "/v1/embeddings",

View File

@@ -257,4 +257,10 @@ func RegisterOpenAIRoutes(app *echo.Echo,
// List models
app.GET("/v1/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB()))
app.GET("/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB()))
// List models enriched with capabilities + input/output modalities
// (LocalAI-specific, additive superset of /v1/models).
capabilitiesHandler := openai.ListModelCapabilitiesEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB())
app.GET("/v1/models/capabilities", capabilitiesHandler)
app.GET("/models/capabilities", capabilitiesHandler)
}

View File

@@ -11,6 +11,24 @@ type BackendMonitorRequest struct {
BasicModelRequest
}
// ModelLoadRequest asks LocalAI to pre-load a model into memory by name, so the
// first request that uses it pays no cold-start load cost. For a realtime
// pipeline model, every configured sub-model (VAD, transcription, LLM, TTS,
// sound_detection, voice_recognition) is loaded instead of the pipeline stub.
// It is the inverse of the /backend/shutdown request.
type ModelLoadRequest struct {
BasicModelRequest
}
// ModelLoadResponse reports the outcome of a /backend/load call.
type ModelLoadResponse struct {
// Loaded lists the model names actually resident in memory after the call.
// For a pipeline model these are its sub-models, not the pipeline name.
Loaded []string `json:"loaded"`
// Message is a short human-readable status ("model loaded", or an error).
Message string `json:"message"`
}
type TokenMetricsRequest struct {
BasicModelRequest
}

View File

@@ -251,3 +251,27 @@ type ModelsDataResponse struct {
Object string `json:"object"`
Data []OpenAIModel `json:"data"`
}
// ModelCapabilities is a strict superset of OpenAIModel that additionally
// describes what a model can do and which modalities it accepts/produces. It is
// served by the LocalAI-specific /v1/models/capabilities endpoint so clients can
// route attachments (image/audio/video) to a model only when it can handle them.
type ModelCapabilities struct {
ID string `json:"id"`
Object string `json:"object"`
// Capabilities are canonical usecase strings (e.g. chat, vision, transcript,
// tts, embeddings, image, video) plus the modifiers "tools" and "thinking".
Capabilities []string `json:"capabilities"`
// InputModalities is the subset of {text,image,audio,video} the model accepts.
InputModalities []string `json:"input_modalities"`
// OutputModalities is the subset of {text,image,audio,video} the model produces.
OutputModalities []string `json:"output_modalities"`
}
// ModelCapabilitiesResponse is the envelope returned by /v1/models/capabilities.
// It mirrors ModelsDataResponse so a client can treat it as an enriched
// drop-in for /v1/models.
type ModelCapabilitiesResponse struct {
Object string `json:"object"`
Data []ModelCapabilities `json:"data"`
}

View File

@@ -426,7 +426,15 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) {
// Process asynchronously
go func() {
started := time.Now()
response := ag.Ask(coreTypes.WithText(message))
outcome := "completed"
if response == nil {
outcome = "cancelled"
} else if response.Error != nil {
outcome = "error"
}
recordAgentRun(name, outcome, time.Since(started).Seconds())
if response == nil {
errMsg, _ := json.Marshal(map[string]any{

View File

@@ -0,0 +1,54 @@
package agentpool
import (
"context"
"sync"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Prometheus metrics for agent chat runs. Operators need a scrape-friendly
// signal for "are agent turns completing, erroring or getting cancelled,
// and how long do they take" — log-derived counters proved brittle
// (ANSI/timezone parsing, container-restart gaps). Chat() is the single
// choke point of the local execution path, so instrumenting the response
// handoff covers UI chats, API chats and connector-triggered asks alike.
//
// Lazily initialised on first record so the package works no matter when
// (or whether) the Prometheus-backed global MeterProvider is installed —
// same pattern as core/services/routing/pii.
var (
agentMetricsOnce sync.Once
runsCounter metric.Int64Counter
runSeconds metric.Float64Histogram
)
func recordAgentRun(agent, outcome string, seconds float64) {
agentMetricsOnce.Do(func() {
meter := otel.Meter("github.com/mudler/LocalAI")
if c, err := meter.Int64Counter(
"localai_agent_runs_total",
metric.WithDescription("Agent chat runs, labeled by agent and outcome (completed|error|cancelled)"),
); err == nil {
runsCounter = c
}
if h, err := meter.Float64Histogram(
"localai_agent_run_seconds",
metric.WithDescription("Wall-clock duration of agent chat runs in seconds"),
); err == nil {
runSeconds = h
}
})
attrs := metric.WithAttributes(
attribute.String("agent", agent),
attribute.String("outcome", outcome),
)
if runsCounter != nil {
runsCounter.Add(context.Background(), 1, attrs)
}
if runSeconds != nil {
runSeconds.Record(context.Background(), seconds, attrs)
}
}

View File

@@ -0,0 +1,48 @@
package pii
import (
"context"
"sync"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Prometheus counter for PII events. The EventStore ring buffer is
// capacity-bound and meant for recent-audit browsing; operators also want
// a monotonic, scrape-friendly signal ("how many detections/blocks per
// hour, did the filter stop firing after a deploy"). Record() is the
// single choke point every producer already goes through (request
// middleware, response scrubbing, MITM proxy connects/intercepts), so one
// counter here covers all paths without touching the producers.
//
// Initialised lazily on first Record so the package works no matter when
// (or whether) the Prometheus-backed global MeterProvider is installed —
// same pattern as core/services/routing/billing.
var (
metricsOnce sync.Once
eventsCounter metric.Int64Counter
)
func recordEventMetric(e PIIEvent) {
metricsOnce.Do(func() {
meter := otel.Meter("github.com/mudler/LocalAI")
c, err := meter.Int64Counter(
"localai_pii_events_total",
metric.WithDescription("PII/audit events recorded, labeled by kind, origin, action and direction"),
)
if err == nil {
eventsCounter = c
}
})
if eventsCounter == nil {
return
}
eventsCounter.Add(context.Background(), 1, metric.WithAttributes(
attribute.String("kind", string(e.Kind)),
attribute.String("origin", string(e.Origin)),
attribute.String("action", string(e.Action)),
attribute.String("direction", string(e.Direction)),
))
}

View File

@@ -58,6 +58,7 @@ type memoryEventStore struct {
}
func (s *memoryEventStore) Record(_ context.Context, e PIIEvent) error {
recordEventMetric(e)
s.mu.Lock()
defer s.mu.Unlock()
s.ring[s.cursor] = e

View File

@@ -14,6 +14,16 @@ import (
// MaxSnippetSeconds is the maximum number of seconds of audio captured per trace.
const MaxSnippetSeconds = 30
// silenceFloorDBFS is the dBFS value reported for digital silence (RMS or peak
// of zero). The true level is -∞ dBFS; reporting a finite floor keeps the
// metric present and meaningful in the Traces UI (a scrubbed nil would read as
// "missing" rather than "silent"). -120 dBFS sits well below 16-bit PCM's
// ~-90 dBFS least-significant-bit floor, so it reads unambiguously as
// "effectively silent". JSON-marshal safety for any non-finite float that does
// reach a trace is owned centrally by RecordBackendTrace's sanitizer — this
// floor is about presentation, not transport.
const silenceFloorDBFS = -120.0
// AudioSnippet captures the first MaxSnippetSeconds of a WAV file and computes
// quality metrics. The result is a map suitable for merging into a BackendTrace
// Data field. maxBytes caps the embedded base64 waveform so a single TTS or
@@ -63,7 +73,7 @@ func AudioSnippetFromPCM(pcm []byte, sampleRate, totalPCMBytes, maxBytes int) ma
snippetDuration := float64(len(samples)) / float64(sampleRate)
rms := sound.CalculateRMS16(samples)
rmsDBFS := -math.Inf(1)
rmsDBFS := silenceFloorDBFS
if rms > 0 {
rmsDBFS = 20 * math.Log10(rms/32768.0)
}
@@ -78,7 +88,7 @@ func AudioSnippetFromPCM(pcm []byte, sampleRate, totalPCMBytes, maxBytes int) ma
}
dcSum += int64(s)
}
peakDBFS := -math.Inf(1)
peakDBFS := silenceFloorDBFS
if peak > 0 {
peakDBFS = 20 * math.Log10(float64(peak)/32768.0)
}

View File

@@ -1,6 +1,9 @@
package trace_test
import (
"encoding/json"
"math"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -47,3 +50,32 @@ var _ = Describe("AudioSnippetFromPCM byte cap", func() {
Expect(out).To(HaveKey("audio_wav_base64"))
})
})
// Silent audio (RMS/peak of zero) has a true level of -∞ dBFS, but emitting
// -Inf made the whole /api/backend-traces response fail to JSON-marshal and
// blanked the Traces UI. The metrics must instead be finite and serializable.
var _ = Describe("AudioSnippetFromPCM silent audio dBFS", func() {
pcm := makePCM(snippetSeconds, snippetSampleRate) // all zeros == digital silence
totalPCM := len(pcm)
It("reports finite dBFS for silence instead of -Inf", func() {
out := trace.AudioSnippetFromPCM(pcm, snippetSampleRate, totalPCM, 0)
rms, ok := out["audio_rms_dbfs"].(float64)
Expect(ok).To(BeTrue())
Expect(math.IsInf(rms, 0)).To(BeFalse(), "silent RMS must not be ±Inf")
Expect(math.IsNaN(rms)).To(BeFalse())
peak, ok := out["audio_peak_dbfs"].(float64)
Expect(ok).To(BeTrue())
Expect(math.IsInf(peak, 0)).To(BeFalse(), "silent peak must not be ±Inf")
Expect(math.IsNaN(peak)).To(BeFalse())
})
It("produces a snippet that round-trips through encoding/json", func() {
out := trace.AudioSnippetFromPCM(pcm, snippetSampleRate, totalPCM, 0)
_, err := json.Marshal(out)
Expect(err).ToNot(HaveOccurred(), "silent-audio metrics must be JSON-marshalable")
})
})

View File

@@ -3,6 +3,8 @@ package trace
import (
"encoding/json"
"fmt"
"maps"
"math"
"slices"
"sync"
"time"
@@ -116,8 +118,13 @@ func RecordBackendTrace(t BackendTrace) {
backendMu.Lock()
maxBody := backendMaxBodyBytes
backendMu.Unlock()
if t.Data != nil && maxBody > 0 {
t.Data = capDataStrings(t.Data, maxBody)
// Always walk Data, even with no body cap configured: besides capping
// oversized strings (maxBody > 0), the walk replaces non-finite floats
// (Inf/NaN) that encoding/json cannot marshal. A single such value — e.g. a
// -Inf dBFS audio metric from a silent clip — would otherwise fail the whole
// /api/backend-traces response and blank the Traces UI.
if t.Data != nil {
t.Data = sanitizeData(t.Data, maxBody)
}
select {
case backendLogChan <- &t:
@@ -126,32 +133,90 @@ func RecordBackendTrace(t BackendTrace) {
}
}
// capDataStrings walks a trace Data map and replaces any string value (at any
// depth) that exceeds maxBytes with a fixed-size marker that names the
// original byte count. The replacement is intentionally short and not valid
// base64/JSON: the goal is to flag "this was dropped" cheaply, not to keep a
// partial value that the UI might try to render. Non-string scalars and
// non-map containers pass through untouched so structural fields like
// total_deltas or audio_sample_rate remain useful.
func capDataStrings(data map[string]any, maxBytes int) map[string]any {
out := make(map[string]any, len(data))
for k, v := range data {
out[k] = capValue(v, maxBytes)
}
// sanitizeData walks a trace Data map (recursing into nested maps and slices)
// and makes every value safe for the /api/backend-traces JSON response:
//
// - When maxBytes > 0, any string longer than maxBytes is replaced with a
// fixed-size marker that names the original byte count. The replacement is
// intentionally short and not valid base64/JSON: it flags "this was dropped"
// cheaply rather than keeping a partial value the UI might try to render.
// - Non-finite floats (Inf/NaN) are replaced with nil regardless of maxBytes,
// because encoding/json refuses to marshal them and one bad value would fail
// the entire response.
//
// Other scalars (ints, bools, finite floats) pass through untouched so
// structural fields like total_deltas or audio_sample_rate remain useful.
//
// The walk is copy-on-write: it runs on every RecordBackendTrace call, and in
// the common case nothing needs rewriting, so containers are only re-allocated
// on the paths that actually changed and untouched values keep their original
// interface boxes instead of paying a per-value re-boxing allocation.
func sanitizeData(data map[string]any, maxBytes int) map[string]any {
out, _ := sanitizeMap(data, maxBytes)
return out
}
func capValue(v any, maxBytes int) any {
func sanitizeMap(m map[string]any, maxBytes int) (map[string]any, bool) {
var out map[string]any
for k, v := range m {
nv, changed := sanitizeValue(v, maxBytes)
if changed && out == nil {
// First change: fork the map. Entries already visited were
// unchanged, so a full copy then overwriting as we go is exact.
out = make(map[string]any, len(m))
maps.Copy(out, m)
}
if out != nil {
out[k] = nv
}
}
if out == nil {
return m, false
}
return out, true
}
func sanitizeSlice(s []any, maxBytes int) ([]any, bool) {
var out []any
for i, v := range s {
nv, changed := sanitizeValue(v, maxBytes)
if changed && out == nil {
out = make([]any, len(s))
copy(out, s)
}
if out != nil {
out[i] = nv
}
}
if out == nil {
return s, false
}
return out, true
}
func sanitizeValue(v any, maxBytes int) (any, bool) {
switch val := v.(type) {
case string:
if len(val) > maxBytes {
return fmt.Sprintf("<truncated: %d bytes>", len(val))
if maxBytes > 0 && len(val) > maxBytes {
return fmt.Sprintf("<truncated: %d bytes>", len(val)), true
}
return val
return v, false
case float64:
if math.IsInf(val, 0) || math.IsNaN(val) {
return nil, true
}
return v, false
case float32:
if f := float64(val); math.IsInf(f, 0) || math.IsNaN(f) {
return nil, true
}
return v, false
case map[string]any:
return capDataStrings(val, maxBytes)
return sanitizeMap(val, maxBytes)
case []any:
return sanitizeSlice(val, maxBytes)
default:
return v
return v, false
}
}

View File

@@ -0,0 +1,80 @@
package trace_test
import (
"encoding/json"
"math"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/trace"
)
// encoding/json cannot marshal ±Inf or NaN. The /api/backend-traces endpoint
// serializes the whole buffer with one json call, so a single non-finite float
// in any trace's Data map (e.g. a -Inf dBFS audio metric from a silent clip)
// would fail the entire response and blank the Traces UI. RecordBackendTrace
// must scrub those values regardless of whether a body cap is configured.
var _ = Describe("RecordBackendTrace non-finite float sanitization", func() {
BeforeEach(func() {
// maxBodyBytes 0 == no body cap: float sanitization must still run.
trace.InitBackendTracingIfEnabled(64, 0)
trace.ClearBackendTraces()
})
It("replaces ±Inf and NaN with nil so the response stays JSON-marshalable", func() {
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: time.Now(),
Type: trace.BackendTraceTranscription,
ModelName: "m",
Data: map[string]any{
"audio_rms_dbfs": math.Inf(-1),
"audio_peak_dbfs": math.Inf(1),
"weird": math.NaN(),
"audio_duration_s": 1.5, // finite siblings must survive
},
})
Eventually(trace.GetBackendTraces).Should(HaveLen(1))
got := trace.GetBackendTraces()[0]
Expect(got.Data["audio_rms_dbfs"]).To(BeNil())
Expect(got.Data["audio_peak_dbfs"]).To(BeNil())
Expect(got.Data["weird"]).To(BeNil())
Expect(got.Data["audio_duration_s"]).To(Equal(1.5), "finite floats must pass through untouched")
_, err := json.Marshal(trace.GetBackendTraces())
Expect(err).ToNot(HaveOccurred(), "the whole trace buffer must marshal even with non-finite inputs")
})
It("scrubs non-finite floats nested in maps and slices", func() {
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: time.Now(),
Type: trace.BackendTraceLLM,
ModelName: "m",
Data: map[string]any{
"nested": map[string]any{
"logprob": math.Inf(-1),
"ok": 0.25,
},
"scores": []any{1.0, math.Inf(1), math.NaN()},
},
})
Eventually(trace.GetBackendTraces).Should(HaveLen(1))
got := trace.GetBackendTraces()[0]
nested := got.Data["nested"].(map[string]any)
Expect(nested["logprob"]).To(BeNil())
Expect(nested["ok"]).To(Equal(0.25))
scores := got.Data["scores"].([]any)
Expect(scores[0]).To(Equal(1.0))
Expect(scores[1]).To(BeNil())
Expect(scores[2]).To(BeNil())
_, err := json.Marshal(trace.GetBackendTraces())
Expect(err).ToNot(HaveOccurred())
})
})

View File

@@ -381,6 +381,8 @@ curl -X POST http://localhost:8080/backend/shutdown \
To stop all models, you'll need to call the endpoint for each loaded model individually, or use the web UI to stop all models at once.
Conversely, you can pre-load a model into memory ahead of its first request with `POST /backend/load` (the inverse of shutdown) — see [Backend Monitor]({{%relref "features/backend-monitor" %}}).
### Best Practices
1. **Monitor VRAM usage**: Use `nvidia-smi` (for NVIDIA GPUs) or similar tools to monitor actual VRAM usage

View File

@@ -36,6 +36,7 @@ Returns the instance version, all available endpoint URLs (flat and categorized)
"endpoints": {
"chat_completions": "/v1/chat/completions",
"models": "/v1/models",
"models_capabilities": "/v1/models/capabilities",
"config_metadata": "/api/models/config-metadata",
"instructions": "/api/instructions",
"swagger": "/swagger/index.html"
@@ -123,6 +124,45 @@ Add `?format=json` to get a raw **OpenAPI fragment** (filtered Swagger spec with
curl http://localhost:8080/api/instructions/config-management?format=json
```
## Model Capabilities
`GET /v1/models/capabilities`
An additive, LocalAI-specific superset of `/v1/models`. It returns the same set of models but enriches each entry with the **capabilities** the model supports and the **input/output modalities** it accepts and produces. Use it to decide, before sending a request, whether a given model can take an image, audio, or video attachment directly — or whether the input needs converting/transcribing first.
Because it is purely additive, clients that only understand `/v1/models` keep working unchanged; they simply never call this route.
```bash
curl http://localhost:8080/v1/models/capabilities
```
```json
{
"object": "list",
"data": [
{
"id": "qwen2.5-omni",
"object": "model",
"capabilities": ["chat", "vision", "tools"],
"input_modalities": ["text", "image", "audio"],
"output_modalities": ["text"]
},
{
"id": "parakeet",
"object": "model",
"capabilities": ["transcript"],
"input_modalities": ["audio"],
"output_modalities": ["text"]
}
]
}
```
- **`capabilities`** — canonical usecase strings (e.g. `chat`, `vision`, `transcript`, `tts`, `embeddings`, `image`, `video`) plus the modifiers `tools` and `thinking`.
- **`input_modalities` / `output_modalities`** — the subsets of `{text, image, audio, video}` the model accepts and produces. Audio and video *input* are derived from the model's multimodal limits (e.g. vLLM `limit_mm_per_prompt`), which no single usecase flag expresses — which is why this endpoint exists alongside the plain listing.
The same query parameters as `/v1/models` are honored (`filter`, `excludeConfigured`), and the same per-user model allowlist is applied when authentication is enabled.
## Configuration Management APIs
These endpoints let agents discover model configuration fields, read current settings, modify them, and estimate VRAM usage.

View File

@@ -166,7 +166,7 @@ When authentication is enabled, the following endpoints require admin role:
- `GET /api/backend-traces`, `POST /api/backend-traces/clear`
- `GET /api/backend-logs/*`, `POST /api/backend-logs/*/clear`
- `GET /api/resources`, `GET /api/settings`, `POST /api/settings`
- `GET /system`, `GET /backend/monitor`, `POST /backend/shutdown`
- `GET /system`, `GET /backend/monitor`, `POST /backend/shutdown`, `POST /backend/load`
**P2P:**
- `GET /api/p2p/*`

View File

@@ -5,7 +5,9 @@ weight = 20
url = "/features/backend-monitor/"
+++
LocalAI provides endpoints to monitor and manage running backends. The `/backend/monitor` endpoint reports the status and resource usage of loaded models, and `/backend/shutdown` allows stopping a model's backend process.
LocalAI provides endpoints to monitor and manage running backends. The `/backend/monitor` endpoint reports the status and resource usage of loaded models, `/backend/load` pre-loads a model into memory, and `/backend/shutdown` allows stopping a model's backend process.
All three are admin-only.
## Monitor API
@@ -62,6 +64,42 @@ curl "http://localhost:8080/backend/monitor?model=my-model"
}
```
## Load API
Pre-loads a model into memory ahead of its first request, so that request pays no cold-start load cost. It is the inverse of the Shutdown API and works for any model, not just realtime pipelines.
- **Method:** `POST`
- **Endpoints:** `/backend/load`, `/v1/backend/load`
### Request
| Parameter | Type | Required | Description |
|-----------|----------|----------|------------------------------|
| `model` | `string` | Yes | Name of the model to load |
### Behavior
- For a regular model, its own backend is loaded.
- For a **realtime pipeline** model (a config with a `pipeline:` block), every configured sub-model (VAD, transcription, LLM, TTS, sound_detection, voice_recognition) is loaded concurrently instead of the pipeline stub, which has no backend of its own.
The call blocks until loading finishes and reports which model names became resident, so partial failures are visible.
### Usage
```bash
curl -X POST http://localhost:8080/backend/load \
-H "Content-Type: application/json" \
-d '{"model": "my-model"}'
```
### Example response
```json
{ "loaded": ["my-model"], "message": "model loaded" }
```
On failure the call returns `500` with `loaded` listing whichever sub-models did load and `message` naming the failures.
## Shutdown API
- **Method:** `POST`

View File

@@ -56,6 +56,39 @@ pipeline:
All streaming flags are off by default, so existing pipelines are unaffected.
### Model warm-up (cold start)
Without warm-up the pipeline's models are loaded into memory only on first use *within* a session: the VAD on the first audio chunk, transcription at the first end-of-speech, the LLM on the first reply, and TTS on the first spoken output. On a cold session this staggers a load delay across those first few interactions — and a model that fails to load (missing weights, wrong backend, out of memory) only fails part-way through the first turn.
To avoid that, LocalAI **warms the pipeline by default**: it loads the VAD, transcription, LLM and TTS backends into memory *before* the session is announced, and the session start **blocks until they are all ready**. The loads run concurrently, so the wait is the slowest single model, not the sum. This means:
- The first turn pays no cold-start cost — every backend is already resident.
- **Model-load errors surface at session start.** If any stage fails to load, the session is not started and the client receives a `model_load_error` instead of `session.created`, so a broken pipeline fails fast and visibly rather than mid-call.
Set `disable_warmup: true` to restore the lazy "load on first use" behavior — session start no longer waits on loading and load errors surface on the first turn instead. Useful if you want idle sessions to avoid holding model memory they may never use:
```yaml
name: gpt-realtime
pipeline:
vad: silero-vad-ggml
transcription: whisper-large-turbo
llm: qwen3-4b
tts: tts-1
disable_warmup: true # lazily load each model on first use instead of at session start
```
#### Pre-loading a pipeline on demand
Warm-up only fires when a realtime session opens. To load a pipeline into memory ahead of time — e.g. to warm it right after boot, or when running with `disable_warmup: true` — POST the model name to the admin-only `/backend/load` endpoint. For a pipeline model it loads every configured sub-model (VAD, transcription, LLM, TTS, sound_detection, voice_recognition) concurrently:
```bash
curl -X POST http://localhost:8080/backend/load \
-H "Content-Type: application/json" \
-d '{"model": "gpt-realtime"}'
```
The endpoint is not realtime-specific — it pre-loads any model. See [Backend Monitor]({{%relref "features/backend-monitor" %}}) for the full request/response reference (it is the inverse of `/backend/shutdown`).
### Turn detection
Turn detection decides when the user has finished speaking and the pipeline should respond. Two modes are supported, matching the OpenAI session schema:

View File

@@ -507,7 +507,7 @@ The `llama.cpp` backend supports additional configuration options that can be sp
| `fit_params_min_ctx` or `fit_ctx` | integer | Minimum context size that can be set by fit_params. Default: `4096`. | `fit_ctx:2048` |
| `n_cache_reuse` or `cache_reuse` | integer | Minimum chunk size to attempt reusing from the cache via KV shifting. Default: `0` (disabled). | `cache_reuse:256` |
| `slot_prompt_similarity` or `sps` | float | How much the prompt of a request must match the prompt of a slot to use that slot. Default: `0.1`. Set to `0` to disable. | `sps:0.5` |
| `swa_full` | boolean | Use full-size SWA (Sliding Window Attention) cache. Upstream default is `false` (a memory-light reduced cache), but that reduced cache cannot reuse a prompt prefix across requests, which defeats `cache_reuse` for SWA models (Gemma 2/3, Cohere2, Llama 4, ...). LocalAI therefore **auto-enables `swa_full:true` for GGUF models detected as SWA** so the cross-request prefix cache works; it is left off for dense models. The tradeoff is memory: the full SWA cache scales with `context_size`. Set `swa_full:false` explicitly to opt back out (e.g. to save memory at a large context). | `swa_full:true` |
| `swa_full` | boolean | Use full-size SWA (Sliding Window Attention) cache. Default: `false`. | `swa_full:true` |
| `cont_batching` or `continuous_batching` | boolean | Enable continuous batching for handling multiple sequences. Default: `true`. | `cont_batching:true` |
| `check_tensors` | boolean | Validate tensor data for invalid values during model loading. Default: `false`. | `check_tensors:true` |
| `warmup` | boolean | Enable warmup run after model loading. Default: `true`. | `warmup:false` |

View File

@@ -23,8 +23,8 @@ Complete reference for all LocalAI command-line interface (CLI) parameters and e
|-----------|---------|-------------|----------------------|
| `--models-path` | `BASEPATH/models` | Path containing models used for inferencing | `$LOCALAI_MODELS_PATH`, `$MODELS_PATH` |
| `--data-path` | `BASEPATH/data` | Path for persistent data (collectiondb, agent state, tasks, jobs). Separates mutable data from configuration | `$LOCALAI_DATA_PATH` |
| `--generated-content-path` | `/tmp/generated/content` | Location for assets generated by backends (e.g. stablediffusion, images, audio, videos) | `$LOCALAI_GENERATED_CONTENT_PATH`, `$GENERATED_CONTENT_PATH` |
| `--upload-path` | `/tmp/localai/upload` | Path to store uploads from files API | `$LOCALAI_UPLOAD_PATH`, `$UPLOAD_PATH` |
| `--generated-content-path` | `TMPDIR/localai-UID/generated/content` | Location for assets generated by backends (e.g. stablediffusion, images, audio, videos). Defaults under the OS temp dir (`$TMPDIR`, falling back to `/tmp`), scoped to the current user's UID so accounts sharing a host never collide. | `$LOCALAI_GENERATED_CONTENT_PATH`, `$GENERATED_CONTENT_PATH` |
| `--upload-path` | `TMPDIR/localai-UID/upload` | Path to store uploads from files API. Defaults under the OS temp dir (`$TMPDIR`, falling back to `/tmp`), scoped to the current user's UID. | `$LOCALAI_UPLOAD_PATH`, `$UPLOAD_PATH` |
| `--localai-config-dir` | `BASEPATH/configuration` | Directory for dynamic loading of certain configuration files (currently runtime_settings.json, api_keys.json, and external_backends.json). See [Runtime Settings]({{%relref "features/runtime-settings" %}}) for web-based configuration. | `$LOCALAI_CONFIG_DIR` |
| `--localai-config-dir-poll-interval` | | Time duration to poll the LocalAI Config Dir if your system has broken fsnotify events (example: `1m`) | `$LOCALAI_CONFIG_DIR_POLL_INTERVAL` |
| `--models-config-file` | | YAML file containing a list of model backend configs (alias: `--config-file`) | `$LOCALAI_MODELS_CONFIG_FILE`, `$CONFIG_FILE` |

View File

@@ -17,6 +17,7 @@ You can see the release notes [here](https://github.com/mudler/LocalAI/releases)
- **May 2026**: [Speaker diarization](/features/audio-diarization/) — new `/v1/audio/diarization` endpoint returning "who spoke when" segments. Backed by `sherpa-onnx` (pyannote-3.0 + speaker embeddings + clustering) for pure diarization, and `vibevoice-cpp` for diarization bundled with long-form ASR. Supports `json` / `verbose_json` / `rttm` response formats.
- **June 2026**: [Sound classification](/features/audio-classification/) — new `/v1/audio/classification` endpoint for audio tagging / sound-event classification, returning scored [AudioSet](https://research.google.com/audioset/) labels (baby cry, glass breaking, alarms, ...). Backed by [ced.cpp](https://github.com/mudler/ced.cpp), a 527-class AudioSet tagger ported to ggml.
- **June 2026**: [PII analyze / redact API](/features/middleware/#analyze--redact-api) — the PII detection pipeline (NER + restricted-regex pattern tiers) is now a standalone service: `POST /api/pii/analyze` returns detected entity spans and `POST /api/pii/redact` returns the sanitised text (or `400 pii_blocked`), without routing a chat request through the middleware. Events gain an `origin` (`middleware` / `proxy` / `pii_analyze` / `pii_redact`) so `/api/pii/events` can be filtered by source.
- **July 2026**: [Model capabilities endpoint](/features/api-discovery/#model-capabilities) — `GET /v1/models/capabilities`, an additive superset of `/v1/models` that reports each model's `capabilities` plus its `input_modalities` / `output_modalities` (`text` / `image` / `audio` / `video`). Lets clients route image/audio/video attachments to a model only when it can handle them; audio/video *input* is derived from the model's multimodal limits, which no single usecase flag expresses.
- **June 2026**: Concurrent scoring and PII NER on llama.cpp — the `Score` (router classifier) and `TokenClassify` (PII NER) primitives now ride llama.cpp's server task queue instead of locking the context, so they run concurrently with chat/completion/embedding traffic and with each other. The `known_usecases` restriction that forced dedicated scorer/NER model configs on llama-cpp is lifted, repeated scoring calls reuse the prompt KV cache across candidates, and scoring inputs are no longer capped by the physical batch size.
## 2024 Highlights

View File

@@ -1,3 +1,3 @@
{
"version": "v4.5.6"
"version": "v4.6.0"
}

View File

@@ -1,36 +1,46 @@
---
- name: "qwopus3.6-35b-a3b-coder-mtp"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/Jackrong/Qwopus3.6-35B-A3B-Coder-MTP-GGUF
description: "# \U0001F31F Qwopus3.6-35B-A3B-v1\n\n## \U0001F4A1 Base Model Overview\n\n**Qwen3.6-35B-A3B** is an advanced hybrid sparse MoE (Mixture-of-Experts) model developed by Alibaba Cloud. It features 35B total parameters with only 3B active parameters per token, ensuring high inference efficiency. Architecturally, it combines Gated DeltaNet linear attention with standard gated attention layers, routing tokens across **256 experts**. It natively supports a massive **262k context window** and is specifically designed for high-performance agentic coding, deep reasoning, and multimodal tasks.\n\n## \U0001F680 Model Refinement & Logic Tuning Qwopus3.6-35B-A3B-v1\n\n\U0001FA90**Qwopus3.6-35B-A3B-v1** is a reasoning-enhanced MoE (Mixture of Experts) model fine-tuned on top of **Qwen3.6-35B-A3B**.\n\n### \U0001F6E0 Training Strategy\n\nThe fine-tuning process for this model is structured into **three distinct stages of distributed SFT (Supervised Fine-Tuning)**, progressively scaling reasoning complexity and data diversity. This systematic approach ensures the model inherits the base MoE capabilities while sharpening its logic-handling depth.\n\n...\n"
license: "apache-2.0"
tags:
- llm
- gguf
- vision
- multimodal
icon: https://cdn-uploads.huggingface.co/production/uploads/66309bd090589b7c65950665/ztbyGV_zGhzcLuTCSVyq3.png
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
mmproj: llama-cpp/mmproj/Qwopus3.6-35B-A3B-Coder-MTP-Q4_K_M/mmproj-F32.gguf
options:
- use_jinja:true
- spec_type:draft-mtp
- spec_n_max:6
- spec_p_min:0.75
parameters:
model: llama-cpp/models/Qwopus3.6-35B-A3B-Coder-MTP-Q4_K_M/Qwopus3.6-35B-A3B-Coder-MTP-Q4_K_M.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/Qwopus3.6-35B-A3B-Coder-MTP-Q4_K_M/Qwopus3.6-35B-A3B-Coder-MTP-Q4_K_M.gguf
sha256: c283cd2321a3cb4c6e7faf9481ac7d946913e4f02e20172eb2872112f567d8d4
uri: https://huggingface.co/Jackrong/Qwopus3.6-35B-A3B-Coder-MTP-GGUF/resolve/main/Qwopus3.6-35B-A3B-Coder-MTP-Q4_K_M.gguf
- filename: llama-cpp/mmproj/Qwopus3.6-35B-A3B-Coder-MTP-Q4_K_M/mmproj-F32.gguf
sha256: 5c82c8095717b39f29c88ebfec3607a10307785b1e14a87744603d6c582cd497
uri: https://huggingface.co/Jackrong/Qwopus3.6-35B-A3B-Coder-MTP-GGUF/resolve/main/mmproj-F32.gguf
- name: "ornith-1.0-9b-mtp"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/protoLabsAI/Ornith-1.0-9B-MTP-GGUF
description: |
[](https://deep-reinforce.com/ornith.html)
# Ornith-1.0-9B
Aloha! 🌺 Today, we are releasing Ornith-1.0, a self-improving family of open-source models for agentic coding.
Highlights:
- **State-of-the-Art Coding Agents**: Available in 9B-Dense, 31B-Dense, 35B-MoE, and 397B-MoE (post-trained on top of Gemma 4 and Qwen 3.5), achieving state-of-the-art performance among open-source models of comparable size on coding benchmarks such as Terminal-Bench 2.1, SWE-Bench, NL2Repo and OpenClaw.
- **Self-Improving Training Framework**:  Ornith-1.0 employs RL to learn to generate not only solution rollouts, but also the scallfold that drive those rollouts. By jointly optimizing the scaffold and the resulting solution, the model discovers better search trajectories and generates higher-quality solutions.
- **Licence**: MIT licensed, globally accessible, and free from regional limitations.
## Ornith 1.0 9B
This model card documents **Ornith-1.0-9B**, the most lightweight member of the Ornith family, designed for efficient single-GPU deployment.
### Benchmarks
Ornith-1.0-9B
Qwen3.5-9B
Qwen3.5-35B
Gemma4-12B
Gemma4-31B
Agentic Coding
...
description: "[](https://deep-reinforce.com/ornith.html)\n\n# Ornith-1.0-9B\n\nAloha! \U0001F33A Today, we are releasing Ornith-1.0, a self-improving family of open-source models for agentic coding.\n\nHighlights:\n\n - **State-of-the-Art Coding Agents**: Available in 9B-Dense, 31B-Dense, 35B-MoE, and 397B-MoE (post-trained on top of Gemma 4 and Qwen 3.5), achieving state-of-the-art performance among open-source models of comparable size on coding benchmarks such as Terminal-Bench 2.1, SWE-Bench, NL2Repo and OpenClaw.\n - **Self-Improving Training Framework**:  Ornith-1.0 employs RL to learn to generate not only solution rollouts, but also the scallfold that drive those rollouts. By jointly optimizing the scaffold and the resulting solution, the model discovers better search trajectories and generates higher-quality solutions.\n - **Licence**: MIT licensed, globally accessible, and free from regional limitations.\n\n## Ornith 1.0 9B\n\nThis model card documents **Ornith-1.0-9B**, the most lightweight member of the Ornith family, designed for efficient single-GPU deployment.\n\n### Benchmarks\n\nOrnith-1.0-9B\nQwen3.5-9B\nQwen3.5-35B\nGemma4-12B\nGemma4-31B\n\nAgentic Coding\n\n...\n"
license: "mit"
tags:
- llm
@@ -36014,7 +36024,7 @@
files:
- filename: parakeet-tdt-0.6b-ja.gguf
uri: huggingface://cstr/parakeet-tdt-0.6b-ja-GGUF/parakeet-tdt-0.6b-ja.gguf
sha256: a9c43116b180b8a2ada2771ac829cf751b9e73adcbe69b7c8379593f9e5da31e
sha256: 374eb0132eebaec4df77a9631cbbeb03790be48a4a517f6cc8e8bdb38fe9a584
- name: parakeet-tdt-1.1b-crispasr
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:
@@ -36473,7 +36483,7 @@
files:
- filename: vibevoice-realtime-0.5b-q4_k.gguf
uri: huggingface://cstr/vibevoice-realtime-0.5b-GGUF/vibevoice-realtime-0.5b-q4_k.gguf
sha256: e3244986d8939a9a8f65701196efbfe3f8b81afd307b29f434fe259b9c411ef1
sha256: 483e1922a9077e3fc66b7947a4d6fee3dfd8edc30afde3410efa5bb386bc0392
- name: chatterbox-tts-crispasr
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:

View File

@@ -461,10 +461,7 @@ func (p *RuleParser) parse(arena *Arena, ctx *ParseContext, start int) ParseResu
if result.Type != Fail {
text := ""
if result.Start < len(ctx.Input) {
end := result.End
if end > len(ctx.Input) {
end = len(ctx.Input)
}
end := min(result.End, len(ctx.Input))
text = ctx.Input[result.Start:end]
}
@@ -514,10 +511,7 @@ func (p *TagParser) parse(arena *Arena, ctx *ParseContext, start int) ParseResul
if result.Type != Fail {
text := ""
if result.Start < len(ctx.Input) {
end := result.End
if end > len(ctx.Input) {
end = len(ctx.Input)
}
end := min(result.End, len(ctx.Input))
text = ctx.Input[result.Start:end]
}

View File

@@ -36,6 +36,10 @@ type LocalAIClient interface {
DeleteModel(ctx context.Context, name string) error
EditModelConfig(ctx context.Context, name string, patch map[string]any) error
ReloadModels(ctx context.Context) error
// LoadModel pre-loads a model into memory by name (the inverse of shutting
// it down). For a realtime pipeline model every configured sub-model is
// loaded; it returns the model names that became resident.
LoadModel(ctx context.Context, model string) ([]string, error)
ImportModelURI(ctx context.Context, req ImportModelURIRequest) (*ImportModelURIResponse, error)
// ---- Model aliases ----

View File

@@ -49,6 +49,7 @@ var toolToHTTPRoute = map[string]string{
ToolDeleteModel: "POST /models/delete/:name",
ToolEditModelConfig: "PATCH /api/models/config-json/:name",
ToolReloadModels: "POST /models/reload",
ToolLoadModel: "POST /backend/load",
ToolInstallBackend: "POST /backends/apply",
ToolUpgradeBackend: "POST /backends/upgrade/:name",
ToolToggleModelState: "PUT /models/toggle-state/:name/:action",

View File

@@ -35,6 +35,7 @@ type fakeClient struct {
setAlias func(string, string) error
listAliases func() ([]AliasInfo, error)
reloadModels func() error
loadModel func(string) ([]string, error)
listBackends func() ([]Backend, error)
listKnownBackends func() ([]schema.KnownBackend, error)
installBackend func(InstallBackendRequest) (string, error)
@@ -169,6 +170,14 @@ func (f *fakeClient) ReloadModels(_ context.Context) error {
return nil
}
func (f *fakeClient) LoadModel(_ context.Context, model string) ([]string, error) {
f.record("LoadModel", model)
if f.loadModel != nil {
return f.loadModel(model)
}
return []string{model}, nil
}
func (f *fakeClient) ListBackends(_ context.Context) ([]Backend, error) {
f.record("ListBackends", nil)
if f.listBackends != nil {

View File

@@ -338,6 +338,16 @@ func (c *Client) ReloadModels(ctx context.Context) error {
return c.do(ctx, http.MethodPost, routeModelsReload, nil, nil)
}
func (c *Client) LoadModel(ctx context.Context, model string) ([]string, error) {
// On a load failure the endpoint returns a non-2xx whose body (carrying the
// per-sub-model failure detail) is folded into the HTTPError by c.do.
var resp schema.ModelLoadResponse
if err := c.do(ctx, http.MethodPost, routeBackendLoad, map[string]string{"model": model}, &resp); err != nil {
return nil, err
}
return resp.Loaded, nil
}
// ---- Model aliases ----
// SetAlias is swap-first: it PATCHes the alias config (a deep-merge that

View File

@@ -19,6 +19,7 @@ const (
routeModelImport = "/models/import"
routeAliases = "/api/aliases"
routeModelsReload = "/models/reload"
routeBackendLoad = "/backend/load"
routeBackends = "/backends"
routeBackendsKnown = "/backends/known"
routeBackendsApply = "/backends/apply"

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