Compare commits

...

32 Commits

Author SHA1 Message Date
localai-org-maint-bot
a8fadc535a fix(vllm-cpp): pass Darwin warning flag to Objective-C
The Metal source that triggers Apple Clang's constant-folding diagnostic is compiled as Objective-C. Pass the targeted suppression through CMAKE_OBJC_FLAGS as well as the C++ language flags so vllm.cpp's -Werror no longer promotes it.

Assisted-by: Codex:gpt-5
2026-08-04 07:07:49 +00:00
localai-org-maint-bot
c7c6edfa67 fix(vllm-cpp): disable Darwin folding diagnostic
The previous no-error flag is overridden by vllm.cpp's later target-local -Werror. Disable only the Apple Clang folding diagnostic so the Metal build can complete while all other warnings remain fatal.

Assisted-by: Codex:gpt-5 [systematic-debugging]
2026-08-04 07:07:49 +00:00
localai-org-maint-bot
20e537b10b fix(vllm-cpp): tolerate Apple Clang folding warning
Keep the GNU constant-folding diagnostics visible on Darwin without allowing vllm.cpp's global -Werror to fail the Metal backend build.

Assisted-by: Codex:gpt-5 [systematic-debugging]
2026-08-04 07:07:49 +00:00
Ettore Di Giacinto
03e4b3b600 chore(vllm-cpp): bump the vllm.cpp pin to main; GGUF speculative decoding is real now
The pin sat at f384edcd while vllm.cpp main moved a long way. The ABI is
unchanged at v10 and both POD structs are field-identical to the pinned commit
(verified by diffing vllm_model_params and vllm_sampling_params across the
range), so the Go mirror needs no edit and this is a clean bump.

What it picks up matters for this backend:

- MTP speculative decoding from a GGUF target, gated end to end on GPU.
- DFlash speculative decoding with a GGUF draft AND a GGUF target.
- NVFP4 GGUF: dequant, plus a native fp4 compute path for dense and
  full-attention projections. On the 27B that closed a cross-container
  divergence entirely (the GGUF and safetensors builds of the same
  quantization run now emit identical tokens) and halved peak RSS.
- A real engine fix: the GDN speculative state gather/scatter was mis-striding
  the widened conv row, so speculation silently corrupted the target's own
  recurrent state on CPU.

Docs corrected accordingly. The section previously told users that mtp and
dflash are rejected on a .gguf target and called it a gap in the engine's GGUF
loader. That is no longer true, and leaving it would send people to safetensors
for no reason. A head-less GGUF is still refused, and the text now says so with
the actual cause.

The real-library ABI handshake was re-run against a libvllm.so built at the
exact pinned commit rather than a stale one: 43 specs pass, reported ABI 10.
make lint clean.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
2026-08-04 07:07:49 +00:00
Ettore Di Giacinto
627ace6f22 feat(vllm-cpp): move to vllm.cpp ABI v10 and expose jump-forward decoding
vllm.cpp landed ABI v10 on main while this branch was open. The backend's
runtime handshake refuses any library whose reported ABI differs from the
mirrors', so the pin and the Go PODs move together or not at all.

v10 appends one int32, `enable_jump_forward`, AFTER the v9 fields. Nothing else
in the config surface changed: the SGLang reconciliation that carried it
explicitly dropped its own duplicate scheduler_policy int in favour of the v9
`scheduling_policy` string this branch already wires, and a diff of EngineParams
and the server flags across the window turns up jump forward and nothing else.

So the exposure is one new knob, `engine_args.enable_jump_forward` - SGLang's
grammar-speed subset, which emits grammar-forced tokens without spending a model
step and therefore only affects constrained requests.

It is the SECOND tri-state on this struct, and it repeats the trap the first one
had: 0 is not "off", it is "defer" (to the environment here, to the model
capability for prefix caching), so an explicit `false` has to reach the engine as
2. The bool->tri-state helper and the log renderer are now shared rather than
duplicated per field, and named for the encoding instead of for prefix caching,
since the next tri-state will want them too. The docs say this outright, because
"omitting the key" and "setting it false" being different is not guessable.

The Go mirror grows the field plus an EXPLICIT trailing pad: the struct is
8-aligned and now ends on a lone int32, so it is 88 bytes rather than 84. The
offset assertions cover it, and the real-library handshake spec (VLLM_CPP_LIBRARY
against a CPU libvllm.so built at the new pin) confirms the version agrees:
43 specs pass, ABI reported 10.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
2026-08-04 07:07:40 +00:00
Ettore Di Giacinto
c251e22d5b fix(vllm-cpp): resolve the DFlash draft path instead of missing the HF cache
The engine resolves speculative_config.model against a directory containing
config.json, or against ~/.cache/huggingface/hub/models--<org>--<repo>/
snapshots/*, and it never downloads. LocalAI keeps models in its own directory,
so the repo-id spelling the vLLM docs teach - "z-lab/Qwen3.6-27B-DFlash" - misses
the HF cache and dies deep inside the load with "draft checkpoint not found",
which reads like a broken checkpoint rather than a model nobody fetched.

Resolve it before the load call: the reference as given, then its last path
segment under LocalAI's models dir (what LocalAI's own downloader produces),
then the whole reference under the models dir. When none resolve, fail there
naming both what was asked for and every location tried, so the message says
what to do about it.

mtp and ngram pass through untouched - neither has a separate draft checkpoint.
A speculative_config that does not parse also passes through, because the engine
owns config validation and produces the better error.

Docs also gain the two limits that were missing and are easy to lose an
afternoon to: speculation is Qwen3.5/3.6-only at this engine pin regardless of
format, and mtp/dflash need a safetensors target. The latter is a gap in the
engine's GGUF loader rather than a property of GGUF - the format carries MTP
weights fine, llama.cpp reads them as nextn.* tensors plus a
<arch>.nextn_predict_layers key - so the docs say that rather than implying GGUF
cannot express it.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
2026-08-04 07:07:24 +00:00
Ettore Di Giacinto
fd2acf3ec4 test(vllm-cpp): catch ABI pin/mirror skew without model weights
The Go PODs in govllmcpp.go are hand-written against one VLLM_ABI_VERSION and
the Makefile pins the vllm.cpp commit that produces it. Nothing checked those
two agree short of the e2e suite, which needs a model to run at all, so a pin
bump could land with a stale mirror and only fail at a user's first load.

VLLM_CPP_LIBRARY now drives a handshake spec that dlopens a built libvllm,
binds every symbol, and compares the library's reported ABI against the
mirrors'. No weights required.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
2026-08-04 07:07:11 +00:00
Ettore Di Giacinto
a3ee37d6a1 feat(vllm-cpp): wire the full engine config surface through engine_args
The vllm-cpp backend could configure four of the engine's knobs - block size,
KV block count, max sequence length and max concurrent sequences - out of a
config surface that is considerably larger. Speculative decoding, prefix
caching, the chunked-prefill token budget, the scheduling policy and the
external KV connector were all reachable from vllm.cpp's own HTTP server and
from nothing LocalAI could write in a model config.

Part of that gap was the C ABI itself, which carried strictly less than
EngineParams does; that is fixed upstream in vllm.cpp ABI v9 (this bumps the pin
to it). The rest was here: the backend parsed a flat `options:` list with five
recognised keys and had no way to express a nested JSON document at all.

Configuration now goes through `engine_args:`, the same map the vLLM and SGLang
backends already take, with keys spelled as vLLM's own CLI flags - so a
`speculative_config` or `kv_transfer_config` block written for vLLM works
verbatim:

  engine_args:
    max_num_batched_tokens: 8192
    enable_prefix_caching: true
    scheduling_policy: lpm
    speculative_config:
      method: dflash
      model: z-lab/Qwen3.6-27B-DFlash
      num_speculative_tokens: 4
    kv_transfer_config:
      kv_connector: LMCacheConnector
      kv_role: kv_both
      kv_connector_extra_config: {host: 127.0.0.1, port: 65432}

The `options:` list keeps working, and now reads every key too, so no existing
config breaks; engine_args wins where both set the same key.

Two details worth calling out. `enable_prefix_caching: false` maps to the ABI's
force-OFF state (2), not the 0 that means "let the model capability decide" -
collapsing them would silently turn the cache ON for the dense architectures
that default it on. And cSamplingParams grows the ABI v8 logits-processor tail:
LocalAI installs no processor, but the C side reads those fields off the pointer
we hand it, so a Go struct that stopped short would have had the engine read 16
bytes past our allocation and call whatever sat there.

The importer gets the safetensors counterpart of the llama-cpp MTP hook: a
`vllm-cpp` import of a HuggingFace repo probes config.json and, on a checkpoint
that declares an MTP head, writes speculative_config {method: mtp} into the
generated engine_args. DFlash draft repositories are detected and refused with a
warning rather than configured as standalone models, since a drafter cannot
serve alone. The llama-cpp importer stops applying its own `spec_type:draft-mtp`
options when the chosen backend is vllm-cpp: those are llama.cpp option keys
vllm-cpp does not read, and vllm.cpp rejects MTP over a GGUF source anyway
because the `mtp.*` draft tensors only exist in the safetensors checkpoint.

docs/content/features/text-generation.md gains a vllm.cpp section - the backend
had no documentation page at all - covering the engine_args table, all three
speculative methods, LMCache, and the legacy options list.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
2026-08-04 07:07:11 +00:00
mudler's LocalAI [bot]
211aa0a536 chore: ⬆️ Update mudler/vllm.cpp to a42b8187caff02c570c28e19e4dc2b1d7f55ed14 (#11174)
⬆️ Update mudler/vllm.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-04 08:17:41 +02:00
mudler's LocalAI [bot]
c86b3b207b chore: ⬆️ Update ikawrakow/ik_llama.cpp to 60389410a1ff01f9d37dcc6261db33b3183bdea2 (#11331)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-04 08:17:14 +02:00
mudler's LocalAI [bot]
62316e52a9 chore: ⬆️ Update 0xShug0/audio.cpp to 4e3aea2fd99aeaa5924e71c51eb2793846045332 (#11332)
⬆️ Update 0xShug0/audio.cpp

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

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-04 08:16:49 +02:00
mudler's LocalAI [bot]
8b667cd1ce chore: ⬆️ Update ggml-org/whisper.cpp to 64d57d3df5c8dacee098577257edcaa154bf5ef3 (#11326)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-04 08:16:36 +02:00
dependabot[bot]
93fe086798 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#11338)
Bumps the npm_and_yarn group with 2 updates in the /core/http/react-ui directory: [@hono/node-server](https://github.com/honojs/node-server) and [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `@hono/node-server` from 1.19.14 to 2.0.12
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.14...v2.0.12)

Updates `brace-expansion` from 1.1.12 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.18)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 2.0.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 08:16:22 +02:00
mudler's LocalAI [bot]
2e14511fe2 docs(blog): add release write-ups for 3.10 through 4.3 (#11330)
The blog has a deep post for 4.8 and a history post that covers the earlier
releases at summary altitude, but nothing in between. These five fill that
gap in the same shape as what-landed-in-localai-4-8: what the release was
for, runnable examples, and the limits that apply.

Every endpoint, CLI flag, env var and gallery entry is verified against the
matching release tag rather than taken from the release notes. That caught
two paths the published 3.10.0 notes got wrong: tracing is /api/traces, not
/api/v1/trace, and a stored response is fetched from /v1/responses/:id, not
/api/v1/responses/{response_id}.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-04 00:13:45 +02:00
mudler's LocalAI [bot]
88fdda6211 chore(model-gallery): ⬆️ update checksum (#11327)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-03 23:17:12 +02:00
mudler's LocalAI [bot]
f447faf08d chore: ⬆️ Update ggml-org/llama.cpp to 221f0f6356efe2260023208365705ec5d5a7c8f5 (#11303)
⬆️ 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-08-03 23:03:39 +02:00
mudler's LocalAI [bot]
6e7c0a4df8 blog, website: edit out the AI writing tells readers called out on HN (#11324)
* blog: rewrite the engines post without the AI tells

The HN thread on this post (item 49125065) spent most of its comments on the
writing rather than the engines. Readers quoted specific lines back as tells.
This is the same post with the same numbers, edited against the updated
no-ai-slop skill.

Every figure, table and link is unchanged, except that "27% of the memory"
is now the underlying 363 MB against 1328 MB from the table.

Two substantive framing fixes, both from the reply draft in
hn-reply-engines-post.md:

- vllm.cpp is no longer implied to be a speed win. The table is a tie, the
  result is the install size, and the post now says so before a reader has to
  work it out and post about it.
- Added one line on the language mix. Readers took the C++/Python/Go tree as
  incoherence rather than as a Go core with per-ecosystem backends.

Cut throughout: the ledger metaphor ("what those ports buy", "not paid for in
throughput"), unearned framing ("the honest reading is", "has nothing to do
with"), the shape summary ("that is the general shape of these wins"),
confident deference ("people who are better at those models than we are"),
self-grading numbers ("a good result for a 66 MiB binary"), verbless
comparisons, three of the four exactness idioms, and the aphoristic headings
and verdicts. The double-tricolon summary is one plain clause now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* blog, website: same anti-slop sweep over the rest of the site

One-by-one pass over the other four posts and the site templates, with the
same rules used on the engines post. All figures, tables, links and PR
numbers are unchanged everywhere; the edits are to prose only.

apex-moe-quantization: ledger metaphors were the main issue, eight uses of
buy/cost/pay/spend for things that are not money. Also "the honest reading
is", "that is the comparison that matters", and two section-ending aphorisms
("Size is a speed knob as much as a memory knob", "Q6_K is the ceiling worth
paying for").

localai-since-march-2023: light touch, this one already reads like a person.
Removed "the curve is not the point", a "not the feature list, but the four
decisions" contrast, and two "X is what made / is the piece that" forms.

parakeet-cpp-asr-on-cpu: six exactness idioms across one post, "byte for
byte" twice, "character for character" twice, "byte-identical" twice and
"bit-identical" once, including in the title. Down to one, kept where the
precision is load-bearing. Also the "what end-of-utterance detection buys
you" heading and the "we say so rather than averaging it away" flex.

what-landed-in-localai-4-8: no changes. It is dense, flat and ends every
section on a PR number or a plain fact, which is the shape the other posts
should look like.

Site templates: "Most backends wrap somebody else's engine. These do not."
was the same contrast the engines post opened with. Also "Not a degraded mode
that technically runs", "A port only ships once it matches the original",
"Speed is the part we then go and win ... not a marketing run", and the last
"byte for byte" on the landing page.

Hugo builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* website: it is eighteen engines, not nineteen

Three places said nineteen: the /engines/ page description, the JUL 2026
timeline entry on the landing page, and the header comment in
data/engines.yaml.

Eighteen is right, confirmed two ways. The "Backends built by us" table in
the README has exactly 18 rows, and data/engines.yaml has 19 entries of which
one is apex-quant, which is a quantization recipe rather than an engine. The
two lists otherwise match name for name.

The yaml comment is the likely origin: it read "the nineteen native engines
the LocalAI team wrote, and the one quantization recipe that feeds them",
which counts apex-quant twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 23:03:25 +02:00
mudler's LocalAI [bot]
e2311045d3 fix(mcp): drop the duplicated scheduling methods on stubClient (#11323)
master does not compile:

    vet: core/http/endpoints/mcp/localai_assistant_test.go:157:19:
    method stubClient.ListScheduling already declared at
    core/http/endpoints/mcp/localai_assistant_test.go:87:19

Two fixes for the same breakage landed. The four Scheduling methods were
already present at lines 87-99, in interface order after ListNodes, by
the time #11318 merged; #11318 appended its own copy after
GetRouterDecisions. The two blocks sit in different parts of the file, so
git merged both without a conflict and nothing flagged it.

Remove the appended copy and keep the one in interface order. Pure
deletion, no behaviour change.

Verified: go vet clean on ./core/http/endpoints/mcp/, and
go test ./core/http/endpoints/mcp/ passes.


Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 22:53:04 +02:00
Ettore Di Giacinto
6bdb04ab5d docs: point the News page at the blog instead of a stale highlights list
The News page kept a hand-maintained "Highlights" list that had drifted:
it was missing all of 2025, duplicated the README's own news list, and
linked /features/middleware/ for a page that lives at operations/.

Both of its jobs already have owners. website/content/blog/ carries the
release write-ups and engineering notes, and GitHub Releases carries the
full changelog. Replace the list with a pointer at those two, so there is
one place to update instead of three.

The page keeps its url and front matter, so /docs/basics/news/ and the
root /basics/news/ redirect that .github/ci/gen-redirects.sh generates
both keep resolving.

Also drop the two contributor instructions in .agents that told authors
to add a whats-new.md bullet per feature: announcing a capability is the
release blog post's job, per .agents/preparing-a-release.md.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Write] [Bash]
2026-08-03 20:29:50 +00:00
mudler's LocalAI [bot]
bd076376be fix(ci): install the Go the module asks for when building the site (#11322)
Deploy site to GitHub Pages failed on five of the last eight master
pushes, always in the build job before Hugo runs:

    Setup go version spec 1.22
    ...
    go: downloading go1.26.0 (linux/amd64)
    go: download go1.26.0: golang.org/toolchain@v0.0.1-go1.26.0.linux-amd64:
        Get "https://proxy.golang.org/...": connect: network is unreachable
    ##[error]Command failed: go env GOPATH

The workflow pinned setup-go to 1.22 while go.mod declares go 1.26.0, so
the `go run ./.github/ci/modelslist.go` step that generates the gallery
page had to fetch the real toolchain from proxy.golang.org first. That
fetch is not reliably reachable from the runner, which is why the deploy
alternated between passing and failing rather than failing outright.

Track go.mod instead of a literal. The version the module needs is then
installed directly and there is no toolchain download to fail.

This matters beyond CI noise: the docs and the site, including the
release blog post, ship through this workflow.

Scoped deliberately to gh-pages, the workflow with the observed failure.
test-extra.yml pins 1.25.4 in a dozen places and is below go.mod for the
same reason, so those jobs also download a toolchain, but they are
currently green and rewriting twelve pins on a hunch risks more than it
fixes. Worth a follow-up.


Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:18:56 +02:00
localai-org-maint-bot
d28ccf32b5 gallery: add Qwen3.6 14B FableVibes variants (#11317)
Add Q4_K_M and Q8_0 llama.cpp entries with the shared Q8_0 multimodal projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 19:02:10 +02:00
mudler's LocalAI [bot]
95bd59d78e fix(mcp): teach the assistant test stub the scheduling methods (#11318)
#11228 added ListScheduling, GetScheduling, SetScheduling and
DeleteScheduling to localaitools.LocalAIClient but did not update
stubClient, the hand-written test double in the mcp endpoints package.
The package therefore fails to typecheck, which takes out both lint and
tests on master:

    cannot use stubClient{} as localaitools.LocalAIClient value in
    argument to h.Initialize: stubClient does not implement
    localaitools.LocalAIClient (missing method DeleteScheduling)

Red on 8f74f74b, fd4ec083 and 8a68f357; green on cd62e8ff, the commit
before.

Add the four methods with the same inert bodies the rest of the stub
uses. The real implementations are covered in the localaitools suites;
this double only exists so the holder can be constructed.


Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:01:40 +02:00
mudler's LocalAI [bot]
1741df0bf1 fix(ui): scale the chrome audit's timeout to the number of routes it walks (#11319)
chrome-audit.spec.js walks 25 routes in a single test, and has been the
UI E2E suite's failure on 5 of the last 6 master runs. It always dies the
same way, at the 30s per-test default:

    Test timeout of 30000ms exceeded.
    Error: page.waitForTimeout: Test timeout of 30000ms exceeded.
      19 |     await page.goto(route)
    > 20 |     await page.waitForTimeout(400)

The spec is new in 5cb0c1a8; the commit before it was green, and every
run since has been red on this file.

The failure is cumulative rather than one bad route. Across those runs
the clock runs out at line 19, 20 or 21 depending on where the loop
happens to be, and the timeout lands on waitForTimeout rather than on
goto, which is what running out of budget looks like as opposed to a
navigation that hangs. 30s over 25 routes is ~1.2s each, including a
deliberate 400ms settle, so there is very little headroom to begin with.

Give the test a budget proportional to its work: six seconds a route.
That absorbs a slow runner and still fails promptly if a route genuinely
hangs.

Verified: the spec passes on the current UI in 12.2s solo, and the full
suite passes 418 at 8 workers locally. What I could NOT do is reproduce
the CI timeout on this machine, which has 20 cores against the runner's
2 to 4; under synthetic CPU load it still finished in 13.5s. So the fix
is argued from the CI signature and the arithmetic, not from a local
repro, and the proof is this spec going green on the hosted runner.

Note test.setTimeout() has to be called inside the test body. At module
scope Playwright rejects it with "test.setTimeout() can only be called
from a test".


Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:01:06 +02:00
mudler's LocalAI [bot]
b6d2e94153 fix(sglang): bound cuda-tile below the 1.6 prereleases (#11320)
Every CUDA sglang image failed in the 2026-08-02 full-matrix rebuild:
-gpu-nvidia-cuda-12-sglang, -gpu-nvidia-cuda-13-sglang and
-nvidia-l4t-cuda-13-arm64-sglang, all with the same build error.

    Building cuda-tile==1.6.0rc3
    x Failed to build `cuda-tile==1.6.0rc3`
      ModuleNotFoundError: No module named 'wheel_stub'
    hint: `cuda-tile` (v1.6.0rc3) was included because `sglang` (v0.5.16)
          depends on `flashinfer-python` (v0.6.14) which depends on `cuda-tile`

This is the failure mode requirements-cublas1{2,3}-after.txt already
carries an nvidia-modelopt bound for, arriving through a different
package. install.sh passes a global --prerelease=allow, which is
load-bearing for flash-attn-4, so an unbounded dependency resolves to a
prerelease; cuda-tile 1.6.0rc3's build backend imports wheel_stub without
declaring it in build-system.requires; --no-build-isolation means nothing
provides it, and the build dies.

Nothing in this repo changed. cuda-tile published 1.6.0rc1 and rc3 and
the weekly cron picked them up, which is the drift that job exists to
catch.

Bound the one package rather than dropping the global flag, matching the
existing precedent. 1.5.0 is the newest stable release, so <1.6 takes the
last good one. l4t13 gets the same bound: it installs plain sglang rather
than sglang[all], but flashinfer-python is a dependency of both.

NOT VERIFIED LOCALLY: reproducing this needs a CUDA docker build, which
this machine cannot run. The diagnosis is from the CI log and the
resolver's own hint, and the change follows a fix already proven in these
same files. CI on this PR is the check that matters.


Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:00:28 +02:00
mudler's LocalAI [bot]
a0f7faaa2a fix(sycl): stop building the ggml CPU variant matrix with icpx (#11321)
Since #11255 and #11276 every GPU image also builds ggml's CPU_ALL_VARIANTS
matrix, so a partial offload uses the host's SIMD kernels. That works
everywhere except SYCL, where the Makefile compiles the whole tree with
icpx -fsycl: icpx never finishes ggml-cpu/arch/x86/repack.cpp at
-march=sapphirerapids. In run 30765516644 both sycl_f16 and sycl_f32 stopped
at that translation unit and sat there for 5h30m with a single compile in
flight until GitHub killed the job at its 6h limit, and turboquant's f16 job
lost its runner outright. gcc compiles the same file in seconds in the vulkan
and CPU jobs of the same run, so the CPU variant matrix is only unbuildable
under icpx.

Route SYCL back to the portable fallback binary, which is what these images
shipped before #11255. run.sh already prefers *-cpu-all when present and falls
back otherwise, so nothing else has to change.


Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:00:00 +02:00
localai-org-maint-bot
133c546c3f feat(api): add text moderation endpoint (#11316)
* feat(api): add text moderation endpoint

Add an OpenAI-compatible /v1/moderations endpoint backed by constrained local text generation. Register its auth and discovery surfaces, document the text-only MVP, and cover response shaping and access control.

Assisted-by: Codex:gpt-5

* test(mcp): update assistant client stub

Keep the LocalAI Assistant holder test stub aligned with the scheduling methods added to LocalAIClient so repository-wide type checking succeeds.\n\nAssisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 18:03:46 +02:00
Pete
8a68f3571c feat(api): add POST /v1/images/upscale endpoint (#10227)
* feat(api): add POST /v1/images/upscale endpoint

Add a new image upscaling endpoint that accepts a source image and
returns an upscaled version. Supports selectable upscaler models
(e.g. realesrgan) and a configurable scale factor (2x or 4x).

- backend.proto: add UpscaleImage RPC and UpscaleImageRequest message
- pkg/grpc: implement UpscaleImage in Backend interface, client, server
  and embed shim
- core/backend/upscale.go: new backend helper (mirrors ImageGeneration)
- core/http/endpoints/openai/upscale.go: new multipart/form-data handler
- core/http/routes/openai.go: register POST /v1/images/upscale
- core/http/auth/features.go: gate upscale routes under FeatureImages
- backend/python/diffusers/backend.py: implement UpscaleImage — uses
  diffusers upscale pipeline when loaded, falls back to Lanczos resize

* fix(grpc): add UpscaleImage stub to Base backend

All Go backends embedding Base now satisfy the AIModel interface
without needing to implement UpscaleImage explicitly.

* fix(images): complete upscale endpoint integration

Store generated upscales under the served images directory, validate scale factors, document and advertise the endpoint, and add a functional Stable Diffusion x4 gallery model.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 15:27:22 +02:00
localai-org-maint-bot
fd4ec083b9 feat(downloads): add resume-safe pause action (#11222)
Give gallery operations distinct pause and cancel paths. Pause preserves partial download data so reinstalling the same model or backend resumes through HTTP Range, while cancel keeps its destructive semantics. Surface the action in the Activity UI and document the API behavior.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 15:25:23 +02:00
Owen Adirah
8f74f74b10 feat(mcp): expose scheduling admin tools (#11228)
* feat(mcp): add scheduling client contracts

Assisted-by: Hephaestus:openai/gpt-5.5
Signed-off-by: Owen Adirah <owenadira@gmail.com>

* feat(mcp): add scheduling HTTP client support

Assisted-by: Hephaestus:openai/gpt-5.5
Signed-off-by: Owen Adirah <owenadira@gmail.com>

* feat(mcp): add in-process scheduling stubs

Assisted-by: Hephaestus:openai/gpt-5.5
Signed-off-by: Owen Adirah <owenadira@gmail.com>

* feat(mcp): register scheduling tools

Assisted-by: Hephaestus:openai/gpt-5.5
Signed-off-by: Owen Adirah <owenadira@gmail.com>

* test(mcp): map scheduling tools to REST routes

Assisted-by: Hephaestus:openai/gpt-5.5
Signed-off-by: Owen Adirah <owenadira@gmail.com>

* docs(mcp): document scheduling assistant tools

Assisted-by: Hephaestus:openai/gpt-5.5
Signed-off-by: Owen Adirah <owenadira@gmail.com>

* fix(mcp): wire in-process scheduling

Use an explicit MCP scheduling DTO and route in-process scheduling calls through the distributed node registry so the embedded assistant matches the REST scheduling surface.

Assisted-by: Hephaestus:openai/gpt-5.5
Signed-off-by: Owen Adirah <owenadira@gmail.com>

* fix(mcp): narrow scheduling dto

Assisted-by: Hephaestus:openai/gpt-5.5 [opencode]
Signed-off-by: Owen Adirah <owenadira@gmail.com>

---------

Signed-off-by: Owen Adirah <owenadira@gmail.com>
2026-08-03 15:24:29 +02:00
localai-org-maint-bot
cd62e8ff18 gallery: add Nemotron 3 embedding models (#11314)
Add multilingual 1B and 8B Q4_K_M GGUF embedding entries and link them as variants for automatic memory-aware selection.

Assisted-by: Codex:gpt-5 [web]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 15:23:08 +02:00
localai-org-maint-bot
af98e76f84 fix(gallery): remove broken DeepSeek V4 0731 entry (#11313)
fix(gallery): repair DeepSeek V4 0731 entry

Use the official single-file ggml-org MXFP4 artifact with its verified SHA256 and route it through llama.cpp instead of treating an unsloth repository page as a ds4 model file.

Assisted-by: Codex:gpt-5 [Hugging Face API]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 13:25:19 +02:00
119 changed files with 4330 additions and 223 deletions

View File

@@ -304,7 +304,9 @@ React pages that want to filter the ModelSelector by capability import this symb
### 4. `docs/content/` (user-facing documentation)
A new capability deserves its own page under `docs/content/features/`, plus cross-links from related features and an entry in `docs/content/whats-new.md`. See the pattern used by `face-recognition.md` / `object-detection.md`.
A new capability deserves its own page under `docs/content/features/`, plus cross-links from related features. See the pattern used by `face-recognition.md` / `object-detection.md`.
Announcing it is the release's job, not this page's: the capability gets covered in the release blog post under `website/content/blog/`. See [preparing-a-release.md](preparing-a-release.md). `docs/content/whats-new.md` is only a pointer at the blog and GitHub Releases, so there is nothing to add there.
## Path protection rules
@@ -334,7 +336,7 @@ When adding a new endpoint:
- [ ] Swagger block on the handler: `@Summary`, `@Tags`, `@Param`, `@Success`, `@Router`
- [ ] If new capability area (new swagger tag): entry in `instructionDefs` in `core/http/endpoints/localai/api_instructions.go` + test count bumped in `api_instructions_test.go`
- [ ] If new `FLAG_*` usecase flag: matching `CAP_*` symbol exported from `core/http/react-ui/src/utils/capabilities.js`
- [ ] `docs/content/features/<feature>.md` created; cross-links from related feature pages; entry in `docs/content/whats-new.md`
- [ ] `docs/content/features/<feature>.md` created; cross-links from related feature pages; capability covered in the release blog post (see [preparing-a-release.md](preparing-a-release.md))
**Quality**
- [ ] Error responses use `schema.ErrorResponse` format (or `echo.NewHTTPError` with a mapped gRPC status — see the `mapBackendError` helper in `core/http/endpoints/localai/images.go`)

View File

@@ -4,6 +4,17 @@ set -euo pipefail
arch=${1:?target architecture is required}
build_type=${2-}
# SYCL compiles the whole tree with icpx -fsycl, and icpx never finishes
# ggml-cpu/arch/x86/repack.cpp at -march=sapphirerapids: the job sits on that one
# translation unit until GitHub kills it at 6h. gcc builds the same file in
# seconds, so only the SYCL images have to give up the CPU variant matrix.
case "$build_type" in
sycl*)
echo llama-cpp-fallback
exit 0
;;
esac
# GPU arm64 base images do not consistently provide the gcc-14 toolchain needed
# to compile ggml's armv9.2 CPU variants. Keep their portable fallback until the
# builder images can supply that compiler.

View File

@@ -4,6 +4,17 @@ set -euo pipefail
arch=${1:?target architecture is required}
build_type=${2-}
# SYCL compiles the whole tree with icpx -fsycl, and icpx never finishes
# ggml-cpu/arch/x86/repack.cpp at -march=sapphirerapids: the job sits on that one
# translation unit until GitHub kills it at 6h. gcc builds the same file in
# seconds, so only the SYCL images have to give up the CPU variant matrix.
case "$build_type" in
sycl*)
echo turboquant-fallback
exit 0
;;
esac
# GPU arm64 base images do not consistently provide the gcc-14 toolchain needed
# to compile ggml's armv9.2 CPU variants. Keep their portable fallback until the
# builder images can supply that compiler.

View File

@@ -51,7 +51,16 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
# Track go.mod rather than a literal. Pinned at 1.22 this installed a
# toolchain older than the module's `go 1.26.0`, so the `go run` below
# downloaded the real one from proxy.golang.org on every run. That
# fetch is not always reachable from the runner and the deploy failed
# on five of eight consecutive master pushes with:
# go: download go1.26.0: ... connect: network is unreachable
# ##[error]Command failed: go env GOPATH
# Installing the version the module asks for removes the download
# instead of depending on it succeeding.
go-version-file: go.mod
cache: false
- name: Setup Hugo

View File

@@ -195,7 +195,7 @@ For more details, see the [Getting Started guide](https://localai.io/basics/gett
- **August 2025**: MLX, MLX-VLM, Diffusers, llama.cpp now supported on Apple Silicon
- **July 2025**: All backends migrated outside the main binary — [lightweight, modular architecture](https://github.com/mudler/LocalAI/releases/tag/v3.2.0)
For older news and full release notes, see [GitHub Releases](https://github.com/mudler/LocalAI/releases) and the [News page](https://localai.io/basics/news/).
For older news and full release notes, see [GitHub Releases](https://github.com/mudler/LocalAI/releases) and the [blog](https://localai.io/blog/).
## Features
@@ -260,7 +260,7 @@ We also maintain [apex-quant](https://github.com/localai-org/apex-quant), a per-
- [Kubernetes installation](https://localai.io/basics/getting_started/#run-localai-in-kubernetes)
- [Integrations & community projects](https://localai.io/docs/integrations/)
- [Installation video walkthrough](https://www.youtube.com/watch?v=cMVNnlqwfw4)
- [Media & blog posts](https://localai.io/basics/news/#media-blogs-social)
- [Blog: release write-ups, benchmarks and engineering notes](https://localai.io/blog/)
- [Examples](https://github.com/mudler/LocalAI-examples) — including the [realtime voice assistant demo](https://github.com/localai-org/localai-realtime-demo) (Go client for the Realtime API with tool calling)
## Team

View File

@@ -15,6 +15,7 @@ service Backend {
rpc PredictStream(PredictOptions) returns (stream Reply) {}
rpc Embedding(PredictOptions) returns (EmbeddingResult) {}
rpc GenerateImage(GenerateImageRequest) returns (Result) {}
rpc UpscaleImage(UpscaleImageRequest) returns (Result) {}
rpc GenerateVideo(GenerateVideoRequest) returns (Result) {}
rpc Generate3D(Generate3DRequest) returns (Result) {}
rpc AudioTranscription(TranscriptRequest) returns (TranscriptResult) {}
@@ -637,6 +638,12 @@ message GenerateImageRequest {
string ModelIdentity = 13;
}
message UpscaleImageRequest {
string src = 1; // input image path
string dst = 2; // output image path
int32 scale = 3; // upscale factor (e.g. 2 or 4)
}
message GenerateVideoRequest {
string prompt = 1;
string negative_prompt = 2; // Negative prompt for video generation

View File

@@ -9,7 +9,7 @@
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
# rebuild and so the bump bot can see the pin.
AUDIO_CPP_VERSION?=5a8312ef7b8aa7cf14e9a24ac568cabd8725d68a
AUDIO_CPP_VERSION?=4e3aea2fd99aeaa5924e71c51eb2793846045332
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))

View File

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

View File

@@ -1,5 +1,5 @@
LLAMA_VERSION?=a7a6d0d269c896218b6c78e0933bd6a17519d3f6
LLAMA_VERSION?=221f0f6356efe2260023208365705ec5d5a7c8f5
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=

View File

@@ -12,10 +12,11 @@ grep -e "flags" /proc/cpuinfo | head -1
BINARY=llama-cpp-fallback
# CPU images and x86 GPU images ship a single llama-cpp-cpu-all built with ggml
# CPU images and most x86 GPU images ship a single llama-cpp-cpu-all built with ggml
# CPU_ALL_VARIANTS: ggml's backend registry dlopens the best libggml-cpu-*.so for this
# host, so no shell-side AVX probing. GPU arm64 images still ship llama-cpp-fallback
# until their builder toolchains support ggml's complete arm variant matrix.
# until their builder toolchains support ggml's complete arm variant matrix, and so do
# the SYCL images, whose icpx compiler hangs on the sapphirerapids variant.
if [ -e "$CURDIR"/llama-cpp-cpu-all ]; then
BINARY=llama-cpp-cpu-all
fi

View File

@@ -12,11 +12,12 @@ grep -e "flags" /proc/cpuinfo | head -1
BINARY=turboquant-fallback
# CPU images and x86 GPU images ship a single turboquant-cpu-all built with ggml
# CPU images and most x86 GPU images ship a single turboquant-cpu-all built with ggml
# CPU_ALL_VARIANTS: ggml's
# backend registry dlopens the best libggml-cpu-*.so for this host, so no shell-side
# probing. GPU arm64 images still ship turboquant-fallback until their builder toolchains
# support ggml's complete arm variant matrix.
# support ggml's complete arm variant matrix, and so do the SYCL images, whose icpx
# compiler hangs on the sapphirerapids variant.
if [ -e "$CURDIR"/turboquant-cpu-all ]; then
BINARY=turboquant-cpu-all
fi

View File

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

View File

@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
# vllm.cpp version
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
VLLM_CPP_VERSION?=9e1c9025ae61167a3335454d7cc0de6093c21845
VLLM_CPP_VERSION?=a42b8187caff02c570c28e19e4dc2b1d7f55ed14
# The backend consumes only the stable C ABI (libvllm + include/vllm.h), so the
# server, examples and tests of the engine are never built here.
@@ -56,6 +56,12 @@ endif
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
LIB=libvllm.dylib
# Apple Clang diagnoses a pair of constant-folded array bounds in the Metal
# build as a GNU extension. Disable that diagnostic for both Objective-C and
# C++ because vllm.cpp appends target-local -Werror after these global flags.
CMAKE_ARGS+=-DCMAKE_CXX_FLAGS=-Wno-gnu-folding-constant
CMAKE_ARGS+=-DCMAKE_OBJC_FLAGS=-Wno-gnu-folding-constant
CMAKE_ARGS+=-DCMAKE_OBJCXX_FLAGS=-Wno-gnu-folding-constant
else
LIB=libvllm.so
endif

View File

@@ -109,6 +109,16 @@ func (v *VllmCpp) Load(opts *pb.ModelOptions) error {
v.opts = parseOptions(opts)
// A DFlash draft is a second checkpoint the engine opens by path, and the
// engine never downloads one. Resolve it against LocalAI's models directory
// now so a repo-id spelling works, and so a missing draft fails here with an
// actionable message rather than as an HF-cache miss inside the load.
resolvedSpec, err := resolveDraftModelPath(v.opts.speculativeConfig, opts.ModelPath)
if err != nil {
return err
}
v.opts.speculativeConfig = resolvedSpec
mp := defaultModelParams()
if v.opts.blockSize > 0 {
mp.BlockSize = v.opts.blockSize
@@ -116,34 +126,62 @@ func (v *VllmCpp) Load(opts *pb.ModelOptions) error {
if v.opts.numBlocks > 0 {
mp.NumBlocks = v.opts.numBlocks
}
// Sequence-length precedence, narrowest source last: context_size is the
// generic LocalAI knob every backend honours, max_model_len is the
// vLLM-specific one, and engine_args.max_model_len is the explicit
// vllm-cpp override.
if opts.ContextSize > 0 {
mp.MaxModelLen = opts.ContextSize
}
if opts.MaxModelLen > 0 {
mp.MaxModelLen = opts.MaxModelLen
}
if v.opts.maxModelLen > 0 {
mp.MaxModelLen = v.opts.maxModelLen
}
if v.opts.maxNumSeqs > 0 {
mp.MaxNumSeqs = v.opts.maxNumSeqs
}
if v.opts.maxNumBatchedTokens > 0 {
mp.MaxNumBatchedTokens = v.opts.maxNumBatchedTokens
}
mp.EnablePrefixCaching = v.opts.enablePrefixCaching
mp.EnableJumpForward = v.opts.enableJumpForward
// Every string below is borrowed by C for the duration of the load call
// only (the library copies what it keeps), so the backing slices just have
// to outlive vllmEngineLoad - hence the single KeepAlive after it.
modelC := cString(model)
mp.ModelPath = uintptr(unsafe.Pointer(&modelC[0])) // #nosec G103 -- borrowed by C for the load call only
var toolParserC, reasoningParserC []byte
if v.opts.toolParser != "" {
toolParserC = cString(v.opts.toolParser)
mp.ToolParser = uintptr(unsafe.Pointer(&toolParserC[0])) // #nosec G103 -- borrowed by C for the load call only
}
if v.opts.reasoningParser != "" {
reasoningParserC = cString(v.opts.reasoningParser)
mp.ReasoningParser = uintptr(unsafe.Pointer(&reasoningParserC[0])) // #nosec G103 -- borrowed by C for the load call only
keep := [][]byte{modelC}
setStr := func(dst *uintptr, s string) {
if s == "" {
return
}
b := cString(s)
keep = append(keep, b)
*dst = uintptr(unsafe.Pointer(&b[0])) // #nosec G103 -- borrowed by C for the load call only
}
setStr(&mp.ToolParser, v.opts.toolParser)
setStr(&mp.ReasoningParser, v.opts.reasoningParser)
setStr(&mp.SpeculativeConfig, v.opts.speculativeConfig)
setStr(&mp.KVTransferConfig, v.opts.kvTransferConfig)
setStr(&mp.SchedulingPolicy, v.opts.schedulingPolicy)
setStr(&mp.TokenizerConfigPath, v.opts.tokenizerConfigPath)
xlog.Info("[vllm-cpp] Load", "model", model, "engine", vllmVersion(),
"blockSize", mp.BlockSize, "numBlocks", mp.NumBlocks,
"maxModelLen", mp.MaxModelLen, "maxNumSeqs", mp.MaxNumSeqs)
"maxModelLen", mp.MaxModelLen, "maxNumSeqs", mp.MaxNumSeqs,
"maxNumBatchedTokens", mp.MaxNumBatchedTokens,
"prefixCaching", triStateName(mp.EnablePrefixCaching),
"jumpForward", triStateName(mp.EnableJumpForward),
"schedulingPolicy", v.opts.schedulingPolicy,
"speculativeConfig", v.opts.speculativeConfig,
"kvTransferConfig", v.opts.kvTransferConfig)
var engine uintptr
rc := vllmEngineLoad(unsafe.Pointer(&mp), unsafe.Pointer(&engine)) // #nosec G103 -- POD out-params
runtime.KeepAlive(modelC)
runtime.KeepAlive(toolParserC)
runtime.KeepAlive(reasoningParserC)
runtime.KeepAlive(keep)
if rc != vllmOK {
return fmt.Errorf("vllm-cpp: engine load failed: %s", vllmLastError())
}

View File

@@ -1,6 +1,6 @@
package main
// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v2).
// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v10).
//
// The structs below are hand-mirrored PODs of the C declarations, with
// explicit padding so the Go layout matches the C layout on linux/darwin
@@ -18,23 +18,56 @@ import (
)
// abiVersion is the VLLM_ABI_VERSION this file mirrors (vllm.h).
const abiVersion = 5
const abiVersion = 10
// The ABI's tri-state toggles (enable_prefix_caching ABI v7,
// enable_jump_forward ABI v10) share one encoding: 0 is NOT "off", it is
// "defer" - to the model capability for prefix caching, to the environment for
// jump forward. Only 2 is an explicit off.
const (
triStateDefer int32 = 0
triStateOn int32 = 1
triStateOff int32 = 2
)
// triStateName renders a tri-state for the load log line, where "0" would
// otherwise read as "off" rather than "whatever the default resolves to".
func triStateName(state int32) string {
switch state {
case triStateOn:
return "on"
case triStateOff:
return "off"
default:
return "model-default"
}
}
// vllm_status (vllm.h).
const (
vllmOK = 0
)
// cModelParams mirrors vllm_model_params.
// cModelParams mirrors vllm_model_params. The int32 fields sit in pairs so the
// interior needs no padding on LP64, but the struct is 8-aligned (it holds
// pointers) and ends on a lone int32, so the trailing pad is explicit. Offsets
// and total size are asserted in vllmcpp_test.go.
type cModelParams struct {
ModelPath uintptr // const char*
TokenizerConfigPath uintptr // const char*
TokenizerConfigPath uintptr // const char*; NULL = <model_dir>/... (ABI v9)
BlockSize int32
NumBlocks int32
MaxModelLen int32
MaxNumSeqs int32
ToolParser uintptr // const char*; NULL = auto-detect (ABI v4)
ReasoningParser uintptr // const char*; NULL = auto-detect (ABI v5)
SpeculativeConfig uintptr // const char* JSON; NULL = no speculation (ABI v6)
EnablePrefixCaching int32 // tri-state 0/1/2 (ABI v7)
MaxNumBatchedTokens int32 // <= 0 = per-arch default (ABI v9)
SchedulingPolicy uintptr // const char*; NULL = "fcfs" (ABI v9)
KVTransferConfig uintptr // const char* JSON; NULL = no connector (ABI v9)
EnableJumpForward int32 // tri-state 0/1/2 (ABI v10)
_ [4]byte // trailing pad to the struct's 8-byte alignment
}
// cSamplingParams mirrors vllm_sampling_params (ABI v2, structured fields
@@ -65,6 +98,12 @@ type cSamplingParams struct {
StructuredGrammar uintptr // const char*
StructuredJSONObject int32
_ [4]byte
// ABI v8 tail. LocalAI installs no custom logits processor, but the fields
// MUST be mirrored: the C side reads them off the pointer we hand it, so a
// Go struct that stopped at StructuredJSONObject would have the engine read
// 16 bytes past our allocation and call whatever garbage sat there.
LogitsProcessor uintptr // vllm_logits_processor; NULL = none
LogitsProcessorUserData uintptr // void*
}
// cCompletion mirrors vllm_completion.

View File

@@ -1,30 +1,80 @@
package main
// Engine-sizing knobs carried through the model config's free-form
// `options:` list ("key:value" entries), mirroring how the other in-house
// backends pass engine-specific settings that have no proto field.
// Load-time engine configuration, from two config surfaces:
//
// - `engine_args:` (ModelOptions.EngineArgs, a JSON object) is the canonical
// one. Keys are spelled exactly as vLLM's own CLI flags, so a config written
// against vLLM works verbatim here - `speculative_config` and
// `kv_transfer_config` in particular take the same JSON documents vLLM's
// --speculative-config / --kv-transfer-config accept, and are handed to the
// engine unparsed.
// - `options:` (the free-form "key:value" list) is the older surface this
// backend shipped with. It is still honoured so existing configs keep
// working; engine_args wins on any key set in both.
//
// Anything unrecognised is ignored rather than fatal: the engine validates the
// documents it is given and reports a precise error at load, and a config that
// also carries knobs for a different backend must not fail the load here.
import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"strconv"
"strings"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
)
type loadOptions struct {
blockSize int32 // KV block size (tokens/block); engine default 32.
numBlocks int32 // KV blocks to allocate; engine default 256.
maxNumSeqs int32 // max concurrent sequences; engine default 8.
// Max sequence length. Also settable through the model config's
// context_size / max_model_len; see Load for the precedence.
maxModelLen int32
// Per-step chunked-prefill token budget (ABI v9). 0 = the engine's
// bounded per-arch default.
maxNumBatchedTokens int32
// Automatic prefix caching tri-state (ABI v7): 0 = the model-capability
// default, 1 = force on, 2 = force off.
enablePrefixCaching int32
// Jump-forward decoding tri-state (ABI v10), SGLang's grammar-speed subset:
// 0 = defer to the environment (VT_ENABLE_JUMP_FORWARD, default off),
// 1 = force on, 2 = force off.
enableJumpForward int32
// Scheduler admission policy (ABI v9): "" = fcfs, else fcfs|priority|lpm.
schedulingPolicy string
// Engine-side parser selection (ABI v4/v5). Empty = the engine
// auto-detects from the chat template; "none" disables the reasoning
// split; unknown names fail the first chat call.
toolParser string
reasoningParser string
// Speculative decoding (ABI v6), as vLLM's --speculative-config JSON:
// {"method":"mtp"|"dflash"|"ngram", ...}. Empty = no speculation.
speculativeConfig string
// External KV connector / LMCache (ABI v9), as vLLM's --kv-transfer-config
// JSON. Empty = no connector.
kvTransferConfig string
// Override for the tokenizer_config.json the chat template is read from
// (ABI v9). Empty = <model_dir>/tokenizer_config.json.
tokenizerConfigPath string
}
func parseOptions(opts *pb.ModelOptions) loadOptions {
lo := loadOptions{}
for _, o := range opts.GetOptions() {
applyOptionsList(&lo, opts.GetOptions())
applyEngineArgs(&lo, opts.GetEngineArgs())
return lo
}
// applyOptionsList reads the legacy free-form "key:value" list. strings.Cut
// splits on the FIRST colon only, so a JSON object value survives intact.
func applyOptionsList(lo *loadOptions, options []string) {
for _, o := range options {
k, v, found := strings.Cut(o, ":")
if !found {
continue
@@ -36,13 +86,211 @@ func parseOptions(opts *pb.ModelOptions) loadOptions {
lo.numBlocks = parseInt32(v, lo.numBlocks)
case "max_num_seqs":
lo.maxNumSeqs = parseInt32(v, lo.maxNumSeqs)
case "tool_parser":
case "max_num_batched_tokens":
lo.maxNumBatchedTokens = parseInt32(v, lo.maxNumBatchedTokens)
case "max_model_len":
lo.maxModelLen = parseInt32(v, lo.maxModelLen)
case "scheduling_policy", "schedule_policy":
lo.schedulingPolicy = strings.TrimSpace(v)
case "tool_parser", "tool_call_parser":
lo.toolParser = strings.TrimSpace(v)
case "reasoning_parser":
lo.reasoningParser = strings.TrimSpace(v)
case "speculative_config":
lo.speculativeConfig = strings.TrimSpace(v)
case "kv_transfer_config":
lo.kvTransferConfig = strings.TrimSpace(v)
case "tokenizer_config", "tokenizer_config_path":
lo.tokenizerConfigPath = strings.TrimSpace(v)
case "enable_prefix_caching", "enable_radix_attention":
if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil {
lo.enablePrefixCaching = boolTriState(b)
}
case "enable_jump_forward":
if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil {
lo.enableJumpForward = boolTriState(b)
}
}
}
return lo
}
// applyEngineArgs overlays the `engine_args:` JSON object. A document that does
// not parse is logged and skipped: engine_args is shared with the other engines
// (the vLLM and SGLang backends read the same field), so a stray key must not
// take the model down.
func applyEngineArgs(lo *loadOptions, engineArgs string) {
if strings.TrimSpace(engineArgs) == "" {
return
}
var args map[string]any
if err := json.Unmarshal([]byte(engineArgs), &args); err != nil {
xlog.Warn("[vllm-cpp] ignoring unparseable engine_args", "error", err)
return
}
for k, v := range args {
switch k {
case "block_size":
lo.blockSize = jsonInt32(v, lo.blockSize)
case "num_blocks":
lo.numBlocks = jsonInt32(v, lo.numBlocks)
case "max_num_seqs":
lo.maxNumSeqs = jsonInt32(v, lo.maxNumSeqs)
case "max_num_batched_tokens":
lo.maxNumBatchedTokens = jsonInt32(v, lo.maxNumBatchedTokens)
case "max_model_len":
lo.maxModelLen = jsonInt32(v, lo.maxModelLen)
case "scheduling_policy", "schedule_policy":
lo.schedulingPolicy = jsonString(v, lo.schedulingPolicy)
case "tool_parser", "tool_call_parser":
lo.toolParser = jsonString(v, lo.toolParser)
case "reasoning_parser":
lo.reasoningParser = jsonString(v, lo.reasoningParser)
case "tokenizer_config", "tokenizer_config_path":
lo.tokenizerConfigPath = jsonString(v, lo.tokenizerConfigPath)
case "speculative_config":
lo.speculativeConfig = jsonDocument(v, lo.speculativeConfig, k)
case "kv_transfer_config":
lo.kvTransferConfig = jsonDocument(v, lo.kvTransferConfig, k)
case "enable_prefix_caching", "enable_radix_attention":
if b, ok := v.(bool); ok {
lo.enablePrefixCaching = boolTriState(b)
}
case "enable_jump_forward":
if b, ok := v.(bool); ok {
lo.enableJumpForward = boolTriState(b)
}
default:
xlog.Debug("[vllm-cpp] ignoring unknown engine_args key", "key", k)
}
}
}
// boolTriState maps a YAML/JSON boolean onto the ABI's tri-state encoding. An
// explicit `false` must reach the engine as force-OFF (2), NOT as the 0 that
// means "defer". The difference is real in both directions: prefix caching
// defaults ON for dense archs and OFF for hybrid ones, and jump forward defers
// to VT_ENABLE_JUMP_FORWARD.
func boolTriState(on bool) int32 {
if on {
return triStateOn
}
return triStateOff
}
// jsonDocument normalises an object-valued engine_args entry to a JSON string
// for the C ABI. YAML nesting arrives as a map (the natural spelling); a
// pre-encoded JSON string is accepted too, since a config round-tripped through
// a flat store may carry it that way.
func jsonDocument(v any, fallback string, key string) string {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) == "" {
return fallback
}
return t
default:
buf, err := json.Marshal(t)
if err != nil {
xlog.Warn("[vllm-cpp] ignoring unencodable engine_args value", "key", key, "error", err)
return fallback
}
return string(buf)
}
}
func jsonString(v any, fallback string) string {
s, ok := v.(string)
if !ok {
return fallback
}
return strings.TrimSpace(s)
}
// jsonInt32 accepts the float64 a JSON number decodes to, plus the string
// spelling a YAML config may produce. Non-positive values keep the fallback:
// every knob this covers uses "<= 0 means the engine default".
func jsonInt32(v any, fallback int32) int32 {
switch t := v.(type) {
case float64:
if t <= 0 || t > 1<<31-1 {
return fallback
}
return int32(t)
case string:
return parseInt32(t, fallback)
default:
return fallback
}
}
// resolveDraftModelPath rewrites a DFlash draft reference into an absolute path
// the engine can actually open.
//
// The engine resolves `speculative_config.model` against a directory containing
// config.json, or against ~/.cache/huggingface/hub/models--<org>--<repo>/
// snapshots/* - and it NEVER downloads. LocalAI keeps models in its own
// directory, so a bare HF repo id (the spelling the vLLM docs teach) misses the
// HF cache and dies deep in the load with "draft checkpoint not found", which
// reads like a broken checkpoint rather than a missing download.
//
// So: try the reference as given, then the last path segment under the models
// dir (`z-lab/Qwen3.6-27B-DFlash` -> `<models>/Qwen3.6-27B-DFlash`, which is
// what LocalAI's own downloader produces), then the whole reference under the
// models dir. If none exist, fail HERE with a message naming both what was
// asked for and where we looked.
//
// mtp and ngram carry no separate draft checkpoint, so they pass through. A
// document that does not parse also passes through: the engine owns config
// validation and produces the better error.
func resolveDraftModelPath(speculativeConfig, modelsDir string) (string, error) {
if strings.TrimSpace(speculativeConfig) == "" {
return speculativeConfig, nil
}
var spec map[string]any
if err := json.Unmarshal([]byte(speculativeConfig), &spec); err != nil {
return speculativeConfig, nil
}
if method, _ := spec["method"].(string); !strings.EqualFold(method, "dflash") {
return speculativeConfig, nil
}
ref, _ := spec["model"].(string)
ref = strings.TrimSpace(ref)
if ref == "" {
return "", fmt.Errorf(
"vllm-cpp: speculative_config method %q requires a \"model\" key naming the draft checkpoint", "dflash")
}
candidates := []string{ref}
if modelsDir != "" {
if base := path.Base(filepath.ToSlash(ref)); base != "" && base != "." && base != "/" {
candidates = append(candidates, filepath.Join(modelsDir, base))
}
candidates = append(candidates, filepath.Join(modelsDir, filepath.FromSlash(ref)))
}
for _, c := range candidates {
if _, err := os.Stat(filepath.Join(c, "config.json")); err != nil {
continue
}
abs, err := filepath.Abs(c)
if err != nil {
abs = c
}
spec["model"] = abs
out, err := json.Marshal(spec)
if err != nil {
return "", fmt.Errorf("vllm-cpp: re-encoding speculative_config: %w", err)
}
xlog.Info("[vllm-cpp] resolved DFlash draft checkpoint", "reference", ref, "path", abs)
return string(out), nil
}
return "", fmt.Errorf(
"vllm-cpp: DFlash draft checkpoint %q not found (looked in: %s). "+
"The engine does not download drafts - install the draft model into LocalAI first, "+
"or set speculative_config.model to an absolute path to a directory containing config.json",
ref, strings.Join(candidates, ", "))
}
func parseInt32(s string, fallback int32) int32 {

View File

@@ -16,7 +16,7 @@ func TestVllmCpp(t *testing.T) {
RunSpecs(t, "vllm-cpp suite")
}
// The Go POD mirrors must match the C struct layout of vllm.h (ABI v2)
// The Go POD mirrors must match the C struct layout of vllm.h (ABI v9)
// byte-for-byte: these offsets are the C offsets on LP64 (linux/darwin
// amd64+arm64). A failure here means govllmcpp.go drifted from vllm.h.
var _ = Describe("C ABI struct mirrors", func() {
@@ -30,10 +30,18 @@ var _ = Describe("C ABI struct mirrors", func() {
Expect(unsafe.Offsetof(p.MaxNumSeqs)).To(Equal(uintptr(28)))
Expect(unsafe.Offsetof(p.ToolParser)).To(Equal(uintptr(32)))
Expect(unsafe.Offsetof(p.ReasoningParser)).To(Equal(uintptr(40)))
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(48)))
Expect(unsafe.Offsetof(p.SpeculativeConfig)).To(Equal(uintptr(48)))
Expect(unsafe.Offsetof(p.EnablePrefixCaching)).To(Equal(uintptr(56)))
Expect(unsafe.Offsetof(p.MaxNumBatchedTokens)).To(Equal(uintptr(60)))
Expect(unsafe.Offsetof(p.SchedulingPolicy)).To(Equal(uintptr(64)))
Expect(unsafe.Offsetof(p.KVTransferConfig)).To(Equal(uintptr(72)))
Expect(unsafe.Offsetof(p.EnableJumpForward)).To(Equal(uintptr(80)))
// 88, not 84: the struct is 8-aligned (it holds pointers), so the
// trailing int32 is padded out. Go pads identically.
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(88)))
})
It("cSamplingParams matches vllm_sampling_params (ABI v2)", func() {
It("cSamplingParams matches vllm_sampling_params (ABI v8)", func() {
var p cSamplingParams
Expect(unsafe.Offsetof(p.Temperature)).To(Equal(uintptr(0)))
Expect(unsafe.Offsetof(p.TopP)).To(Equal(uintptr(4)))
@@ -55,7 +63,9 @@ var _ = Describe("C ABI struct mirrors", func() {
Expect(unsafe.Offsetof(p.NStructuredChoice)).To(Equal(uintptr(96)))
Expect(unsafe.Offsetof(p.StructuredGrammar)).To(Equal(uintptr(104)))
Expect(unsafe.Offsetof(p.StructuredJSONObject)).To(Equal(uintptr(112)))
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(120)))
Expect(unsafe.Offsetof(p.LogitsProcessor)).To(Equal(uintptr(120)))
Expect(unsafe.Offsetof(p.LogitsProcessorUserData)).To(Equal(uintptr(128)))
Expect(unsafe.Sizeof(p)).To(Equal(uintptr(136)))
})
It("cCompletion matches vllm_completion", func() {
@@ -68,6 +78,23 @@ var _ = Describe("C ABI struct mirrors", func() {
})
})
// Pin/mirror skew is the failure mode this backend is most exposed to: the Go
// PODs above are hand-written against one VLLM_ABI_VERSION, and the Makefile
// pins the vllm.cpp commit that produces it. This spec catches drift without
// needing model weights - set VLLM_CPP_LIBRARY to a built libvllm and it binds
// every symbol and compares the library's reported ABI against the mirrors'.
var _ = Describe("real library ABI handshake", func() {
It("binds every symbol and reports the ABI the mirrors were written against", func() {
lib := os.Getenv("VLLM_CPP_LIBRARY")
if lib == "" {
Skip("VLLM_CPP_LIBRARY not set; skipping the real-library handshake")
}
Expect(registerLib(lib)).To(Succeed())
Expect(vllmABIVersion()).To(Equal(int32(abiVersion)))
Expect(vllmVersion()).NotTo(BeEmpty())
})
})
var _ = Describe("parseOptions", func() {
It("extracts the engine sizing knobs", func() {
lo := parseOptions(&pb.ModelOptions{Options: []string{
@@ -83,6 +110,129 @@ var _ = Describe("parseOptions", func() {
}})
Expect(lo).To(Equal(loadOptions{}))
})
It("carries a speculative_config JSON value through the legacy options list", func() {
// strings.Cut splits on the FIRST colon only, so a JSON object value
// survives the "key:value" spelling intact.
lo := parseOptions(&pb.ModelOptions{Options: []string{
`speculative_config:{"method":"mtp","num_speculative_tokens":1}`,
}})
Expect(lo.speculativeConfig).To(Equal(`{"method":"mtp","num_speculative_tokens":1}`))
})
})
var _ = Describe("engine_args", func() {
It("maps every load knob onto the C model params", func() {
lo := parseOptions(&pb.ModelOptions{EngineArgs: `{
"block_size": 64,
"num_blocks": 1024,
"max_model_len": 16384,
"max_num_seqs": 32,
"max_num_batched_tokens": 8192,
"enable_prefix_caching": true,
"scheduling_policy": "lpm",
"tool_parser": "qwen3",
"reasoning_parser": "deepseek_r1",
"tokenizer_config": "/models/tok/tokenizer_config.json"
}`})
Expect(lo.blockSize).To(Equal(int32(64)))
Expect(lo.numBlocks).To(Equal(int32(1024)))
Expect(lo.maxModelLen).To(Equal(int32(16384)))
Expect(lo.maxNumSeqs).To(Equal(int32(32)))
Expect(lo.maxNumBatchedTokens).To(Equal(int32(8192)))
Expect(lo.enablePrefixCaching).To(Equal(int32(1)))
Expect(lo.schedulingPolicy).To(Equal("lpm"))
Expect(lo.toolParser).To(Equal("qwen3"))
Expect(lo.reasoningParser).To(Equal("deepseek_r1"))
Expect(lo.tokenizerConfigPath).To(Equal("/models/tok/tokenizer_config.json"))
})
It("re-marshals a nested speculative_config object to JSON for the engine", func() {
lo := parseOptions(&pb.ModelOptions{EngineArgs: `{
"speculative_config": {"method": "mtp", "num_speculative_tokens": 1}
}`})
Expect(lo.speculativeConfig).To(MatchJSON(`{"method":"mtp","num_speculative_tokens":1}`))
})
It("re-marshals a nested kv_transfer_config object (LMCache) to JSON", func() {
lo := parseOptions(&pb.ModelOptions{EngineArgs: `{
"kv_transfer_config": {
"kv_connector": "LMCacheConnector",
"kv_role": "kv_both",
"kv_connector_extra_config": {"host": "127.0.0.1", "port": 65432}
}
}`})
Expect(lo.kvTransferConfig).To(MatchJSON(`{
"kv_connector":"LMCacheConnector",
"kv_role":"kv_both",
"kv_connector_extra_config":{"host":"127.0.0.1","port":65432}
}`))
})
It("accepts a pre-encoded JSON string for the object-valued knobs", func() {
// A config written by hand (or round-tripped through a flat store) may
// carry the object as a string; both spellings reach the engine the same.
lo := parseOptions(&pb.ModelOptions{EngineArgs: `{
"speculative_config": "{\"method\":\"ngram\",\"num_speculative_tokens\":4}"
}`})
Expect(lo.speculativeConfig).To(MatchJSON(`{"method":"ngram","num_speculative_tokens":4}`))
})
It("maps enable_prefix_caching false onto the force-OFF tri-state", func() {
// The C ABI tri-state is 0=model default, 1=on, 2=off, so an explicit
// `false` must NOT collapse to the 0 that means "let the model decide".
lo := parseOptions(&pb.ModelOptions{EngineArgs: `{"enable_prefix_caching": false}`})
Expect(lo.enablePrefixCaching).To(Equal(int32(2)))
})
It("leaves the prefix-caching tri-state at the model default when unset", func() {
lo := parseOptions(&pb.ModelOptions{EngineArgs: `{"max_num_seqs": 4}`})
Expect(lo.enablePrefixCaching).To(Equal(int32(0)))
})
It("accepts the radix-attention alias upstream documents for prefix caching", func() {
lo := parseOptions(&pb.ModelOptions{EngineArgs: `{"enable_radix_attention": true}`})
Expect(lo.enablePrefixCaching).To(Equal(int32(1)))
})
It("maps enable_jump_forward onto its own tri-state", func() {
// ABI v10. Same tri-state shape as prefix caching, and the same trap:
// an explicit false must be force-OFF (2), not the 0 that defers to the
// environment.
on := parseOptions(&pb.ModelOptions{EngineArgs: `{"enable_jump_forward": true}`})
Expect(on.enableJumpForward).To(Equal(int32(1)))
off := parseOptions(&pb.ModelOptions{EngineArgs: `{"enable_jump_forward": false}`})
Expect(off.enableJumpForward).To(Equal(int32(2)))
unset := parseOptions(&pb.ModelOptions{EngineArgs: `{"max_num_seqs": 4}`})
Expect(unset.enableJumpForward).To(Equal(int32(0)))
})
It("reads enable_jump_forward from the legacy options list too", func() {
lo := parseOptions(&pb.ModelOptions{Options: []string{"enable_jump_forward:true"}})
Expect(lo.enableJumpForward).To(Equal(int32(1)))
})
It("lets engine_args override the legacy options list", func() {
lo := parseOptions(&pb.ModelOptions{
Options: []string{"max_num_seqs:8", "block_size:16"},
EngineArgs: `{"max_num_seqs": 64}`,
})
Expect(lo.maxNumSeqs).To(Equal(int32(64))) // engine_args wins
Expect(lo.blockSize).To(Equal(int32(16))) // untouched keys survive
})
It("ignores malformed engine_args rather than failing the load", func() {
lo := parseOptions(&pb.ModelOptions{
Options: []string{"max_num_seqs:8"},
EngineArgs: `{not json`,
})
Expect(lo.maxNumSeqs).To(Equal(int32(8)))
})
It("ignores unknown keys", func() {
lo := parseOptions(&pb.ModelOptions{EngineArgs: `{"gpu_memory_utilization": 0.9}`})
Expect(lo).To(Equal(loadOptions{}))
})
})
var _ = Describe("samplingFromPredict", func() {
@@ -135,6 +285,91 @@ var _ = Describe("samplingFromPredict", func() {
})
})
// The engine resolves speculative_config.model against a local directory or
// ~/.cache/huggingface/hub ONLY - it never downloads. LocalAI keeps models in
// its own directory, so a bare repo id would miss the HF cache and fail deep in
// the load with a confusing "draft checkpoint not found". Resolve it here.
var _ = Describe("resolveDraftModelPath", func() {
var modelsDir string
BeforeEach(func() {
modelsDir = GinkgoT().TempDir()
})
// draftDir creates a plausible draft checkpoint under models/.
draftDir := func(name string) string {
d := filepath.Join(modelsDir, name)
Expect(os.MkdirAll(d, 0o750)).To(Succeed())
Expect(os.WriteFile(filepath.Join(d, "config.json"), []byte("{}"), 0o600)).To(Succeed())
return d
}
It("rewrites a repo id to the matching directory in the models dir", func() {
want := draftDir("Qwen3.6-27B-DFlash")
spec := `{"method":"dflash","model":"z-lab/Qwen3.6-27B-DFlash"}`
out, err := resolveDraftModelPath(spec, modelsDir)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(MatchJSON(`{"method":"dflash","model":"` + want + `"}`))
})
It("rewrites a models-dir-relative path", func() {
want := draftDir("drafts__dflash")
spec := `{"method":"dflash","model":"drafts__dflash"}`
out, err := resolveDraftModelPath(spec, modelsDir)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring(want))
})
It("leaves an absolute path that already resolves alone", func() {
abs := draftDir("elsewhere")
spec := `{"method":"dflash","model":"` + abs + `"}`
out, err := resolveDraftModelPath(spec, modelsDir)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(MatchJSON(spec))
})
It("fails with an actionable error when the draft is nowhere on disk", func() {
// Silently passing the repo id through would surface as an HF-cache
// miss inside the engine, which reads as "your model is broken".
spec := `{"method":"dflash","model":"z-lab/Not-Downloaded"}`
_, err := resolveDraftModelPath(spec, modelsDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("z-lab/Not-Downloaded"))
Expect(err.Error()).To(ContainSubstring(modelsDir))
})
It("requires a model key for dflash", func() {
_, err := resolveDraftModelPath(`{"method":"dflash"}`, modelsDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("model"))
})
It("leaves mtp and ngram configs untouched", func() {
// Neither has a separate draft checkpoint to resolve.
for _, spec := range []string{
`{"method":"mtp"}`,
`{"method":"ngram","num_speculative_tokens":4}`,
} {
out, err := resolveDraftModelPath(spec, modelsDir)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(MatchJSON(spec))
}
})
It("passes a malformed document through for the engine to reject", func() {
// The engine owns config validation and produces the better message.
out, err := resolveDraftModelPath(`{not json`, modelsDir)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(Equal(`{not json`))
})
It("is a no-op on an empty config", func() {
out, err := resolveDraftModelPath("", modelsDir)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(BeEmpty())
})
})
var _ = Describe("validModelPath", func() {
It("accepts a .gguf file", func() {
dir := GinkgoT().TempDir()

View File

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

View File

@@ -883,6 +883,34 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
return backend_pb2.Result(message="Media generated", success=True)
def UpscaleImage(self, request, context):
try:
if not request.src:
return backend_pb2.Result(success=False, message="No source image provided")
if not request.dst:
return backend_pb2.Result(success=False, message="No destination path provided")
scale = request.scale if request.scale > 0 else 2
image = Image.open(request.src).convert("RGB")
# If the loaded pipeline supports upscaling (e.g. StableDiffusionUpscalePipeline),
# use it; otherwise fall back to high-quality Lanczos resize.
if self.pipe is not None and self.PipelineType in ("StableDiffusionUpscalePipeline", "StableDiffusionLatentUpscalePipeline"):
print(f"UpscaleImage: using diffusers upscale pipeline ({self.PipelineType})", file=sys.stderr)
upscaled = self.pipe(prompt="", image=image).images[0]
else:
# Fallback: high-quality Lanczos resize
print(f"UpscaleImage: no upscale pipeline loaded, using Lanczos resize (scale={scale})", file=sys.stderr)
new_w = image.width * scale
new_h = image.height * scale
upscaled = image.resize((new_w, new_h), Image.LANCZOS)
upscaled.save(request.dst)
return backend_pb2.Result(message="Image upscaled", success=True)
except Exception as e:
print(f"UpscaleImage error: {e}", file=sys.stderr)
return backend_pb2.Result(success=False, message=str(e))
def GenerateVideo(self, request, context):
try:
prompt = request.prompt

View File

@@ -15,3 +15,12 @@ sglang[all]>=0.5.11
# load-bearing for flash-attn-4, and this is the narrower change. Raise the
# bound once 0.46.0 final ships.
nvidia-modelopt<0.46
# Same failure mode as the nvidia-modelopt bound above, via a different
# package. sglang -> flashinfer-python -> cuda-tile, unbounded, and the
# global --prerelease=allow resolves it to 1.6.0rc3, whose build backend
# imports wheel_stub without declaring it in build-system.requires. With
# --no-build-isolation nothing installs it and the build dies with
# "No module named 'wheel_stub'". 1.5.0 is the newest stable release.
# Raise the bound once 1.6.0 final ships.
cuda-tile<1.6

View File

@@ -15,3 +15,12 @@ sglang[all]>=0.5.11
# load-bearing for flash-attn-4, and this is the narrower change. Raise the
# bound once 0.46.0 final ships.
nvidia-modelopt<0.46
# Same failure mode as the nvidia-modelopt bound above, via a different
# package. sglang -> flashinfer-python -> cuda-tile, unbounded, and the
# global --prerelease=allow resolves it to 1.6.0rc3, whose build backend
# imports wheel_stub without declaring it in build-system.requires. With
# --no-build-isolation nothing installs it and the build dies with
# "No module named 'wheel_stub'". 1.5.0 is the newest stable release.
# Raise the bound once 1.6.0 final ships.
cuda-tile<1.6

View File

@@ -13,3 +13,12 @@
# FunctionCallParser, ReasoningParser); the [all] extras are optional
# accelerators not required at import time.
sglang>=0.5.11
# Same failure mode the cublas profiles carry an nvidia-modelopt bound for,
# reached through a different package. sglang -> flashinfer-python ->
# cuda-tile, unbounded, and the global --prerelease=allow resolves it to
# 1.6.0rc3, whose build backend imports wheel_stub without declaring it in
# build-system.requires. With --no-build-isolation nothing installs it and
# the build dies with "No module named 'wheel_stub'". 1.5.0 is the newest
# stable release. Raise the bound once 1.6.0 final ships.
cuda-tile<1.6

View File

@@ -553,12 +553,17 @@ func (a *Application) start() error {
// once at startup and reused across chat sessions that opt in via metadata.
if !a.applicationConfig.DisableLocalAIAssistant {
holder := mcpTools.NewLocalAIAssistantHolder()
var nodeRegistry *nodes.NodeRegistry
if a.distributed != nil {
nodeRegistry = a.distributed.Registry
}
assistantClient := localaiInproc.New(
a.applicationConfig,
a.applicationConfig.SystemState,
a.backendLoader,
a.modelLoader,
a.galleryService,
nodeRegistry,
)
// Wire usage tracking so the assistant's get_usage_stats tool
// returns real data; nil values keep the tool returning a clear

37
core/backend/upscale.go Normal file
View File

@@ -0,0 +1,37 @@
package backend
import (
"context"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/grpc/proto"
model "github.com/mudler/LocalAI/pkg/model"
)
// ImageUpscale loads the model specified in modelConfig and calls UpscaleImage
// on the backend, writing the result to dst.
func ImageUpscale(ctx context.Context, src, dst string, scale int, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func() error, error) {
opts := ModelOptions(modelConfig, appConfig, model.WithContext(ctx))
inferenceModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}
fn := func() error {
_, err := inferenceModel.UpscaleImage(
ctx,
&proto.UpscaleImageRequest{
Src: src,
Dst: dst,
Scale: int32(scale),
},
)
return err
}
return fn, nil
}
// ImageUpscaleFunc is a test-friendly indirection.
var ImageUpscaleFunc = ImageUpscale

View File

@@ -44,6 +44,7 @@ const (
MethodPredictStream GRPCMethod = "PredictStream"
MethodEmbedding GRPCMethod = "Embedding"
MethodGenerateImage GRPCMethod = "GenerateImage"
MethodUpscaleImage GRPCMethod = "UpscaleImage"
MethodGenerateVideo GRPCMethod = "GenerateVideo"
MethodGenerate3D GRPCMethod = "Generate3D"
MethodAudioTranscription GRPCMethod = "AudioTranscription"
@@ -348,7 +349,7 @@ var BackendCapabilities = map[string]BackendCapability{
// --- Image/video generation backends ---
"diffusers": {
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodGenerateVideo},
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodUpscaleImage, MethodGenerateVideo},
PossibleUsecases: []string{UsecaseImage, UsecaseVideo},
DefaultUsecases: []string{UsecaseImage},
Description: "HuggingFace diffusers — Stable Diffusion, Flux, video generation",

117
core/config/vllm_spec.go Normal file
View File

@@ -0,0 +1,117 @@
package config
// Speculative-decoding auto-defaults for the vllm-cpp backend, the safetensors
// counterpart of the GGUF/llama.cpp hook in mtp.go.
//
// The two engines detect and spell the same feature differently. llama.cpp
// reads `<arch>.nextn_predict_layers` out of the GGUF header and takes
// `spec_type:draft-mtp` in `options:`; vllm.cpp reads `mtp_num_hidden_layers`
// out of the checkpoint's config.json and takes vLLM's own
// `--speculative-config` JSON, which LocalAI carries in `engine_args`. The
// engine resolves the draft depth and the default k itself, so the config only
// has to name the method.
import (
"encoding/json"
"github.com/mudler/xlog"
)
// hfSpecConfig is the subset of a HuggingFace config.json that decides whether
// speculative decoding can be auto-enabled.
type hfSpecConfig struct {
ModelType string `json:"model_type"`
// MtpNumHiddenLayers is the MTP head depth (upstream speculative.py reads
// it as n_predict for the qwen3_5 / qwen3_5_moe families).
MtpNumHiddenLayers uint32 `json:"mtp_num_hidden_layers"`
// DFlashConfig marks a z-lab DFlash DRAFT checkpoint (mask_token_id +
// target_layer_ids). Its presence means this repo is a draft, not a
// servable target.
DFlashConfig json.RawMessage `json:"dflash_config"`
// TextConfig is where multimodal checkpoints nest the language-model
// config, and therefore the MTP depth.
TextConfig *hfSpecConfig `json:"text_config"`
}
// parseHFSpecConfig decodes the speculative-relevant subset of a config.json.
// A document that does not parse yields nothing rather than an error: detection
// is best-effort and must never break an import.
func parseHFSpecConfig(configJSON []byte) (hfSpecConfig, bool) {
if len(configJSON) == 0 {
return hfSpecConfig{}, false
}
var c hfSpecConfig
if err := json.Unmarshal(configJSON, &c); err != nil {
xlog.Debug("[vllm-spec] config.json did not parse; skipping detection", "error", err)
return hfSpecConfig{}, false
}
return c, true
}
// IsDFlashDraftConfig reports whether a HuggingFace config.json describes a
// DFlash DRAFT checkpoint. Unlike MTP - whose head ships inside the target
// checkpoint's `mtp.*` tensors - a DFlash draft is its own repo that can only
// run paired with a target it verifies against, so it must never be configured
// as a standalone model.
func IsDFlashDraftConfig(configJSON []byte) bool {
c, ok := parseHFSpecConfig(configJSON)
if !ok {
return false
}
return len(c.DFlashConfig) > 0 ||
(c.TextConfig != nil && len(c.TextConfig.DFlashConfig) > 0)
}
// HasSafetensorsMTPHead reports whether a HuggingFace config.json declares a
// self-speculating Multi-Token Prediction head, returning its depth. The depth
// is informational: vllm.cpp resolves n_predict and the default
// num_speculative_tokens from the checkpoint itself.
//
// DFlash drafts are excluded for the same reason `gemma4-assistant` GGUFs are
// excluded from the llama.cpp hook: they carry head metadata but cannot
// self-speculate.
//
// NOTE this is a safetensors-only signal. vllm.cpp rejects an MTP config over a
// GGUF source, because the `mtp.*` draft tensors only exist in the safetensors
// checkpoint - so the GGUF import path must not use this.
func HasSafetensorsMTPHead(configJSON []byte) (uint32, bool) {
c, ok := parseHFSpecConfig(configJSON)
if !ok {
return 0, false
}
if IsDFlashDraftConfig(configJSON) {
return 0, false
}
n := c.MtpNumHiddenLayers
if n == 0 && c.TextConfig != nil {
n = c.TextConfig.MtpNumHiddenLayers
}
return n, n > 0
}
// ApplyVLLMSpeculativeDefaults enables MTP speculative decoding in cfg's
// engine_args when nothing is configured there yet. It is a no-op when the user
// already set a speculative_config, so an explicit choice (a different method,
// an explicit k, a DFlash draft) is never clobbered.
//
// `layers` is the detected head depth and is only used for the diagnostic log
// line - the engine derives the real k from the checkpoint.
func ApplyVLLMSpeculativeDefaults(cfg *ModelConfig, layers uint32) {
if cfg == nil {
return
}
if _, set := cfg.EngineArgs["speculative_config"]; set {
xlog.Debug("[vllm-spec] MTP head detected but speculative_config already configured; leaving user choice intact",
"name", cfg.Name, "mtp_num_hidden_layers", layers)
return
}
if cfg.EngineArgs == nil {
cfg.EngineArgs = map[string]any{}
}
// Only the method: vllm.cpp defaults num_speculative_tokens to the
// checkpoint's own n_predict (speculative.py:865-875), which is the right
// value far more reliably than anything guessable here.
cfg.EngineArgs["speculative_config"] = map[string]any{"method": "mtp"}
xlog.Info("[vllm-spec] MTP head detected; enabling mtp speculative decoding",
"name", cfg.Name, "mtp_num_hidden_layers", layers)
}

View File

@@ -0,0 +1,117 @@
package config_test
import (
. "github.com/mudler/LocalAI/core/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("vllm-cpp speculative-decoding auto-defaults", func() {
Context("HasSafetensorsMTPHead", func() {
It("detects a top-level mtp_num_hidden_layers", func() {
n, ok := HasSafetensorsMTPHead([]byte(`{
"model_type": "qwen3_5_moe",
"mtp_num_hidden_layers": 1
}`))
Expect(ok).To(BeTrue())
Expect(n).To(Equal(uint32(1)))
})
It("detects the head nested under text_config", func() {
// Multimodal checkpoints nest the language-model config, which is
// where the MTP depth lives (mirrors the engine's own resolution
// off config.raw text_config).
n, ok := HasSafetensorsMTPHead([]byte(`{
"model_type": "qwen3_5_moe",
"text_config": {"mtp_num_hidden_layers": 2}
}`))
Expect(ok).To(BeTrue())
Expect(n).To(Equal(uint32(2)))
})
It("reports no head when the key is absent", func() {
n, ok := HasSafetensorsMTPHead([]byte(`{"model_type": "llama"}`))
Expect(ok).To(BeFalse())
Expect(n).To(BeZero())
})
It("reports no head for a zero depth", func() {
_, ok := HasSafetensorsMTPHead([]byte(`{"mtp_num_hidden_layers": 0}`))
Expect(ok).To(BeFalse())
})
It("ignores a DFlash draft checkpoint", func() {
// A DFlash draft is a SEPARATE checkpoint that cannot serve alone:
// it needs a target to verify against. Same exclusion the GGUF path
// makes for gemma4-assistant drafts.
_, ok := HasSafetensorsMTPHead([]byte(`{
"model_type": "qwen3_dflash",
"mtp_num_hidden_layers": 1,
"dflash_config": {"mask_token_id": 151666, "target_layer_ids": [0, 1]}
}`))
Expect(ok).To(BeFalse())
})
It("reports no head on unparseable JSON", func() {
_, ok := HasSafetensorsMTPHead([]byte(`{not json`))
Expect(ok).To(BeFalse())
})
It("reports no head on empty input", func() {
_, ok := HasSafetensorsMTPHead(nil)
Expect(ok).To(BeFalse())
})
})
Context("IsDFlashDraftConfig", func() {
It("recognises a draft by its dflash_config block", func() {
Expect(IsDFlashDraftConfig([]byte(`{
"dflash_config": {"mask_token_id": 151666, "target_layer_ids": [0]}
}`))).To(BeTrue())
})
It("does not flag an ordinary checkpoint", func() {
Expect(IsDFlashDraftConfig([]byte(`{"model_type": "qwen3_5_moe"}`))).To(BeFalse())
})
})
Context("ApplyVLLMSpeculativeDefaults", func() {
It("writes the mtp method into engine_args", func() {
cfg := &ModelConfig{Name: "qwen"}
ApplyVLLMSpeculativeDefaults(cfg, 1)
Expect(cfg.EngineArgs).To(HaveKey("speculative_config"))
spec, ok := cfg.EngineArgs["speculative_config"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(spec["method"]).To(Equal("mtp"))
})
It("leaves an existing speculative_config alone", func() {
cfg := &ModelConfig{
Name: "qwen",
LLMConfig: LLMConfig{
EngineArgs: map[string]any{
"speculative_config": map[string]any{"method": "ngram", "num_speculative_tokens": 4},
},
},
}
ApplyVLLMSpeculativeDefaults(cfg, 1)
spec := cfg.EngineArgs["speculative_config"].(map[string]any)
Expect(spec["method"]).To(Equal("ngram"))
})
It("preserves unrelated engine_args keys", func() {
cfg := &ModelConfig{
Name: "qwen",
LLMConfig: LLMConfig{EngineArgs: map[string]any{"max_num_seqs": 32}},
}
ApplyVLLMSpeculativeDefaults(cfg, 1)
Expect(cfg.EngineArgs).To(HaveKeyWithValue("max_num_seqs", 32))
Expect(cfg.EngineArgs).To(HaveKey("speculative_config"))
})
It("tolerates a nil config", func() {
Expect(func() { ApplyVLLMSpeculativeDefaults(nil, 1) }).ToNot(Panic())
})
})
})

View File

@@ -298,7 +298,15 @@ func (i *LlamaCPPImporter) Import(details Details) (gallery.ModelConfig, error)
// imported configs already carry spec_type:draft-mtp before the model is
// ever loaded - users see it in the YAML preview rather than discovering
// it after the first start.
maybeApplyMTPDefaults(&modelConfig, details, &cfg)
//
// vllm-cpp is excluded on both counts: `spec_type:*` are llama.cpp option
// keys it does not read, and vllm.cpp rejects an MTP config over a GGUF
// source outright (the `mtp.*` draft tensors exist only in the safetensors
// checkpoint). Its MTP auto-config runs in the vllm importer instead, over
// the safetensors config.json.
if backend != "vllm-cpp" {
maybeApplyMTPDefaults(&modelConfig, details, &cfg)
}
data, err := yaml.Marshal(modelConfig)
if err != nil {

View File

@@ -1,13 +1,21 @@
package importers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/httpclient"
"github.com/mudler/xlog"
"go.yaml.in/yaml/v2"
)
@@ -107,6 +115,12 @@ func (i *VLLMImporter) Import(details Details) (gallery.ModelConfig, error) {
// vllm python backend, so use_tokenizer_template carries over), but
// tool/reasoning parsing is the engine's own autoparser pipeline -
// the vllm-python tool_parser/reasoning_parser options don't apply.
//
// Auto-detect a Multi-Token Prediction head, the safetensors analogue
// of the llama-cpp importer's GGUF hook, so a freshly imported
// Qwen3.5 / Qwen3.6 config already carries speculative decoding in its
// engine_args instead of leaving the throughput on the table.
maybeApplyVLLMSpeculativeDefaults(&modelConfig, details)
} else {
// Auto-detect tool_parser and reasoning_parser for known model families.
// Surfacing them in the generated YAML lets users see and edit the choices.
@@ -132,3 +146,89 @@ func (i *VLLMImporter) Import(details Details) (gallery.ModelConfig, error) {
ConfigFile: string(data),
}, nil
}
// maxSpecConfigProbeBytes caps the config.json body we read. Real ones are a
// few KB; the cap keeps a hostile or mislabelled URL from streaming into the
// importer.
const maxSpecConfigProbeBytes = 1 << 20 // 1 MiB
// specConfigProbeTimeout bounds the config.json fetch. Detection is an
// optimisation, so it must never hold an import open for long.
const specConfigProbeTimeout = 30 * time.Second
// specConfigFetcher is the seam the config.json probe goes through, so tests can
// drive the whole import path without a network round trip.
var specConfigFetcher = fetchProbeBody
// maybeApplyVLLMSpeculativeDefaults fetches the repository's config.json and,
// when it declares a Multi-Token Prediction head, enables MTP speculative
// decoding in the emitted engine_args. This is the safetensors counterpart of
// the llama-cpp importer's GGUF header probe.
//
// Every failure is non-fatal and logged at debug: a network blip, a private
// repo, or a config.json this doesn't understand must leave the import working
// exactly as it did before, just without the speculative default.
func maybeApplyVLLMSpeculativeDefaults(modelConfig *config.ModelConfig, details Details) {
probeURL := vllmSpecProbeURL(details)
if probeURL == "" {
return
}
body, err := specConfigFetcher(probeURL)
if err != nil {
xlog.Debug("[vllm-spec-importer] could not read config.json for MTP detection", "uri", probeURL, "error", err)
return
}
applySpecFromConfigJSON(modelConfig, body, details.URI)
}
// applySpecFromConfigJSON is the decision half of the probe, split out so it can
// be exercised without a network round trip.
func applySpecFromConfigJSON(modelConfig *config.ModelConfig, body []byte, uri string) {
if config.IsDFlashDraftConfig(body) {
// A DFlash draft cannot serve on its own - it only proposes tokens for
// a target model to verify. Say so rather than emitting a config that
// would fail at load.
xlog.Warn("[vllm-spec-importer] this repository is a DFlash DRAFT checkpoint, not a servable model; "+
"import the TARGET model and point engine_args.speculative_config at this repo "+
`({"method":"dflash","model":"<this repo>"})`, "uri", uri)
return
}
n, ok := config.HasSafetensorsMTPHead(body)
if !ok {
return
}
config.ApplyVLLMSpeculativeDefaults(modelConfig, n)
}
// vllmSpecProbeURL returns the HTTP(S) URL of the repository's config.json, or
// "" when the import isn't backed by a HuggingFace repo we can fetch from (a
// local directory import, an OCI artifact, ...).
func vllmSpecProbeURL(details Details) string {
if details.HuggingFace == nil || details.HuggingFace.ModelID == "" {
return ""
}
return resolveHTTPProbe(downloader.HuggingFacePrefix + details.HuggingFace.ModelID + "/config.json")
}
// fetchProbeBody GETs a small remote JSON document under a short timeout.
func fetchProbeBody(url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), specConfigProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := httpclient.NewWithTimeout(specConfigProbeTimeout).Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, maxSpecConfigProbeBytes))
}

View File

@@ -0,0 +1,118 @@
package importers
import (
"encoding/json"
"errors"
"github.com/mudler/LocalAI/core/config"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("vllm-cpp speculative auto-config (importer)", func() {
Context("applySpecFromConfigJSON", func() {
It("enables mtp when the checkpoint declares an MTP head", func() {
cfg := &config.ModelConfig{Name: "qwen3.5"}
applySpecFromConfigJSON(cfg, []byte(`{
"model_type": "qwen3_5_moe",
"mtp_num_hidden_layers": 1
}`), "huggingface://Qwen/Qwen3.5-A3B")
Expect(cfg.EngineArgs).To(HaveKeyWithValue("speculative_config",
map[string]any{"method": "mtp"}))
})
It("leaves a plain checkpoint untouched", func() {
cfg := &config.ModelConfig{Name: "llama"}
applySpecFromConfigJSON(cfg, []byte(`{"model_type": "llama"}`), "huggingface://meta/llama")
Expect(cfg.EngineArgs).To(BeEmpty())
})
It("refuses to configure a DFlash draft as a servable model", func() {
// The draft only proposes tokens; configuring it standalone would
// produce a model that cannot load.
cfg := &config.ModelConfig{Name: "dflash-draft"}
applySpecFromConfigJSON(cfg, []byte(`{
"model_type": "qwen3_dflash",
"dflash_config": {"mask_token_id": 151666, "target_layer_ids": [0, 1]}
}`), "huggingface://z-lab/Qwen3.6-27B-DFlash")
Expect(cfg.EngineArgs).To(BeEmpty())
})
It("survives a config.json it cannot parse", func() {
cfg := &config.ModelConfig{Name: "weird"}
Expect(func() {
applySpecFromConfigJSON(cfg, []byte(`<html>404</html>`), "huggingface://a/b")
}).ToNot(Panic())
Expect(cfg.EngineArgs).To(BeEmpty())
})
})
Context("Import over a repository with an MTP head", func() {
var restore func()
BeforeEach(func() {
original := specConfigFetcher
restore = func() { specConfigFetcher = original }
})
AfterEach(func() { restore() })
importWith := func(backend, configJSON string) string {
specConfigFetcher = func(string) ([]byte, error) {
return []byte(configJSON), nil
}
importer := &VLLMImporter{}
out, err := importer.Import(Details{
URI: "huggingface://Qwen/Qwen3.5-A3B",
Preferences: json.RawMessage(`{"backend": "` + backend + `"}`),
HuggingFace: &hfapi.ModelDetails{ModelID: "Qwen/Qwen3.5-A3B"},
})
Expect(err).ToNot(HaveOccurred())
return out.ConfigFile
}
It("emits engine_args.speculative_config for vllm-cpp", func() {
yaml := importWith("vllm-cpp", `{"model_type":"qwen3_5_moe","mtp_num_hidden_layers":1}`)
Expect(yaml).To(ContainSubstring("engine_args:"))
Expect(yaml).To(ContainSubstring("speculative_config:"))
Expect(yaml).To(ContainSubstring("method: mtp"))
})
It("emits nothing speculative for the python vllm backend", func() {
// The python backend has its own speculative surface and its own
// version-dependent MTP support; this hook is vllm-cpp only.
yaml := importWith("vllm", `{"model_type":"qwen3_5_moe","mtp_num_hidden_layers":1}`)
Expect(yaml).NotTo(ContainSubstring("speculative_config"))
})
It("emits nothing speculative when the probe fails", func() {
specConfigFetcher = func(string) ([]byte, error) {
return nil, errors.New("network down")
}
importer := &VLLMImporter{}
out, err := importer.Import(Details{
URI: "huggingface://Qwen/Qwen3.5-A3B",
Preferences: json.RawMessage(`{"backend": "vllm-cpp"}`),
HuggingFace: &hfapi.ModelDetails{ModelID: "Qwen/Qwen3.5-A3B"},
})
Expect(err).ToNot(HaveOccurred())
Expect(out.ConfigFile).NotTo(ContainSubstring("speculative_config"))
})
})
Context("vllmSpecProbeURL", func() {
It("resolves the repository's config.json to an HTTPS URL", func() {
url := vllmSpecProbeURL(Details{
URI: "huggingface://Qwen/Qwen3.5-A3B",
HuggingFace: &hfapi.ModelDetails{ModelID: "Qwen/Qwen3.5-A3B"},
})
Expect(url).To(ContainSubstring("Qwen/Qwen3.5-A3B"))
Expect(url).To(HaveSuffix("config.json"))
Expect(url).To(HavePrefix("https://"))
})
It("skips the probe when there is no HuggingFace repo behind the import", func() {
Expect(vllmSpecProbeURL(Details{URI: "/models/local-dir"})).To(BeEmpty())
})
})
})

View File

@@ -39,6 +39,8 @@ var RouteFeatureRegistry = []RouteFeature{
{"POST", "/images/generations", FeatureImages},
{"POST", "/v1/images/inpainting", FeatureImages},
{"POST", "/images/inpainting", FeatureImages},
{"POST", "/v1/images/upscale", FeatureImages},
{"POST", "/images/upscale", FeatureImages},
// Audio transcription
{"POST", "/v1/audio/transcriptions", FeatureAudioTranscription},
@@ -116,6 +118,10 @@ var RouteFeatureRegistry = []RouteFeature{
// Rerank
{"POST", "/v1/rerank", FeatureRerank},
// Moderation
{"POST", "/v1/moderations", FeatureModeration},
{"POST", "/moderations", FeatureModeration},
// Stores
{"POST", "/stores/set", FeatureStores},
{"POST", "/stores/delete", FeatureStores},
@@ -191,6 +197,7 @@ func APIFeatureMetas() []FeatureMeta {
{FeatureEmbeddings, "Embeddings", true},
{FeatureSound, "Sound Generation", true},
{FeatureRealtime, "Realtime", true},
{FeatureModeration, "Moderation", true},
{FeatureRerank, "Rerank", true},
{FeatureTokenize, "Tokenize", true},
{FeatureMCP, "MCP", true},

View File

@@ -0,0 +1,24 @@
package auth_test
import (
. "github.com/mudler/LocalAI/core/http/auth"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Moderation feature registration", func() {
It("registers both moderation routes as default-on API features", func() {
Expect(APIFeatures).To(ContainElement(FeatureModeration))
patterns := []string{}
for _, route := range RouteFeatureRegistry {
if route.Feature == FeatureModeration {
patterns = append(patterns, route.Pattern)
}
}
Expect(patterns).To(ConsistOf("/v1/moderations", "/moderations"))
metas := APIFeatureMetas()
Expect(metas).To(ContainElement(FeatureMeta{Key: FeatureModeration, Label: "Moderation", DefaultValue: true}))
})
})

View File

@@ -59,10 +59,14 @@ func ok(c echo.Context) error {
func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
e := echo.New()
e.Use(auth.Middleware(db, appConfig))
if db != nil {
e.Use(auth.RequireRouteFeature(db))
}
// API routes (require auth)
e.GET("/v1/models", ok)
e.POST("/v1/chat/completions", ok)
e.POST("/v1/moderations", ok)
e.GET("/api/settings", ok)
e.POST("/api/settings", ok)
@@ -81,10 +85,14 @@ func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
func newAdminTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
e := echo.New()
e.Use(auth.Middleware(db, appConfig))
if db != nil {
e.Use(auth.RequireRouteFeature(db))
}
// Regular routes
e.GET("/v1/models", ok)
e.POST("/v1/chat/completions", ok)
e.POST("/v1/moderations", ok)
// Admin-only routes
adminMw := auth.RequireAdmin()

View File

@@ -91,6 +91,19 @@ var _ = Describe("Auth Middleware", func() {
Expect(rec.Code).To(Equal(http.StatusOK))
})
It("allows authenticated users to call moderation by default", func() {
sessionID := createTestSession(db, user.ID)
rec := doRequest(app, http.MethodPost, "/v1/moderations", withSessionCookie(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
})
It("blocks moderation when the user's feature is disabled", func() {
Expect(auth.UpdateUserPermissions(db, user.ID, auth.PermissionMap{auth.FeatureModeration: false})).To(Succeed())
sessionID := createTestSession(db, user.ID)
rec := doRequest(app, http.MethodPost, "/v1/moderations", withSessionCookie(sessionID))
Expect(rec.Code).To(Equal(http.StatusForbidden))
})
It("allows requests with valid session as Bearer token", func() {
sessionID := createTestSession(db, user.ID)
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(sessionID))
@@ -156,6 +169,11 @@ var _ = Describe("Auth Middleware", func() {
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("returns 401 for unauthenticated moderation requests", func() {
rec := doRequest(app, http.MethodPost, "/v1/moderations")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("returns 401 for unauthenticated 3D generation requests", func() {
rec := doRequest(app, http.MethodPost, "/3d/generations")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))

View File

@@ -51,6 +51,7 @@ const (
FeatureEmbeddings = "embeddings"
FeatureSound = "sound"
FeatureRealtime = "realtime"
FeatureModeration = "moderation"
FeatureRerank = "rerank"
FeatureTokenize = "tokenize"
FeatureMCP = "mcp"
@@ -75,7 +76,7 @@ var APIFeatures = []string{
FeatureChat, FeatureImages, FeatureAudioSpeech, FeatureAudioTranscription,
FeatureAudioDiarization, FeatureAudioClassification,
FeatureVAD, FeatureDetection, FeatureVideo, Feature3D, FeatureEmbeddings, FeatureSound,
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
FeatureRealtime, FeatureModeration, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
FeaturePIIFilter,
}

View File

@@ -30,6 +30,12 @@ var instructionDefs = []instructionDef{
Tags: []string{"inference", "embeddings"},
Intro: "Set \"stream\": true for SSE streaming. Supports tool/function calling when the model config has function templates configured.",
},
{
Name: "moderation",
Description: "OpenAI-compatible text moderation using a local completion model",
Tags: []string{"moderation"},
Intro: "POST /v1/moderations accepts a text string or array plus a LocalAI completion model. LocalAI constrains the model to the OpenAI moderation category schema and returns one result per input. Multimodal moderation inputs are not yet supported.",
},
{
Name: "audio",
Description: "Text-to-speech, voice activity detection, transcription, speaker diarization, sound classification, and sound generation",

View File

@@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {
instructions, ok := resp["instructions"].([]any)
Expect(ok).To(BeTrue())
Expect(instructions).To(HaveLen(18))
Expect(instructions).To(HaveLen(19))
// Verify each instruction has required fields and correct URL format
for _, s := range instructions {
@@ -69,6 +69,7 @@ var _ = Describe("API Instructions Endpoints", func() {
Expect(names).To(ContainElements(
"chat-inference",
"moderation",
"config-management",
"model-management",
"monitoring",

View File

@@ -84,6 +84,22 @@ func (stubClient) ListNodes(_ context.Context) ([]localaitools.Node, error) {
return []localaitools.Node{}, nil
}
func (stubClient) ListScheduling(_ context.Context) ([]localaitools.ModelSchedulingConfig, error) {
return []localaitools.ModelSchedulingConfig{}, nil
}
func (stubClient) GetScheduling(_ context.Context, _ string) (*localaitools.ModelSchedulingConfig, error) {
return &localaitools.ModelSchedulingConfig{}, nil
}
func (stubClient) SetScheduling(_ context.Context, _ localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
return &localaitools.ModelSchedulingConfig{}, nil
}
func (stubClient) DeleteScheduling(_ context.Context, _ string) error {
return nil
}
func (stubClient) SetNodeVRAMBudget(_ context.Context, _, _ string) error {
return nil
}

View File

@@ -0,0 +1,190 @@
package openai
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
)
var moderationCategories = []string{
"harassment",
"harassment/threatening",
"hate",
"hate/threatening",
"illicit",
"illicit/violent",
"self-harm",
"self-harm/intent",
"self-harm/instructions",
"sexual",
"sexual/minors",
"violence",
"violence/graphic",
}
type moderationGenerator func(context.Context, string, *config.ModelConfig) (string, backend.TokenUsage, error)
type generatedModeration struct {
Categories map[string]bool `json:"categories"`
CategoryScores map[string]float64 `json:"category_scores"`
}
// ModerationEndpoint implements the text input subset of OpenAI's moderation
// API using any LocalAI completion model and constrained JSON generation.
// @Summary Classify text for potentially harmful content.
// @Tags moderation
// @Param request body schema.ModerationRequest true "query params"
// @Success 200 {object} schema.ModerationResponse "Response"
// @Router /v1/moderations [post]
func ModerationEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return moderationEndpoint(func(ctx context.Context, input string, cfg *config.ModelConfig) (string, backend.TokenUsage, error) {
prompt := moderationPrompt(input)
var messages schema.Messages
if cfg.TemplateConfig.UseTokenizerTemplate {
messages = schema.Messages{{Role: "user", Content: prompt}}
prompt = ""
} else if evaluator != nil {
if rendered, err := evaluator.EvaluateTemplateForPrompt(templates.CompletionPromptTemplate, *cfg, templates.PromptTemplateData{Input: prompt, SystemPrompt: cfg.SystemPrompt}); err == nil {
prompt = rendered
}
}
predict, err := backend.ModelInferenceFunc(ctx, prompt, messages, nil, nil, nil, ml, cfg, cl, appConfig, nil, "", "", nil, nil, nil, nil)
if err != nil {
return "", backend.TokenUsage{}, err
}
response, err := predict()
return response.Response, response.Usage, err
})
}
func moderationEndpoint(generate moderationGenerator) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.ModerationRequest)
if !ok || input == nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid moderation request")
}
if len(input.Input) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "input must contain at least one text string")
}
if generate == nil {
return echo.NewHTTPError(http.StatusInternalServerError, "moderation generator is unavailable")
}
modelConfig, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || modelConfig == nil {
return echo.NewHTTPError(http.StatusBadRequest, "moderation model configuration is unavailable")
}
grammar, err := moderationGrammar()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to build moderation grammar").SetInternal(err)
}
cfg := *modelConfig
cfg.Grammar = grammar
maxTokens := 512
cfg.Maxtokens = &maxTokens
results := make([]schema.ModerationResult, 0, len(input.Input))
promptTokens, completionTokens := 0, 0
for _, text := range input.Input {
if strings.TrimSpace(text) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "input strings must not be empty")
}
raw, usage, err := generate(c.Request().Context(), text, &cfg)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "moderation inference failed").SetInternal(err)
}
result, err := parseModerationResult(raw)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "invalid moderation result: "+err.Error()).SetInternal(err)
}
results = append(results, result)
promptTokens += usage.Prompt
completionTokens += usage.Completion
}
middleware.StampUsage(c, input.Model, promptTokens, completionTokens)
return c.JSON(http.StatusOK, schema.ModerationResponse{
ID: "modr-" + uuid.NewString(),
Model: input.Model,
Results: results,
})
}
}
func moderationPrompt(input string) string {
encoded, _ := json.Marshal(input)
return `Classify the supplied text for safety moderation. Return only the JSON object required by the grammar.
For each category, set the boolean to true only when the text matches that category. Give each category a confidence score from 0 to 1.
The categories are harassment, harassment/threatening, hate, hate/threatening, illicit, illicit/violent, self-harm, self-harm/intent, self-harm/instructions, sexual, sexual/minors, violence, and violence/graphic.
Text to classify: ` + string(encoded)
}
func moderationGrammar() (string, error) {
boolProperties := map[string]any{}
scoreProperties := map[string]any{}
for _, category := range moderationCategories {
boolProperties[category] = map[string]any{"type": "boolean"}
scoreProperties[category] = map[string]any{"type": "number"}
}
structure := functions.JSONFunctionStructure{AnyOf: []functions.Item{{
Type: "object",
Properties: map[string]any{
"categories": map[string]any{
"type": "object",
"properties": boolProperties,
"required": moderationCategories,
"additionalProperties": false,
},
"category_scores": map[string]any{
"type": "object",
"properties": scoreProperties,
"required": moderationCategories,
"additionalProperties": false,
},
},
}}}
return structure.Grammar()
}
func parseModerationResult(raw string) (schema.ModerationResult, error) {
var generated generatedModeration
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &generated); err != nil {
return schema.ModerationResult{}, err
}
result := schema.ModerationResult{
Categories: make(map[string]bool, len(moderationCategories)),
CategoryScores: make(map[string]float64, len(moderationCategories)),
CategoryAppliedInputTypes: make(map[string][]string, len(moderationCategories)),
}
for _, category := range moderationCategories {
flagged, exists := generated.Categories[category]
if !exists {
return schema.ModerationResult{}, fmt.Errorf("missing category %q", category)
}
score, exists := generated.CategoryScores[category]
if !exists || math.IsNaN(score) || math.IsInf(score, 0) || score < 0 || score > 1 {
return schema.ModerationResult{}, fmt.Errorf("category %q has an invalid score", category)
}
result.Categories[category] = flagged
result.CategoryScores[category] = score
result.CategoryAppliedInputTypes[category] = []string{"text"}
result.Flagged = result.Flagged || flagged
}
return result, nil
}

View File

@@ -0,0 +1,105 @@
package openai
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Moderations endpoint", func() {
It("classifies each text input and returns the OpenAI response shape", func() {
inputs := []string{}
generate := func(_ context.Context, input string, cfg *config.ModelConfig) (string, backend.TokenUsage, error) {
inputs = append(inputs, input)
Expect(cfg.Grammar).To(ContainSubstring("harassment"))
return `{
"categories":{"harassment":true,"harassment/threatening":false,"hate":false,"hate/threatening":false,"illicit":false,"illicit/violent":false,"self-harm":false,"self-harm/intent":false,"self-harm/instructions":false,"sexual":false,"sexual/minors":false,"violence":false,"violence/graphic":false},
"category_scores":{"harassment":0.9,"harassment/threatening":0.1,"hate":0,"hate/threatening":0,"illicit":0,"illicit/violent":0,"self-harm":0,"self-harm/intent":0,"self-harm/instructions":0,"sexual":0,"sexual/minors":0,"violence":0,"violence/graphic":0}
}`, backend.TokenUsage{Prompt: 12, Completion: 8}, nil
}
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/v1/moderations", strings.NewReader(`{"model":"guard","input":["first","second"]}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
Input: schema.ModerationInput{"first", "second"},
})
modelConfig := &config.ModelConfig{Name: "guard"}
modelConfig.Model = "guard.gguf"
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, modelConfig)
Expect(moderationEndpoint(generate)(ctx)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(inputs).To(Equal([]string{"first", "second"}))
var response schema.ModerationResponse
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
Expect(response.ID).To(HavePrefix("modr-"))
Expect(response.Model).To(Equal("guard"))
Expect(response.Results).To(HaveLen(2))
Expect(response.Results[0].Flagged).To(BeTrue())
Expect(response.Results[0].Categories["harassment"]).To(BeTrue())
Expect(response.Results[0].CategoryAppliedInputTypes["harassment"]).To(Equal([]string{"text"}))
})
It("rejects an empty input list", func() {
e := echo.New()
ctx := e.NewContext(httptest.NewRequest(http.MethodPost, "/v1/moderations", nil), httptest.NewRecorder())
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
})
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Name: "guard"})
err := moderationEndpoint(nil)(ctx)
Expect(err).To(MatchError(ContainSubstring("input must contain at least one text string")))
Expect(err.(*echo.HTTPError).Code).To(Equal(http.StatusBadRequest))
})
It("surfaces malformed classifier output without returning a partial result", func() {
generate := func(context.Context, string, *config.ModelConfig) (string, backend.TokenUsage, error) {
return "not-json", backend.TokenUsage{}, nil
}
e := echo.New()
ctx := e.NewContext(httptest.NewRequest(http.MethodPost, "/v1/moderations", nil), httptest.NewRecorder())
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
Input: schema.ModerationInput{"text"},
})
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Name: "guard"})
err := moderationEndpoint(generate)(ctx)
Expect(err).To(MatchError(ContainSubstring("invalid moderation result")))
Expect(err.(*echo.HTTPError).Code).To(Equal(http.StatusInternalServerError))
})
})
var _ = Describe("Moderation input", func() {
DescribeTable("accepts OpenAI text input forms",
func(body string, expected schema.ModerationInput) {
var req schema.ModerationRequest
Expect(json.Unmarshal([]byte(body), &req)).To(Succeed())
Expect(req.Input).To(Equal(expected))
},
Entry("single text", `{"input":"hello"}`, schema.ModerationInput{"hello"}),
Entry("text array", `{"input":["hello","world"]}`, schema.ModerationInput{"hello", "world"}),
)
It("rejects multimodal input in the text-only MVP", func() {
var req schema.ModerationRequest
err := json.Unmarshal([]byte(`{"input":[{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}`), &req)
Expect(err).To(MatchError(ContainSubstring("text string or array of text strings")))
})
})

View File

@@ -0,0 +1,134 @@
package openai
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/xlog"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
model "github.com/mudler/LocalAI/pkg/model"
)
// UpscaleEndpoint handles POST /v1/images/upscale
//
// @Summary Image upscaling
// @Description Upscale an image using a specified model (e.g. stable-diffusion-x4-upscaler). Accepts multipart/form-data.
// @Tags images
// @Accept multipart/form-data
// @Produce application/json
// @Param model formData string true "Upscaler model identifier (e.g. stable-diffusion-x4-upscaler)"
// @Param image formData file true "Input image file"
// @Param scale formData int false "Upscale factor: 2 or 4 (default 2)"
// @Success 200 {object} schema.OpenAIResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /v1/images/upscale [post]
func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
modelName := c.FormValue("model")
scaleStr := c.FormValue("scale")
if modelName == "" {
xlog.Error("Upscale Endpoint - missing model")
return echo.NewHTTPError(http.StatusBadRequest, "missing model")
}
scale := 2
if scaleStr != "" {
v, err := strconv.Atoi(scaleStr)
if err != nil || (v != 2 && v != 4) {
return echo.NewHTTPError(http.StatusBadRequest, "scale must be 2 or 4")
}
scale = v
}
// Read uploaded image
imageFile, err := c.FormFile("image")
if err != nil {
xlog.Error("Upscale Endpoint - missing image file", "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "missing image file")
}
imgSrc, err := imageFile.Open()
if err != nil {
return err
}
defer imgSrc.Close()
imgBytes, err := io.ReadAll(imgSrc)
if err != nil {
return err
}
// Get model config from middleware context
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
xlog.Error("Upscale Endpoint - model config not found in context")
return echo.ErrBadRequest
}
tmpDir := filepath.Join(appConfig.GeneratedContentDir, "images")
if err := os.MkdirAll(tmpDir, 0750); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to prepare storage")
}
// Write input image to a temp file
srcTmp, err := os.CreateTemp(tmpDir, "upscale_src_")
if err != nil {
return err
}
if _, err := srcTmp.Write(imgBytes); err != nil {
_ = srcTmp.Close()
_ = os.Remove(srcTmp.Name())
return err
}
if err := srcTmp.Close(); err != nil {
xlog.Warn("Upscale Endpoint - failed to close src temp file", "error", err)
}
srcPath := srcTmp.Name()
defer os.Remove(srcPath)
// Prepare output file path
id := uuid.New().String()
dstPath := filepath.Join(tmpDir, fmt.Sprintf("upscale_%s.png", id))
fn, err := backend.ImageUpscaleFunc(c.Request().Context(), srcPath, dstPath, scale, ml, *cfg, appConfig)
if err != nil {
return err
}
if err := fn(); err != nil {
_ = os.Remove(dstPath)
return err
}
baseURL := middleware.BaseURL(c)
imgURL, err := url.JoinPath(baseURL, "generated-images", filepath.Base(dstPath))
if err != nil {
_ = os.Remove(dstPath)
return err
}
created := int(time.Now().Unix())
resp := &schema.OpenAIResponse{
ID: id,
Created: created,
Data: []schema.Item{{URL: imgURL}},
Usage: &schema.OpenAIUsage{
InputTokensDetails: &schema.InputTokensDetails{},
},
}
return c.JSON(http.StatusOK, resp)
}
}

View File

@@ -0,0 +1,89 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
model "github.com/mudler/LocalAI/pkg/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Image upscaling", func() {
var (
appConfig *config.ApplicationConfig
tmpDir string
)
BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "upscale")
Expect(err).ToNot(HaveOccurred())
appConfig = config.NewApplicationConfig(config.WithGeneratedContentDir(tmpDir))
})
AfterEach(func() {
Expect(os.RemoveAll(tmpDir)).To(Succeed())
})
It("stores the result in the directory served by /generated-images", func() {
original := backend.ImageUpscaleFunc
backend.ImageUpscaleFunc = func(_ context.Context, _, dst string, scale int, _ *model.ModelLoader, _ config.ModelConfig, _ *config.ApplicationConfig) (func() error, error) {
Expect(scale).To(Equal(4))
return func() error {
return os.WriteFile(dst, []byte("PNGDATA"), 0o644)
}, nil
}
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
req, _ := makeMultipartRequest(
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "4"},
map[string][]byte{"image": []byte("IMAGEDATA")},
)
rec := httptest.NewRecorder()
ctx := echo.New().NewContext(req, rec)
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
Expect(UpscaleEndpoint(nil, nil, appConfig)(ctx)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
var response schema.OpenAIResponse
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
Expect(response.Data).To(HaveLen(1))
Expect(response.Data[0].URL).To(ContainSubstring("/generated-images/upscale_"))
filename := filepath.Base(response.Data[0].URL)
contents, err := os.ReadFile(filepath.Join(tmpDir, "images", filename))
Expect(err).ToNot(HaveOccurred())
Expect(contents).To(Equal([]byte("PNGDATA")))
})
It("rejects unsupported scale factors", func() {
req, _ := makeMultipartRequest(
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "3"},
map[string][]byte{"image": []byte("IMAGEDATA")},
)
rec := httptest.NewRecorder()
ctx := echo.New().NewContext(req, rec)
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
err := UpscaleEndpoint(nil, nil, appConfig)(ctx)
var httpErr *echo.HTTPError
Expect(err).To(MatchError(ContainSubstring("scale must be 2 or 4")))
Expect(err).To(BeAssignableToTypeOf(httpErr))
httpErr = err.(*echo.HTTPError)
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
Expect(httpErr.Message).To(Equal("scale must be 2 or 4"))
Expect(bytes.TrimSpace(rec.Body.Bytes())).To(BeEmpty())
})
})

View File

@@ -43,6 +43,40 @@ test('lists live operations and cancels one from a labelled button', async ({ pa
expect(cancelledPath).toBe('/api/operations/job-gemma/cancel')
})
test('pauses a model download without invoking destructive cancel', async ({ page }) => {
await stub(page, {
operations: [{
id: 'gemma-3-27b-it',
name: 'gemma-3-27b-it',
jobID: 'job-gemma',
progress: 22,
taskType: 'installation',
isBackend: false,
isQueued: false,
isDeletion: false,
cancellable: true,
phase: 'downloading',
}],
})
const requests = []
await page.route('**/api/operations/job-gemma/pause', (route) => {
requests.push(new URL(route.request().url()).pathname)
return route.fulfill({ contentType: 'application/json', body: '{}' })
})
await page.route('**/api/operations/job-gemma/cancel', (route) => {
requests.push(new URL(route.request().url()).pathname)
return route.fulfill({ contentType: 'application/json', body: '{}' })
})
await page.goto('/app/activity')
const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' })
await card.locator('.operation-card__pause').click()
await expect.poll(() => requests).toEqual(['/api/operations/job-gemma/pause'])
})
test('separates an unacknowledged failure from the record', async ({ page }) => {
await stub(page, {
operations: [{

View File

@@ -14,6 +14,16 @@ const ROUTES = [
]
test('no page renders a dead icon or a default-chrome control', async ({ page }) => {
// One test walks every route, so its budget has to scale with the list rather
// than sit on Playwright's per-test default of 30s. At 25 routes that default
// allows ~1.2s per navigation, which holds on a developer machine and does
// not on a loaded CI runner: the suite went red on the commit that added this
// spec, timing out mid-loop at waitForTimeout rather than at any single goto,
// which is what cumulative slowness looks like as opposed to one hung route.
// Six seconds a route absorbs a slow runner and still fails promptly if a
// route really does hang.
test.setTimeout(ROUTES.length * 6_000)
const findings = []
for (const route of ROUTES) {
await page.goto(route)

View File

@@ -21,9 +21,10 @@
"@fortawesome/fontawesome-free": "^6.7.2",
"@lezer/highlight": "^1.2.1",
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"dompurify": "^3.4.12",
"highlight.js": "^11.11.1",
"hono": "4.12.25",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.6",
@@ -944,11 +945,12 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -1718,10 +1720,11 @@
"dev": true
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -3432,9 +3435,9 @@
}
},
"node_modules/hono": {
"version": "4.12.31",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
"version": "4.12.25",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -4383,16 +4386,16 @@
}
},
"node_modules/istanbul-lib-processinfo/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/istanbul-lib-processinfo/node_modules/glob": {
@@ -5278,16 +5281,16 @@
}
},
"node_modules/nyc/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/nyc/node_modules/convert-source-map": {
@@ -5974,10 +5977,11 @@
}
},
"node_modules/quick-temp/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -6569,16 +6573,16 @@
}
},
"node_modules/spawn-wrap/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/spawn-wrap/node_modules/foreground-child": {
@@ -6902,16 +6906,16 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/test-exclude/node_modules/glob": {

View File

@@ -35,7 +35,7 @@
"@fortawesome/fontawesome-free": "^6.7.2",
"@lezer/highlight": "^1.2.1",
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"dompurify": "^3.4.12",
"highlight.js": "^11.11.1",
"hono": "4.12.25",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",

View File

@@ -29,7 +29,7 @@ function formatEta(seconds) {
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
}
export default function OperationCard({ operation, onCancel, onDismiss, onRetry }) {
export default function OperationCard({ operation, onCancel, onPause, onDismiss, onRetry }) {
const { t } = useTranslation('admin')
const nodes = Array.isArray(operation.nodes) ? operation.nodes : []
// Holds only what the user chose. The default has to stay a live
@@ -144,6 +144,16 @@ export default function OperationCard({ operation, onCancel, onDismiss, onRetry
<div className="operation-card__actions">
{showProgress && <span className="operation-card__pct" aria-hidden="true">{Math.round(operation.progress)}%</span>}
{canCancel && (
<button
type="button"
className="btn btn-sm btn-secondary operation-card__pause"
onClick={() => onPause?.(operation.jobID)}
aria-label={t('activity.pauseLabel', { name })}
>
{t('activity.pause')}
</button>
)}
{canCancel && (
// A page of cards would otherwise hand a screen reader a list of
// identical "Cancel" buttons with nothing to tell them apart.

View File

@@ -137,6 +137,16 @@ export function OperationsProvider({ children, pollInterval = 1000 }) {
}
}, [fetchOperations])
const pauseOperation = useCallback(async (jobID) => {
try {
await operationsApi.pause(jobID)
cancelledRef.current.set(jobID, Date.now())
await fetchOperations()
} catch (err) {
setError(err.message)
}
}, [fetchOperations])
// Whether this tab cancelled the job. Read by the strip to tell "the last
// operation finished" from "the user called it off": both look identical in
// /api/operations, which lists neither.
@@ -226,6 +236,7 @@ export function OperationsProvider({ children, pollInterval = 1000 }) {
fetchHistory,
clearHistory,
cancelOperation,
pauseOperation,
wasCancelled,
dismissFailedOp,
refetch: fetchOperations,

View File

@@ -83,7 +83,7 @@ export default function Activity() {
const { t } = useTranslation('admin')
const outlet = useOutletContext()
const addToast = outlet?.addToast
const { operations, history, fetchHistory, clearHistory, cancelOperation, dismissFailedOp } = useOperations()
const { operations, history, fetchHistory, clearHistory, cancelOperation, pauseOperation, dismissFailedOp } = useOperations()
const [filter, setFilter] = useState('all')
useEffect(() => { fetchHistory() }, [fetchHistory])
@@ -195,7 +195,7 @@ export default function Activity() {
{t('activity.inProgress')} <span className="activity-section__count">{live.length}</span>
</h2>
{live.map((op) => (
<OperationCard key={op.jobID || op.id} operation={op} onCancel={cancelOperation} />
<OperationCard key={op.jobID || op.id} operation={op} onCancel={cancelOperation} onPause={pauseOperation} />
))}
</section>
)}

View File

@@ -173,6 +173,7 @@ export const resourcesApi = {
export const operationsApi = {
list: () => fetchJSON(API_CONFIG.endpoints.operations),
cancel: (jobID) => postJSON(API_CONFIG.endpoints.cancelOperation(jobID), {}),
pause: (jobID) => postJSON(API_CONFIG.endpoints.pauseOperation(jobID), {}),
dismiss: (jobID) => postJSON(API_CONFIG.endpoints.dismissOperation(jobID), {}),
history: () => fetchJSON(API_CONFIG.endpoints.operationsHistory),
clearHistory: () => fetchJSON(API_CONFIG.endpoints.operationsHistory, { method: 'DELETE' }),

View File

@@ -4,6 +4,7 @@ export const API_CONFIG = {
operations: '/api/operations',
operationsHistory: '/api/operations/history',
cancelOperation: (jobID) => `/api/operations/${jobID}/cancel`,
pauseOperation: (jobID) => `/api/operations/${jobID}/pause`,
dismissOperation: (jobID) => `/api/operations/${jobID}/dismiss`,
// Models gallery

View File

@@ -141,6 +141,20 @@ func RegisterOpenAIRoutes(app *echo.Echo,
app.POST("/completions", completionHandler, completionMiddleware...)
app.POST("/v1/engines/:model/completions", completionHandler, completionMiddleware...)
// moderation
moderationHandler := openai.ModerationEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
moderationMiddleware := []echo.MiddlewareFunc{
nodeHeaderMiddleware,
usageMiddleware,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_COMPLETION)),
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.ModerationRequest) }),
middleware.AdmissionControl(application.AdmissionLimiter(), application.PIIEvents()),
}
app.POST("/v1/moderations", moderationHandler, moderationMiddleware...)
app.POST("/moderations", moderationHandler, moderationMiddleware...)
// embeddings
embeddingHandler := openai.EmbeddingsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
embeddingMiddleware := []echo.MiddlewareFunc{
@@ -254,6 +268,11 @@ func RegisterOpenAIRoutes(app *echo.Echo,
app.POST("/v1/images/inpainting", inpaintingHandler, imageMiddleware...)
app.POST("/images/inpainting", inpaintingHandler, imageMiddleware...)
// upscale endpoint - reuse same middleware config as images
upscaleHandler := openai.UpscaleEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
app.POST("/v1/images/upscale", upscaleHandler, imageMiddleware...)
app.POST("/images/upscale", upscaleHandler, imageMiddleware...)
// 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()))

View File

@@ -356,6 +356,24 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
})
}, adminMiddleware)
// Pause operation endpoint (admin only). Unlike cancel, pause preserves a
// partial download so submitting the same install later resumes it.
app.POST("/api/operations/:jobID/pause", func(c echo.Context) error {
jobID := c.Param("jobID")
xlog.Debug("API request to pause operation", "jobID", jobID)
if err := galleryService.PauseOperation(jobID); err != nil {
xlog.Error("Failed to pause operation", "error", err, "jobID", jobID)
return c.JSON(http.StatusBadRequest, map[string]any{"error": err.Error()})
}
opcache.DeleteUUID(jobID)
return c.JSON(200, map[string]any{
"success": true,
"message": "Operation paused",
})
}, adminMiddleware)
// Dismiss a failed operation (acknowledge the error and remove it from the list)
app.POST("/api/operations/:jobID/dismiss", func(c echo.Context) error {
jobID := c.Param("jobID")
@@ -970,7 +988,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
uid := id.String()
opcache.Set(galleryID, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
ctx, cancelFunc, pauseFunc := galleryop.NewUserCancellableContext(context.Background())
op := galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
ID: uid,
GalleryElementName: galleryID,
@@ -979,9 +997,10 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
BackendGalleries: appConfig.BackendGalleries,
Context: ctx,
CancelFunc: cancelFunc,
PauseFunc: pauseFunc,
}
// Store cancellation function immediately so queued operations can be cancelled
galleryService.StoreCancellation(uid, cancelFunc)
galleryService.StoreCancellationActions(uid, cancelFunc, pauseFunc)
galleryService.EnqueueModelOp(op)
return c.JSON(200, map[string]any{
@@ -1017,7 +1036,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
opcache.Set(galleryID, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
ctx, cancelFunc, pauseFunc := galleryop.NewUserCancellableContext(context.Background())
op := galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
ID: uid,
Delete: true,
@@ -1026,9 +1045,10 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
BackendGalleries: appConfig.BackendGalleries,
Context: ctx,
CancelFunc: cancelFunc,
PauseFunc: pauseFunc,
}
// Store cancellation function immediately so queued operations can be cancelled
galleryService.StoreCancellation(uid, cancelFunc)
galleryService.StoreCancellationActions(uid, cancelFunc, pauseFunc)
galleryService.EnqueueModelOp(op)
cl.RemoveModelConfig(galleryName)
@@ -1423,19 +1443,20 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
uid := id.String()
opcache.SetBackend(backendID, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
ctx, cancelFunc, pauseFunc := galleryop.NewUserCancellableContext(context.Background())
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: uid,
GalleryElementName: backendID,
Galleries: appConfig.BackendGalleries,
Context: ctx,
CancelFunc: cancelFunc,
PauseFunc: pauseFunc,
// The React UI's "Reinstall backend" action reuses this route, so
// the op must force even when the backend is already installed.
Force: true,
}
// Store cancellation function immediately so queued operations can be cancelled
galleryService.StoreCancellation(uid, cancelFunc)
galleryService.StoreCancellationActions(uid, cancelFunc, pauseFunc)
galleryService.EnqueueBackendOp(op)
return c.JSON(200, map[string]any{
@@ -1485,19 +1506,20 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
opcache.SetBackend(cacheKey, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
ctx, cancelFunc, pauseFunc := galleryop.NewUserCancellableContext(context.Background())
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: uid,
GalleryElementName: req.Name, // May be empty, will be derived during installation
Galleries: appConfig.BackendGalleries,
Context: ctx,
CancelFunc: cancelFunc,
PauseFunc: pauseFunc,
ExternalURI: req.URI,
ExternalName: req.Name,
ExternalAlias: req.Alias,
}
// Store cancellation function immediately so queued operations can be cancelled
galleryService.StoreCancellation(uid, cancelFunc)
galleryService.StoreCancellationActions(uid, cancelFunc, pauseFunc)
galleryService.EnqueueBackendOp(op)
return c.JSON(200, map[string]any{
@@ -1533,7 +1555,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
opcache.SetBackend(backendID, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
ctx, cancelFunc, pauseFunc := galleryop.NewUserCancellableContext(context.Background())
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: uid,
Delete: true,
@@ -1541,9 +1563,10 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
Galleries: appConfig.BackendGalleries,
Context: ctx,
CancelFunc: cancelFunc,
PauseFunc: pauseFunc,
}
// Store cancellation function immediately so queued operations can be cancelled
galleryService.StoreCancellation(uid, cancelFunc)
galleryService.StoreCancellationActions(uid, cancelFunc, pauseFunc)
galleryService.EnqueueBackendOp(op)
return c.JSON(200, map[string]any{
@@ -1652,7 +1675,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
// and the Backends UI can reflect progress on the affected row.
opcache.SetBackend(backendName, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
ctx, cancelFunc, pauseFunc := galleryop.NewUserCancellableContext(context.Background())
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: uid,
GalleryElementName: backendName,
@@ -1660,9 +1683,10 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
Upgrade: true,
Context: ctx,
CancelFunc: cancelFunc,
PauseFunc: pauseFunc,
}
// Store cancellation function immediately so queued operations can be cancelled
galleryService.StoreCancellation(uid, cancelFunc)
galleryService.StoreCancellationActions(uid, cancelFunc, pauseFunc)
galleryService.EnqueueBackendOp(op)
return c.JSON(200, map[string]any{

View File

@@ -339,6 +339,33 @@ var _ = Describe("/api/operations with node-scoped backend ops", func() {
Expect(envelope.Operations[0]).ToNot(HaveKey("isCancelled"))
})
It("pauses through the resume-safe operation callback", func() {
state, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir()))
Expect(err).NotTo(HaveOccurred())
appCfg := &config.ApplicationConfig{SystemState: state}
galleryService := galleryop.NewGalleryService(appCfg, nil)
opcache := galleryop.NewOpCache(galleryService)
opcache.Set("localai@gemma", "job-pause")
var cancelled, paused bool
galleryService.StoreCancellationActions(
"job-pause",
func() { cancelled = true },
func() { paused = true },
)
e := echo.New()
routes.RegisterUIAPIRoutes(e, nil, nil, appCfg, galleryService, opcache, &application.Application{}, noopMw)
req := httptest.NewRequest(http.MethodPost, "/api/operations/job-pause/pause", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(paused).To(BeTrue())
Expect(cancelled).To(BeFalse())
Expect(opcache.Get("localai@gemma")).To(BeEmpty())
})
It("reports a running removal as a deletion", func() {
state, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir()))
Expect(err).NotTo(HaveOccurred())

52
core/schema/moderation.go Normal file
View File

@@ -0,0 +1,52 @@
package schema
import (
"encoding/json"
"fmt"
)
// ModerationInput accepts the text-only forms supported by the OpenAI
// moderations API. Multimodal moderation can be added without changing the
// response contract once LocalAI has a moderation-capable vision path.
type ModerationInput []string
func (i *ModerationInput) UnmarshalJSON(data []byte) error {
var single string
if err := json.Unmarshal(data, &single); err == nil {
*i = ModerationInput{single}
return nil
}
var multiple []string
if err := json.Unmarshal(data, &multiple); err == nil {
*i = ModerationInput(multiple)
return nil
}
return fmt.Errorf("input must be a text string or array of text strings")
}
func (i ModerationInput) MarshalJSON() ([]byte, error) {
if len(i) == 1 {
return json.Marshal(i[0])
}
return json.Marshal([]string(i))
}
type ModerationRequest struct {
BasicModelRequest
Input ModerationInput `json:"input"`
}
type ModerationResult struct {
Flagged bool `json:"flagged"`
Categories map[string]bool `json:"categories"`
CategoryScores map[string]float64 `json:"category_scores"`
CategoryAppliedInputTypes map[string][]string `json:"category_applied_input_types"`
}
type ModerationResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Results []ModerationResult `json:"results"`
}

View File

@@ -54,6 +54,22 @@ var _ = Describe("GalleryService.CancelOperation persistence", func() {
Expect(fresh.GetStatus("op-cancel")).To(BeNil(),
"a cancelled op must not hydrate back as active after a restart")
})
It("pauses with the resume-safe callback instead of the destructive cancel callback", func() {
svc := galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
var cancelled, paused bool
svc.StoreCancellationActions("op-pause", func() { cancelled = true }, func() { paused = true })
Expect(svc.PauseOperation("op-pause")).To(Succeed())
Expect(paused).To(BeTrue())
Expect(cancelled).To(BeFalse())
status := svc.GetStatus("op-pause")
Expect(status).ToNot(BeNil())
Expect(status.Processed).To(BeTrue())
Expect(status.Cancelled).To(BeTrue())
Expect(status.Message).To(Equal("paused"))
})
})
// Reproduces "an op orphaned by a replica that died mid-flight stays 'pending'

View File

@@ -32,6 +32,7 @@ type ManagementOp[T any, E any] struct {
// Context for cancellation support
Context context.Context
CancelFunc context.CancelFunc
PauseFunc context.CancelFunc
// External backend installation parameters (for OCI/URL/path)
// These are used when installing backends from external sources rather than galleries
@@ -189,6 +190,7 @@ type GalleryProgressEvent struct {
// runs the cancel func on whichever replica registered it.
type GalleryCancelEvent struct {
JobID string `json:"id"`
Pause bool `json:"pause,omitempty"`
}
// NodeStatus values shared between NodeProgress (per-node tick) and the

View File

@@ -28,7 +28,7 @@ type GalleryService struct {
modelManager ModelManager
backendManager BackendManager
statuses map[string]*OpStatus
cancellations map[string]context.CancelFunc
cancellations map[string]cancellationActions
// Distributed mode (nil when not in distributed mode).
// natsClient is the wider MessagingClient (Publisher + subscribe methods)
@@ -67,7 +67,7 @@ func NewGalleryService(appConfig *config.ApplicationConfig, ml *model.ModelLoade
modelManager: NewLocalModelManager(appConfig, ml),
backendManager: NewLocalBackendManager(appConfig, ml),
statuses: make(map[string]*OpStatus),
cancellations: make(map[string]context.CancelFunc),
cancellations: make(map[string]cancellationActions),
}
}
@@ -387,6 +387,17 @@ func (g *GalleryService) failStaleStatus(id string) {
// SubjectGalleryCancelWildcard subscriber and runs it locally. The caller
// gets a non-error reply so the UI shows the cancel as accepted.
func (g *GalleryService) CancelOperation(id string) error {
return g.stopOperation(id, false)
}
// PauseOperation stops an in-progress download while preserving its partial
// file. Re-submitting the same install resumes it through the downloader's
// existing HTTP Range support.
func (g *GalleryService) PauseOperation(id string) error {
return g.stopOperation(id, true)
}
func (g *GalleryService) stopOperation(id string, pause bool) error {
g.Lock()
if status, ok := g.statuses[id]; ok && status.Cancelled {
@@ -394,7 +405,7 @@ func (g *GalleryService) CancelOperation(id string) error {
return fmt.Errorf("operation %q is already cancelled", id)
}
cancelFunc, localExists := g.cancellations[id]
actions, localExists := g.cancellations[id]
if localExists {
delete(g.cancellations, id)
}
@@ -410,12 +421,12 @@ func (g *GalleryService) CancelOperation(id string) error {
if status, ok := g.statuses[id]; ok {
status.Cancelled = true
status.Processed = true
status.Message = "cancelled"
status.Message = map[bool]string{true: "paused", false: "cancelled"}[pause]
} else {
g.statuses[id] = &OpStatus{
Cancelled: true,
Processed: true,
Message: "cancelled",
Message: map[bool]string{true: "paused", false: "cancelled"}[pause],
Cancellable: false,
}
}
@@ -435,11 +446,15 @@ func (g *GalleryService) CancelOperation(id string) error {
// I/O and user-provided callback after Unlock — the cancel-wildcard
// subscriber loops back into applyCancel on this same replica, which
// would otherwise deadlock on g.Mutex.
if cancelFunc != nil {
cancelFunc()
stopFunc := actions.cancel
if pause {
stopFunc = actions.pause
}
if stopFunc != nil {
stopFunc()
}
if nc != nil {
if err := nc.Publish(messaging.SubjectGalleryCancel(id), GalleryCancelEvent{JobID: id}); err != nil {
if err := nc.Publish(messaging.SubjectGalleryCancel(id), GalleryCancelEvent{JobID: id, Pause: pause}); err != nil {
xlog.Warn("Failed to broadcast gallery cancel", "op_id", id, "error", err)
}
}
@@ -452,9 +467,9 @@ func (g *GalleryService) CancelOperation(id string) error {
// run the local cancel func if we have one (no echo via NATS), and reflect
// the cancellation in the local statuses map. Idempotent: a replica that
// already cancelled this op locally treats the inbound event as a no-op.
func (g *GalleryService) applyCancel(id string) {
func (g *GalleryService) applyCancel(id string, pause bool) {
g.Lock()
cancelFunc, hasCancel := g.cancellations[id]
actions, hasCancel := g.cancellations[id]
if hasCancel {
delete(g.cancellations, id)
}
@@ -465,12 +480,12 @@ func (g *GalleryService) applyCancel(id string) {
}
status.Cancelled = true
status.Processed = true
status.Message = "cancelled"
status.Message = map[bool]string{true: "paused", false: "cancelled"}[pause]
} else {
g.statuses[id] = &OpStatus{
Cancelled: true,
Processed: true,
Message: "cancelled",
Message: map[bool]string{true: "paused", false: "cancelled"}[pause],
Cancellable: false,
}
}
@@ -478,8 +493,12 @@ func (g *GalleryService) applyCancel(id string) {
// Invoke the cancel func after Unlock so a callback that touches
// GalleryService doesn't re-enter the mutex.
if hasCancel {
cancelFunc()
stopFunc := actions.cancel
if pause {
stopFunc = actions.pause
}
if hasCancel && stopFunc != nil {
stopFunc()
}
}
@@ -488,23 +507,40 @@ func (g *GalleryService) applyCancel(id string) {
// distinguish a deliberate user cancel (discard the half-downloaded .partial)
// from an incidental cancellation such as process shutdown (keep the .partial
// so the next run resumes via Range instead of restarting from zero).
func newUserCancellableContext(parent context.Context) (context.Context, context.CancelFunc) {
// NewUserCancellableContext creates distinct callbacks for destructive cancel
// and resume-safe pause while sharing one operation context.
func NewUserCancellableContext(parent context.Context) (context.Context, context.CancelFunc, context.CancelFunc) {
ctx, cancelCause := context.WithCancelCause(parent)
return ctx, func() { cancelCause(downloader.ErrUserCancelled) }
return ctx,
func() { cancelCause(downloader.ErrUserCancelled) },
func() { cancelCause(context.Canceled) }
}
// storeCancellation stores a cancellation function for an operation
func (g *GalleryService) storeCancellation(id string, cancelFunc context.CancelFunc) {
type cancellationActions struct {
cancel context.CancelFunc
pause context.CancelFunc
}
func (g *GalleryService) storeCancellation(id string, cancelFunc, pauseFunc context.CancelFunc) {
g.Lock()
defer g.Unlock()
g.cancellations[id] = cancelFunc
if pauseFunc == nil {
pauseFunc = cancelFunc
}
g.cancellations[id] = cancellationActions{cancel: cancelFunc, pause: pauseFunc}
}
// StoreCancellation is a public method to store a cancellation function for an operation
// This allows cancellation functions to be stored immediately when operations are created,
// enabling cancellation of queued operations that haven't started processing yet.
func (g *GalleryService) StoreCancellation(id string, cancelFunc context.CancelFunc) {
g.storeCancellation(id, cancelFunc)
g.storeCancellation(id, cancelFunc, cancelFunc)
}
// StoreCancellationActions registers distinct destructive-cancel and
// resume-safe pause callbacks for an operation.
func (g *GalleryService) StoreCancellationActions(id string, cancelFunc, pauseFunc context.CancelFunc) {
g.storeCancellation(id, cancelFunc, pauseFunc)
}
// removeCancellation removes a cancellation function when operation completes
@@ -554,10 +590,10 @@ func (g *GalleryService) Start(c context.Context, cl *config.ModelConfigLoader,
case op := <-g.BackendGalleryChannel:
// Create context if not provided
if op.Context == nil {
op.Context, op.CancelFunc = newUserCancellableContext(c)
g.storeCancellation(op.ID, op.CancelFunc)
op.Context, op.CancelFunc, op.PauseFunc = NewUserCancellableContext(c)
g.storeCancellation(op.ID, op.CancelFunc, op.PauseFunc)
} else if op.CancelFunc != nil {
g.storeCancellation(op.ID, op.CancelFunc)
g.storeCancellation(op.ID, op.CancelFunc, op.PauseFunc)
}
// Create DB record for distributed tracking
if g.galleryStore != nil {
@@ -597,10 +633,10 @@ func (g *GalleryService) Start(c context.Context, cl *config.ModelConfigLoader,
case op := <-g.ModelGalleryChannel:
// Create context if not provided
if op.Context == nil {
op.Context, op.CancelFunc = newUserCancellableContext(c)
g.storeCancellation(op.ID, op.CancelFunc)
op.Context, op.CancelFunc, op.PauseFunc = NewUserCancellableContext(c)
g.storeCancellation(op.ID, op.CancelFunc, op.PauseFunc)
} else if op.CancelFunc != nil {
g.storeCancellation(op.ID, op.CancelFunc)
g.storeCancellation(op.ID, op.CancelFunc, op.PauseFunc)
}
// Create DB record for distributed tracking
if g.galleryStore != nil {
@@ -663,7 +699,7 @@ func (g *GalleryService) SubscribeBroadcasts() error {
if evt.JobID == "" {
return
}
g.applyCancel(evt.JobID)
g.applyCancel(evt.JobID, evt.Pause)
})
if err != nil {
if uerr := progressSub.Unsubscribe(); uerr != nil {

View File

@@ -154,6 +154,9 @@ func (c *fakeBackendClient) Predict(_ context.Context, _ *pb.PredictOptions, _ .
func (c *fakeBackendClient) GenerateImage(_ context.Context, _ *pb.GenerateImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return nil, nil
}
func (c *fakeBackendClient) UpscaleImage(_ context.Context, _ *pb.UpscaleImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return nil, nil
}
func (c *fakeBackendClient) GenerateVideo(_ context.Context, _ *pb.GenerateVideoRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return nil, nil
}

View File

@@ -138,6 +138,12 @@ func (c *InFlightTrackingClient) GenerateImage(ctx context.Context, in *pb.Gener
return res, c.reconcile(err)
}
func (c *InFlightTrackingClient) UpscaleImage(ctx context.Context, in *pb.UpscaleImageRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
defer c.track(ctx)()
res, err := c.inner.UpscaleImage(ctx, in, opts...)
return res, c.reconcile(err)
}
func (c *InFlightTrackingClient) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
defer c.track(ctx)()
res, err := c.inner.GenerateVideo(ctx, in, opts...)

View File

@@ -83,6 +83,10 @@ func (f *fakeGRPCBackend) GenerateImage(_ context.Context, _ *pb.GenerateImageRe
return &pb.Result{}, nil
}
func (f *fakeGRPCBackend) UpscaleImage(_ context.Context, _ *pb.UpscaleImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return &pb.Result{}, nil
}
func (f *fakeGRPCBackend) GenerateVideo(_ context.Context, _ *pb.GenerateVideoRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return &pb.Result{}, nil
}

View File

@@ -157,7 +157,7 @@ When authentication is enabled, the following endpoints require admin role:
**Model & Backend Management:**
- `GET /api/models`, `POST /api/models/install/*`, `POST /api/models/delete/*`
- `GET /api/backends`, `POST /api/backends/install/*`, `POST /api/backends/delete/*`
- `GET /api/operations`, `POST /api/operations/*/cancel`, `POST /api/operations/*/dismiss`
- `GET /api/operations`, `POST /api/operations/*/cancel`, `POST /api/operations/*/pause`, `POST /api/operations/*/dismiss`
- `GET /api/operations/history`, `DELETE /api/operations/history`
- `GET /models/available`, `GET /models/galleries`, `GET /models/jobs/*`
- `GET /backends`, `GET /backends/available`, `GET /backends/galleries`

View File

@@ -69,4 +69,5 @@ For more complex grammars, you can define multi-line BNF rules. The grammar pars
## Related Features
- [OpenAI Functions]({{%relref "features/openai-functions" %}}) - Function calling with structured outputs
- [Text Generation]({{%relref "features/text-generation" %}}) - General text generation capabilities
- [Text Generation]({{%relref "features/text-generation" %}}) - General text generation capabilities
- [Moderation]({{%relref "features/moderation" %}}) - OpenAI-compatible safety classification whose response is constrained to the moderation schema

View File

@@ -164,6 +164,30 @@ By default the RPC devices join the pool and participate in placement; combine w
![anime_girl](https://github.com/go-skynet/LocalAI/assets/2420543/8aaca62a-e864-4011-98ae-dcc708103928)
(Generated with [AnimagineXL](https://huggingface.co/Linaqruf/animagine-xl))
#### Image upscaling
LocalAI can upscale an uploaded image by a factor of 2 or 4 through
`POST /v1/images/upscale`. Install the included Stable Diffusion x4 upscaler
gallery model first:
```bash
local-ai models install stable-diffusion-x4-upscaler
```
Then send the model name, scale, and image as multipart form fields:
```bash
curl http://localhost:8080/v1/images/upscale \
-F model=stable-diffusion-x4-upscaler \
-F scale=4 \
-F image=@input.png
```
The response uses the same format as image generation and returns the generated
image under `/generated-images`. The `diffusers` backend uses a loaded
`StableDiffusionUpscalePipeline` or `StableDiffusionLatentUpscalePipeline` when
configured. Other diffusers pipelines fall back to Lanczos resizing.
#### Model setup
The models will be downloaded the first time you use the backend from `huggingface` automatically.

View File

@@ -0,0 +1,37 @@
+++
disableToc = false
title = "Moderation"
weight = 65
url = "/features/moderation/"
+++
LocalAI exposes an OpenAI-compatible text moderation endpoint at
`POST /v1/moderations`. It uses a local text-generation model with a constrained
JSON grammar, so no separate moderation service or cloud API is required.
```bash
curl http://localhost:8080/v1/moderations \
-H "Content-Type: application/json" \
-d '{
"model": "your-instruct-model",
"input": "Text to classify"
}'
```
`input` may be one string or an array of strings. The response contains one
result per input with `flagged`, `categories`, `category_scores`, and
`category_applied_input_types` fields. The category names match the OpenAI
moderation API, including harassment, hate, illicit activity, self-harm,
sexual content, and violence categories.
The selected model must support text completion. For consistent results, use
an instruction-tuned model that follows safety-classification prompts well.
LocalAI constrains the output shape, but the model determines the classification
quality and confidence scores.
{{% notice note %}}
This first implementation supports text only. OpenAI-style multimodal input
objects containing images return a validation error.
{{% /notice %}}

View File

@@ -918,6 +918,200 @@ options:
The full list of registered parsers lives in `sglang.srt.function_call`
and `sglang.srt.parser.reasoning_parser`.
### vllm.cpp
[vllm.cpp](https://github.com/mudler/vllm.cpp) is the LocalAI team's C++ port of
vLLM: the same continuous-batching scheduler, paged KV cache and prefix caching,
with no Python at inference time. It consumes either a HuggingFace safetensors
model directory or a `.gguf` file, and applies the model's chat template,
tool-call parsing and reasoning split engine-side.
#### Setup
```yaml
name: vllm-cpp
backend: vllm-cpp
parameters:
model: "Qwen/Qwen3-4B"
context_size: 8192
template:
use_tokenizer_template: true
```
#### Configuring the engine with `engine_args`
The same `engine_args:` map the vLLM and SGLang backends accept is honoured
here, with keys spelled exactly as vLLM's own CLI flags - so a `speculative_config`
or `kv_transfer_config` block written for vLLM works verbatim. Unknown keys are
ignored rather than fatal; the engine validates the documents it is handed and
reports a precise error at load.
```yaml
name: qwen35-a3b
backend: vllm-cpp
parameters:
model: "Qwen/Qwen3.5-A3B"
context_size: 16384
template:
use_tokenizer_template: true
engine_args:
# KV cache sizing: num_blocks * block_size tokens of cache.
block_size: 32
num_blocks: 1024
# Concurrency and the per-step chunked-prefill token budget.
max_num_seqs: 32
max_num_batched_tokens: 8192
# Automatic prefix caching. Omit to keep the model's own default
# (on for dense models, off for hybrid / attention-free ones).
enable_prefix_caching: true
# Scheduler admission order: fcfs (default), priority, or lpm
# (cache-aware longest-prefix-match; needs prefix caching to have any effect).
scheduling_policy: lpm
```
| Key | Meaning | Default |
|-----|---------|---------|
| `block_size` | KV-cache block size, in tokens per block | 32 |
| `num_blocks` | KV-cache blocks to allocate | 256 |
| `max_model_len` | Max sequence length; also settable as `context_size` / `max_model_len` | model config |
| `max_num_seqs` | Max concurrent sequences the scheduler admits | 8 |
| `max_num_batched_tokens` | Per-step chunked-prefill token budget | per-arch (2048 dense, 4096/8192 MoE) |
| `enable_prefix_caching` | Automatic prefix caching; `enable_radix_attention` is an accepted alias | model default |
| `enable_jump_forward` | Jump-forward decoding, which emits grammar-forced tokens without a model step. Only affects constrained requests (`grammar`, JSON schema) | off |
| `scheduling_policy` | `fcfs`, `priority`, or `lpm` | `fcfs` |
| `tool_parser` / `reasoning_parser` | Force a parser instead of chat-template auto-detection | auto |
| `tokenizer_config` | Override the `tokenizer_config.json` the chat template is read from | `<model_dir>/tokenizer_config.json` |
| `speculative_config` | Speculative decoding (see below) | disabled |
| `kv_transfer_config` | External KV connector / LMCache (see below) | none |
Raising `max_num_batched_tokens` lets more prefill land in a single step, at the
cost of decode latency for requests queued behind it. The default deliberately
does not scale with `max_num_seqs`, which is what keeps a large concurrent
prefill from blowing up the per-step activation on the hybrid architectures.
`enable_prefix_caching` and `enable_jump_forward` are tri-state at the engine
boundary: omitting the key defers to a default (the model's own capability for
prefix caching, an environment variable for jump forward), while an explicit
`false` forces the feature off. Those are genuinely different - prefix caching
defaults *on* for dense models - so write the key only when you mean to override.
#### Speculative decoding
`speculative_config:` takes the same JSON object as vLLM's
`--speculative-config`. Three methods are supported.
> **Architecture limit.** At the current engine pin, `mtp` and `dflash` are
> **Qwen3.5 / Qwen3.6 only**. The engine builds a widened speculative KV cache
> directly for those families rather than through the model registry, so a
> speculative config on any other architecture (Llama, GLM, Gemma, Mistral, ...)
> will not work regardless of checkpoint format. `ngram` needs no draft weights
> and is not subject to this limit.
> **Format support.** `mtp` and `dflash` now work from a `.gguf` target as well
> as safetensors. An MTP head is read from the GGUF's `nextn.*` tensors when the
> file declares `<arch>.nextn_predict_layers`; a GGUF exported WITHOUT the head
> (converted with `--no-mtp`, or predating llama.cpp's Qwen3.5 MTP support) is
> refused at load naming that as the reason. A DFlash draft may itself be a
> `dflash`-arch GGUF, and the target may be a GGUF too. `ngram` needs no draft
> weights and works on any format.
**MTP** (Multi-Token Prediction) uses a draft head shipped inside the target
checkpoint's own `mtp.*` tensors, so there is no second model to download. It
requires a **safetensors** checkpoint - the `mtp.*` tensors do not survive GGUF
conversion, and an MTP config over a `.gguf` model is rejected at load.
```yaml
engine_args:
speculative_config:
method: mtp
# Optional; defaults to the checkpoint's own head depth, which is
# usually the right value. Must be a multiple of that depth.
num_speculative_tokens: 1
```
**DFlash** uses a separate block-diffusion drafter that proposes a whole block
of tokens in one non-autoregressive forward pass. Unlike MTP, the draft is its
own checkpoint, so `model:` is **required**:
```yaml
engine_args:
speculative_config:
method: dflash
model: z-lab/Qwen3.6-27B-DFlash
num_speculative_tokens: 4
```
The draft shares the *target's* `embed_tokens` and `lm_head`, so both must come
from the same model family and the target must be safetensors.
**The engine does not download the draft.** `model:` is resolved, in order,
as a path as given, then as the last path segment under LocalAI's models
directory (`z-lab/Qwen3.6-27B-DFlash``<models>/Qwen3.6-27B-DFlash`, which is
what LocalAI's own downloader produces), then as the whole reference under the
models directory. Install the draft into LocalAI first, or give an absolute path
to a directory containing `config.json`. If none of those resolve, the load
fails immediately naming every location that was tried, rather than reporting a
missing checkpoint from inside the engine.
**N-gram** needs no draft model at all - it proposes from the prompt's own
suffix history. `num_speculative_tokens` is required:
```yaml
engine_args:
speculative_config:
method: ngram
num_speculative_tokens: 4
prompt_lookup_min: 5
prompt_lookup_max: 5
```
> **Auto-configuration on import.** When you import a safetensors repository
> with `backend: vllm-cpp`, LocalAI reads the checkpoint's `config.json` and, if
> it declares an MTP head (`mtp_num_hidden_layers`), writes
> `speculative_config: {method: mtp}` into the generated `engine_args` for you.
> An explicit `speculative_config` in your own config is never overwritten.
> Importing a DFlash *draft* repository is refused with a warning: a drafter
> cannot serve on its own, so import the target model and point
> `speculative_config.model` at the draft.
#### External KV cache with LMCache
`kv_transfer_config:` takes vLLM's `--kv-transfer-config` JSON and selects an
external KV-cache connector. The `lm://` LMCache client lets prefill KV be
stored to and reloaded from a shared `lmcache.v1.server`, so a prefix computed
by one replica does not have to be recomputed by the next:
```yaml
engine_args:
kv_transfer_config:
kv_connector: LMCacheConnector
kv_role: kv_both # required whenever kv_connector is set
kv_connector_extra_config:
host: 127.0.0.1
port: 65432
```
`kv_role` is one of `kv_producer` (store only), `kv_consumer` (load only), or
`kv_both`. An unregistered connector name, a missing role, or a malformed
document fails the load with an explicit error rather than silently running
without the cache.
#### Legacy `options:` list
Earlier versions configured this backend through the flat `options:` list, and
those configs keep working. Every key in the table above is still read from
there in `key:value` form, and `engine_args` wins on any key set in both:
```yaml
options:
- max_num_seqs:32
- enable_prefix_caching:true
```
New configs should prefer `engine_args:`, which is the only place the nested
`speculative_config` / `kv_transfer_config` documents can be written naturally
rather than as a single-line JSON string.
### Transformers
[Transformers](https://huggingface.co/docs/transformers/index) is a State-of-the-art Machine Learning library for PyTorch, TensorFlow, and JAX.

View File

@@ -170,10 +170,15 @@ Both surfaces are backed by these endpoints. All of them are admin-only when
| -------- | -------------------------------- | ------------------------------------------------------------------------ |
| `GET` | `/api/operations` | Running, queued and failed operations, least advanced first. |
| `POST` | `/api/operations/{jobID}/cancel` | Cancel a queued operation, or a running install. Not a running removal. |
| `POST` | `/api/operations/{jobID}/pause` | Pause a queued operation or running download and preserve partial data. |
| `POST` | `/api/operations/{jobID}/dismiss`| Acknowledge a failed operation and move it into the record. |
| `GET` | `/api/operations/history` | List finished operations, newest first. |
| `DELETE` | `/api/operations/history` | Clear the record. Live operations are untouched. |
Pausing preserves the download's `.partial` file. Start the same model or
backend installation again to resume from the saved bytes when the origin
supports HTTP Range requests. Cancelling intentionally discards partial data.
Both `GET` endpoints wrap their list in an `operations` key rather than
returning a bare array:

View File

@@ -6,26 +6,11 @@ url = '/basics/news/'
icon = "newspaper"
+++
Release notes have been now moved completely over Github releases.
LocalAI news is published in two places, both kept current:
You can see the release notes [here](https://github.com/mudler/LocalAI/releases).
- **[Blog](https://localai.io/blog/)** for release write-ups, benchmark reports and engineering notes.
- **[GitHub Releases](https://github.com/mudler/LocalAI/releases)** for the full changelog of every version.
## 2026 Highlights
For how the project got here, read [LocalAI, from March 2023 to now](https://localai.io/blog/localai-since-march-2023/).
- **July 2026**: [LongCat video and avatar generation](/features/video-generation/) - dedicated CUDA backend for `LongCat-Video` text/image-to-video and `LongCat-Video-Avatar-1.5` speech-driven avatars. Includes multi-segment continuation, portrait and recorded-audio inputs in Studio, and an SDPA CUDA 13 ARM64 build for DGX Spark.
- **April 2026**: [Audio Transform](/features/audio-transform/) - generic audio-in / audio-out endpoint with optional reference signal. First implementation: [LocalVQE](https://github.com/localai-org/LocalVQE) C++ backend (joint AEC + noise suppression + dereverberation, DeepVQE-style). Both batch (`POST /audio/transformations`) and bidirectional WebSocket streaming (`/audio/transformations/stream`). Studio "Transform" tab with synchronized waveform players for input / reference / output.
- **April 2026**: [Face recognition backend](/features/face-recognition/) - `insightface`-powered 1:1 verification, 1:N identification, face embedding, face detection, and demographic analysis. Ships both a non-commercial `buffalo_l` model and an Apache 2.0 OpenCV Zoo alternative.
- **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/localai-org/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 attachments using inferred or explicitly declared model modalities instead of backend-name checks.
- **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
- **April 2024**: [Reranker API](https://github.com/mudler/LocalAI/pull/2121)
- **May 2024**: [Distributed inferencing](https://github.com/mudler/LocalAI/pull/2324), [Decentralized P2P llama.cpp](https://github.com/mudler/LocalAI/pull/2343) - [Docs](https://localai.io/features/distribute/)
- **July/August 2024**: [P2P Dashboard, Federated mode and AI Swarms](https://github.com/mudler/LocalAI/pull/2723), [P2P Global community pools](https://github.com/mudler/LocalAI/issues/3113), FLUX-1 support, [P2P Explorer](https://explorer.localai.io)
- **October 2024**: Examples moved to [LocalAI-examples](https://github.com/mudler/LocalAI-examples)
- **November 2024**: [Voice Activity Detection (VAD)](https://github.com/mudler/LocalAI/pull/4204), [Bark.cpp backend](https://github.com/mudler/LocalAI/pull/4287)
- **December 2024**: [stablediffusion.cpp backend (ggml)](https://github.com/mudler/LocalAI/pull/4289)
This page used to carry a hand-maintained highlights list. It drifted against both sources above, so it now points at them instead.

View File

@@ -1,4 +1,59 @@
---
- &nemotron-3-embed-1b
name: "nemotron-3-embed-1b-q4"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
variants:
- model: nemotron-3-embed-8b-q4
license: openmdw-1.1
urls:
- https://huggingface.co/nvidia/Nemotron-3-Embed-1B-BF16
- https://huggingface.co/zenmagnets/Nemotron-3-Embed-1B-Q4_K_M-GGUF
description: |
Nemotron-3-Embed-1B is NVIDIA's multilingual text embedding model for
retrieval, semantic search, and RAG. This compact Q4_K_M GGUF produces
2,048-dimensional normalized embeddings and supports 36 languages. Prefix
retrieval queries with `query: ` and documents with `passage: `.
tags:
- embeddings
- multilingual
- retrieval
- rag
- gguf
- cpu
- gpu
overrides:
backend: llama-cpp
embeddings: true
known_usecases:
- embeddings
parameters:
model: llama-cpp/models/nemotron-3-embed-1b-q4_k_m.gguf
files:
- filename: llama-cpp/models/nemotron-3-embed-1b-q4_k_m.gguf
uri: huggingface://zenmagnets/Nemotron-3-Embed-1B-Q4_K_M-GGUF/nemotron-3-embed-1b-q4_k_m.gguf
sha256: 9a74166f51dbc280073748fa199bea49283bd21f7f9280f2dec2b4d975ddfd1d
- !!merge <<: *nemotron-3-embed-1b
name: "nemotron-3-embed-8b-q4"
variants: []
urls:
- https://huggingface.co/nvidia/Nemotron-3-Embed-8B-BF16
- https://huggingface.co/Abiray/Nemotron-3-Embed-8B-GGUF
description: |
Nemotron-3-Embed-8B is NVIDIA's larger multilingual text embedding model
for retrieval, semantic search, and RAG. This Q4_K_M GGUF balances retrieval
quality with local resource use and supports 36 languages. Prefix retrieval
queries with `query: ` and documents with `passage: `.
overrides:
backend: llama-cpp
embeddings: true
known_usecases:
- embeddings
parameters:
model: llama-cpp/models/Nemotron-3-Embed-8B-Q4_K_M.gguf
files:
- filename: llama-cpp/models/Nemotron-3-Embed-8B-Q4_K_M.gguf
uri: huggingface://Abiray/Nemotron-3-Embed-8B-GGUF/Nemotron-3-Embed-8B-Q4_K_M.gguf
sha256: a2aa29c618da6eed10d9474e72e33188c61e5fd700aed2fe9a1d98abdc90c6fc
- &grug-27b
name: "grug-27b"
variants:
@@ -111,7 +166,8 @@
- name: "deepseek-v4-flash-0731"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/unsloth/DeepSeek-V4-Flash-0731-GGUF
- https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-0731
- https://huggingface.co/ggml-org/DeepSeek-V4-Flash-0731-GGUF
description: "# DeepSeek-V4-Flash-0731\n\nTechnical Report\U0001F441\n\n## Introduction\n\n**DeepSeek-V4-Flash-0731** is the official release of **DeepSeek-V4-Flash**, superseding the preview version, with substantially enhanced agentic capabilities. It has the same model structure as DeepSeek-V4-Flash-DSpark, i.e. it comes with a speculative decoding module attached.\n\nDeepSeek-V4-Flash-0731 outperforms DeepSeek-V4-Pro (Preview) on benchmarks listed below despite its far smaller activated parameter count, and is broadly competitive with the strongest proprietary models available.\n\nNotes:\n\n1. For the Code Agent tasks among the public benchmarks above, DeepSeek-V4-Flash-0731 is evaluated with the minimal mode of DeepSeek Harness (to be released) as the agent framework, using the `max` reasoning effort level with `temperature = 1.0, top_p = 0.95`.\n2. † DSBench-FullStack is an internal full-stack development test set; DSBench-Hard is an internal test set of difficult coding-agent problems.\n\n## Chat Template\n\n...\n"
license: "mit"
tags:
@@ -120,20 +176,20 @@
- deepseek
icon: https://github.com/deepseek-ai/DeepSeek-V2/blob/main/figures/logo.svg
overrides:
backend: ds4
backend: llama-cpp
function:
grammar:
disable: true
known_usecases:
- chat
parameters:
model: ds4flash.gguf
model: DeepSeek-V4-Flash-0731-MXFP4.gguf
template:
use_tokenizer_template: true
files:
- filename: ds4flash.gguf
uri: https://huggingface.co/unsloth/DeepSeek-V4-Flash-0731-GGUF
sha256: ef1940d5a91c294393c60b4083b497a05f6df1174e9a12225843945b228c5d20
- filename: DeepSeek-V4-Flash-0731-MXFP4.gguf
uri: huggingface://ggml-org/DeepSeek-V4-Flash-0731-GGUF/DeepSeek-V4-Flash-0731-MXFP4.gguf
sha256: 65f73494afaf27d3add0751a5b716dd2d3e012c66ae0dbbcc1bf8477f92b3ab7
- name: instella-moe-16b-a3b-think
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:
@@ -255,7 +311,7 @@
files:
- filename: llama-cpp/models/Parable-Granite-4.1-3B-Claude-Fable-5-Q4_K_M/Parable-Granite-4.1-3B-Claude-Fable-5-GGUF-Q4_K_M.gguf
uri: https://huggingface.co/AnkitAI/Parable-Granite-4.1-3B-Claude-Fable-5-GGUF/resolve/main/Parable-Granite-4.1-3B-Claude-Fable-5-GGUF-Q4_K_M.gguf
sha256: 67dc7695d92939c713165761f115c9d892fdff74fcbd987c8bb453b9b8ab645d
sha256: dbf202638af23e72508d8316577655d24ba2037fda51ce802b8996977e290bce
- name: "parable-qwen3-4b-claude-fable-5"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -289,7 +345,7 @@
files:
- filename: llama-cpp/models/Parable-Qwen3-4B-Claude-Fable-5-Q4_K_M/Parable-Qwen3-4B-Claude-Fable-5-GGUF-Q4_K_M.gguf
uri: https://huggingface.co/AnkitAI/Parable-Qwen3-4B-Claude-Fable-5-GGUF/resolve/main/Parable-Qwen3-4B-Claude-Fable-5-GGUF-Q4_K_M.gguf
sha256: c94b06a912aa901f3da5689754577ad534415efafc50dcee3f389594a153bf38
sha256: 65cc4824fb78ecaf55afdfcdb6dd2e27e1aa805d289db89eae94d32d450403f0
- name: "parable-granite-4.1-8b-claude-fable-5"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -325,7 +381,7 @@
files:
- filename: llama-cpp/models/Parable-Granite-4.1-8B-Claude-Fable-5-Q4_K_M/Parable-Granite-4.1-8B-Claude-Fable-5-GGUF-Q4_K_M.gguf
uri: https://huggingface.co/AnkitAI/Parable-Granite-4.1-8B-Claude-Fable-5-GGUF/resolve/main/Parable-Granite-4.1-8B-Claude-Fable-5-GGUF-Q4_K_M.gguf
sha256: 61a8133c344a0d0a00188395afe33c803e3b973cb4bbfd5ef1fa7110e80bc1c3
sha256: 57e464ae3d35253d4351639757dc35e71bab8324d12d49a5870695ce73dc19cf
- name: "parable-qwen3-8b-claude-fable-5"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -359,7 +415,7 @@
files:
- filename: llama-cpp/models/Parable-Qwen3-8B-Claude-Fable-5-Q4_K_M/Parable-Qwen3-8B-Claude-Fable-5-GGUF-Q4_K_M.gguf
uri: https://huggingface.co/AnkitAI/Parable-Qwen3-8B-Claude-Fable-5-GGUF/resolve/main/Parable-Qwen3-8B-Claude-Fable-5-GGUF-Q4_K_M.gguf
sha256: 956070afc8023b8665fe450842f7be76b505b53d142460fd9b588222f4e16112
sha256: 4532d2379d38a37279866a030e51d419561f9d4d22fee00d2a33647d66f05065
- &pocket-35b
name: "pocket-35b"
variants:
@@ -1331,6 +1387,85 @@
- filename: llama-cpp/mmproj/Qwythos-9B-v2-MTP-Q4_K_M/mmproj-Qwythos-9B-v2-BF16.gguf
sha256: 0d1687cb33124c78acab788b342d4a2eaf85b3035e87c3abe4ee9d0b84ddb4f5
uri: https://huggingface.co/empero-ai/Qwythos-9B-v2-GGUF/resolve/main/mmproj-Qwythos-9B-v2-BF16.gguf
- &qwen3-6-14b-a3b-fablevibes
name: "qwen3.6-14b-a3b-fablevibes"
variants:
- model: qwen3.6-14b-a3b-fablevibes-q8
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/tvall43/Qwen3.6-14B-A3B-FableVibes
- https://huggingface.co/tvall43/Qwen3.6-14B-A3B-FableVibes-GGUF
description: |
Qwen3.6-14B-A3B-FableVibes is an Apache-2.0 mixture-of-experts reasoning
model distilled from Fable 5 and Claude Opus traces, with additional tool
calling and coding data. It retains Qwen 3.6 vision support while pruning
the 35B-A3B base to a 14B consumer-oriented footprint. This default entry
uses the recommended Q4_K_M GGUF quantization and its Q8_0 multimodal
projector.
license: "apache-2.0"
tags:
- llm
- gguf
- cpu
- gpu
- moe
- reasoning
- thinking
- vision
- multimodal
last_checked: "2026-08-03"
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
mmproj: llama-cpp/mmproj/Qwen3.6-14B-A3B-FableVibes/Qwen3.6-14B-A3B-FableVibes-mmproj-Q8_0.gguf
options:
- use_jinja:true
parameters:
model: llama-cpp/models/Qwen3.6-14B-A3B-FableVibes/Qwen3.6-14B-A3B-FableVibes-Q4_K_M.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/Qwen3.6-14B-A3B-FableVibes/Qwen3.6-14B-A3B-FableVibes-Q4_K_M.gguf
sha256: 21aa4b0b28090469e8a319c889451df2f1ea6aad27ac3818c8c8a86f86d5bc9e
uri: huggingface://tvall43/Qwen3.6-14B-A3B-FableVibes-GGUF/Qwen3.6-14B-A3B-FableVibes-Q4_K_M.gguf
- filename: llama-cpp/mmproj/Qwen3.6-14B-A3B-FableVibes/Qwen3.6-14B-A3B-FableVibes-mmproj-Q8_0.gguf
sha256: ca27dbf0c65a7232e9458bfdda8bc45efc09ab60e4cc6f58ea0c7b7cc2253257
uri: huggingface://tvall43/Qwen3.6-14B-A3B-FableVibes-GGUF/Qwen3.6-14B-A3B-FableVibes-mmproj-Q8_0.gguf
- !!merge <<: *qwen3-6-14b-a3b-fablevibes
name: "qwen3.6-14b-a3b-fablevibes-q8"
variants: []
description: |
Qwen3.6-14B-A3B-FableVibes is an Apache-2.0 mixture-of-experts reasoning
model distilled from Fable 5 and Claude Opus traces, with additional tool
calling and coding data. This entry uses the near-lossless Q8_0 GGUF
quantization and its matching Q8_0 multimodal projector.
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
mmproj: llama-cpp/mmproj/Qwen3.6-14B-A3B-FableVibes-Q8_0/Qwen3.6-14B-A3B-FableVibes-mmproj-Q8_0.gguf
options:
- use_jinja:true
parameters:
model: llama-cpp/models/Qwen3.6-14B-A3B-FableVibes-Q8_0/Qwen3.6-14B-A3B-FableVibes-Q8_0.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/Qwen3.6-14B-A3B-FableVibes-Q8_0/Qwen3.6-14B-A3B-FableVibes-Q8_0.gguf
sha256: ddea86093b863215fa75969d81df494fafdb0e6c4a65af557ed3a710e2238e58
uri: huggingface://tvall43/Qwen3.6-14B-A3B-FableVibes-GGUF/Qwen3.6-14B-A3B-FableVibes-Q8_0.gguf
- filename: llama-cpp/mmproj/Qwen3.6-14B-A3B-FableVibes-Q8_0/Qwen3.6-14B-A3B-FableVibes-mmproj-Q8_0.gguf
sha256: ca27dbf0c65a7232e9458bfdda8bc45efc09ab60e4cc6f58ea0c7b7cc2253257
uri: huggingface://tvall43/Qwen3.6-14B-A3B-FableVibes-GGUF/Qwen3.6-14B-A3B-FableVibes-mmproj-Q8_0.gguf
- name: "qwen3.6-27b-fable-fusion-711-uncensored-heretic-nm-dau-neo-max-mtp"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -1874,7 +2009,7 @@
files:
- filename: ds4flash.gguf
uri: https://huggingface.co/unsloth/DeepSeek-V4-Flash-GGUF
sha256: 856c407993ccffa9ad52e23fbef8bb7b458c792a52278f4ca7931741b0c20ce2
sha256: 1bfdafd1c288eb1b2bcb629ee9e1b7567dcf0abbe4d20995905a3c3465e9bd1e
- name: "qwopus3.6-35b-a3b-coder-mtp"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
@@ -8959,6 +9094,34 @@
- filename: llama-cpp/models/deepseek-ai.DeepSeek-V3.2.Q4_K_M-00029-of-00029.gguf
sha256: 013af4e9d2f84e484f77c7bae2a02652607f0f0179bd2815ffdf401c3ada5184
uri: https://huggingface.co/DevQuasar/deepseek-ai.DeepSeek-V3.2-GGUF/resolve/main/deepseek-ai.DeepSeek-V3.2.Q4_K_M-00029-of-00029.gguf
- name: stable-diffusion-x4-upscaler
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:
- https://huggingface.co/stabilityai/stable-diffusion-x4-upscaler
description: |
Stable Diffusion x4 Upscaler is Stability AI's diffusion-based super-resolution model. It enlarges low-resolution images by four times while reconstructing image detail.
license: openrail++
tags:
- image-upscaling
- super-resolution
- image-to-image
- diffusers
last_checked: "2026-07-30"
overrides:
backend: diffusers
f16: true
diffusers:
pipeline_type: StableDiffusionUpscalePipeline
known_usecases:
- image
parameters:
model: stabilityai/stable-diffusion-x4-upscaler
artifacts:
- name: model
target: model
source:
type: huggingface
repo: stabilityai/stable-diffusion-x4-upscaler
- name: z-image-diffusers
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:

View File

@@ -72,6 +72,7 @@ type InferenceBackend interface {
PredictStream(ctx context.Context, in *pb.PredictOptions, f func(reply *pb.Reply), opts ...grpc.CallOption) error
Predict(ctx context.Context, in *pb.PredictOptions, opts ...grpc.CallOption) (*pb.Reply, error)
GenerateImage(ctx context.Context, in *pb.GenerateImageRequest, opts ...grpc.CallOption) (*pb.Result, error)
UpscaleImage(ctx context.Context, in *pb.UpscaleImageRequest, opts ...grpc.CallOption) (*pb.Result, error)
GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest, opts ...grpc.CallOption) (*pb.Result, error)
Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...grpc.CallOption) (*pb.Result, error)
TTS(ctx context.Context, in *pb.TTSRequest, opts ...grpc.CallOption) (*pb.Result, error)

View File

@@ -63,6 +63,10 @@ func (llm *Base) Generate3D(*pb.Generate3DRequest) error {
return fmt.Errorf("unimplemented")
}
func (llm *Base) UpscaleImage(*pb.UpscaleImageRequest) error {
return fmt.Errorf("unimplemented")
}
func (llm *Base) AudioTranscription(context.Context, *pb.TranscriptRequest) (pb.TranscriptResult, error) {
return pb.TranscriptResult{}, fmt.Errorf("unimplemented")
}

View File

@@ -230,6 +230,23 @@ func (c *Client) GenerateImage(ctx context.Context, in *pb.GenerateImageRequest,
return client.GenerateImage(ctx, in, opts...)
}
func (c *Client) UpscaleImage(ctx context.Context, in *pb.UpscaleImageRequest, opts ...grpc.CallOption) (*pb.Result, error) {
if !c.parallel {
c.opMutex.Lock()
defer c.opMutex.Unlock()
}
c.setBusy(true)
defer c.setBusy(false)
defer c.wdMark()()
conn, err := c.dial()
if err != nil {
return nil, err
}
defer conn.Close()
client := pb.NewBackendClient(conn)
return client.UpscaleImage(ctx, in, opts...)
}
func (c *Client) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest, opts ...grpc.CallOption) (*pb.Result, error) {
if !c.parallel {
c.opMutex.Lock()

View File

@@ -49,6 +49,10 @@ func (e *embedBackend) GenerateImage(ctx context.Context, in *pb.GenerateImageRe
return e.s.GenerateImage(ctx, in)
}
func (e *embedBackend) UpscaleImage(ctx context.Context, in *pb.UpscaleImageRequest, opts ...grpc.CallOption) (*pb.Result, error) {
return e.s.UpscaleImage(ctx, in)
}
func (e *embedBackend) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest, opts ...grpc.CallOption) (*pb.Result, error) {
return e.s.GenerateVideo(ctx, in)
}

View File

@@ -17,6 +17,7 @@ type AIModel interface {
Free() error
Embeddings(*pb.PredictOptions) ([]float32, error)
GenerateImage(*pb.GenerateImageRequest) error
UpscaleImage(*pb.UpscaleImageRequest) error
GenerateVideo(*pb.GenerateVideoRequest) error
Generate3D(*pb.Generate3DRequest) error
Detect(*pb.DetectOptions) (pb.DetectResponse, error)

View File

@@ -150,6 +150,18 @@ func (s *server) GenerateImage(ctx context.Context, in *pb.GenerateImageRequest)
return &pb.Result{Message: "Image generated", Success: true}, nil
}
func (s *server) UpscaleImage(ctx context.Context, in *pb.UpscaleImageRequest) (*pb.Result, error) {
if s.llm.Locking() {
s.llm.Lock()
defer s.llm.Unlock()
}
err := s.llm.UpscaleImage(in)
if err != nil {
return &pb.Result{Message: fmt.Sprintf("Error upscaling image: %s", err.Error()), Success: false}, err
}
return &pb.Result{Message: "Image upscaled", Success: true}, nil
}
func (s *server) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest) (*pb.Result, error) {
if err := s.checkModelIdentity(in); err != nil {
return nil, err

View File

@@ -64,6 +64,10 @@ type LocalAIClient interface {
// ---- System ----
SystemInfo(ctx context.Context) (*SystemInfo, error)
ListNodes(ctx context.Context) ([]Node, error)
ListScheduling(ctx context.Context) ([]ModelSchedulingConfig, error)
GetScheduling(ctx context.Context, modelName string) (*ModelSchedulingConfig, error)
SetScheduling(ctx context.Context, req SetSchedulingRequest) (*ModelSchedulingConfig, error)
DeleteScheduling(ctx context.Context, modelName string) error
// SetNodeVRAMBudget sets (or, with an empty budget, clears) a federated
// node's VRAM allocation cap as a sticky admin override. Only meaningful
// in distributed mode; single-process clients report it as unavailable.

View File

@@ -35,6 +35,8 @@ var toolToHTTPRoute = map[string]string{
ToolListKnownBackends: "GET /backends/known",
ToolSystemInfo: "GET / (welcome JSON)",
ToolListNodes: "GET /api/nodes",
ToolListScheduling: "GET /api/nodes/scheduling",
ToolGetScheduling: "GET /api/nodes/scheduling/:model",
ToolVRAMEstimate: "POST /api/models/vram-estimate",
ToolGetBranding: "GET /api/branding",
ToolGetUsageStats: "GET /api/usage (or /api/usage/all when all=true)",
@@ -60,6 +62,8 @@ var toolToHTTPRoute = map[string]string{
ToolCreateVoiceProfile: "POST /api/voice-profiles",
ToolDeleteVoiceProfile: "DELETE /api/voice-profiles/:id",
ToolSetNodeVRAMBudget: "PUT /api/nodes/:id/vram-budget",
ToolSetScheduling: "POST /api/nodes/scheduling",
ToolDeleteScheduling: "DELETE /api/nodes/scheduling/:model",
}
// allKnownTools is the union of expectedFullCatalog (defined in

View File

@@ -115,6 +115,41 @@ type SetNodeVRAMBudgetRequest struct {
Budget string `json:"budget,omitempty" jsonschema:"VRAM allocation cap as a percentage (e.g. 80%) or absolute amount (e.g. 12GB). Empty string clears the override."`
}
// ModelSchedulingConfig is the MCP wire shape for one per-model distributed
// scheduling rule. Keep this DTO explicit instead of aliasing the node-registry
// model so the MCP contract only exposes operator-facing scheduling fields.
type ModelSchedulingConfig struct {
ModelName string `json:"model_name"`
NodeSelector string `json:"node_selector,omitempty"`
MinReplicas int `json:"min_replicas"`
MaxReplicas int `json:"max_replicas"`
SpreadAll bool `json:"spread_all,omitempty"`
RoutePolicy string `json:"route_policy,omitempty"`
BalanceAbsThreshold int `json:"balance_abs_threshold,omitempty"`
BalanceRelThreshold float64 `json:"balance_rel_threshold,omitempty"`
MinPrefixMatch float64 `json:"min_prefix_match,omitempty"`
}
// SetSchedulingRequest is the input for set_scheduling. It mirrors
// /api/nodes/scheduling so standalone MCP and REST callers preserve the same
// PATCH-style semantics for the optional prefix-cache routing fields.
type SetSchedulingRequest struct {
ModelName string `json:"model_name" jsonschema:"Installed model name whose distributed scheduling rule should be created or updated."`
NodeSelector map[string]string `json:"node_selector,omitempty" jsonschema:"Optional node-label selector. Empty means any healthy backend node."`
MinReplicas int `json:"min_replicas" jsonschema:"Minimum desired replicas. Mutually exclusive with spread_all."`
MaxReplicas int `json:"max_replicas" jsonschema:"Maximum desired replicas. Must be >= min_replicas when non-zero. Mutually exclusive with spread_all."`
SpreadAll bool `json:"spread_all,omitempty" jsonschema:"When true, keep one replica on every matching node. Mutually exclusive with min_replicas/max_replicas."`
RoutePolicy *string `json:"route_policy,omitempty" jsonschema:"Optional prefix-cache route policy override. Omit to preserve the existing value on updates."`
BalanceAbsThreshold *int `json:"balance_abs_threshold,omitempty" jsonschema:"Optional absolute imbalance threshold override. Omit to preserve the existing value on updates."`
BalanceRelThreshold *float64 `json:"balance_rel_threshold,omitempty" jsonschema:"Optional relative imbalance threshold override. Omit to preserve the existing value on updates."`
MinPrefixMatch *float64 `json:"min_prefix_match,omitempty" jsonschema:"Optional minimum prefix match threshold override. Omit to preserve the existing value on updates."`
}
// DeleteSchedulingRequest identifies the model scheduling rule to remove.
type DeleteSchedulingRequest struct {
ModelName string `json:"model_name" jsonschema:"Installed model name whose scheduling config should be removed."`
}
// ImportModelURIRequest is the input for import_model_uri. It mirrors the
// REST surface (`/models/import-uri`) closely so both clients can produce
// identical responses; the BackendPreference is a flat field rather than the

View File

@@ -42,6 +42,10 @@ type fakeClient struct {
upgradeBackend func(string) (string, error)
systemInfo func() (*SystemInfo, error)
listNodes func() ([]Node, error)
listScheduling func() ([]ModelSchedulingConfig, error)
getScheduling func(string) (*ModelSchedulingConfig, error)
setScheduling func(SetSchedulingRequest) (*ModelSchedulingConfig, error)
deleteScheduling func(string) error
setNodeVRAMBudget func(string, string) error
vramEstimate func(VRAMEstimateRequest) (*vram.EstimateResult, error)
toggleModelState func(string, modeladmin.Action) error
@@ -230,6 +234,38 @@ func (f *fakeClient) ListNodes(_ context.Context) ([]Node, error) {
return nil, nil
}
func (f *fakeClient) ListScheduling(_ context.Context) ([]ModelSchedulingConfig, error) {
f.record("ListScheduling", nil)
if f.listScheduling != nil {
return f.listScheduling()
}
return []ModelSchedulingConfig{}, nil
}
func (f *fakeClient) GetScheduling(_ context.Context, modelName string) (*ModelSchedulingConfig, error) {
f.record("GetScheduling", modelName)
if f.getScheduling != nil {
return f.getScheduling(modelName)
}
return &ModelSchedulingConfig{ModelName: modelName}, nil
}
func (f *fakeClient) SetScheduling(_ context.Context, req SetSchedulingRequest) (*ModelSchedulingConfig, error) {
f.record("SetScheduling", req)
if f.setScheduling != nil {
return f.setScheduling(req)
}
return &ModelSchedulingConfig{ModelName: req.ModelName, MinReplicas: req.MinReplicas, MaxReplicas: req.MaxReplicas, SpreadAll: req.SpreadAll}, nil
}
func (f *fakeClient) DeleteScheduling(_ context.Context, modelName string) error {
f.record("DeleteScheduling", modelName)
if f.deleteScheduling != nil {
return f.deleteScheduling(modelName)
}
return nil
}
func (f *fakeClient) SetNodeVRAMBudget(_ context.Context, nodeID, budget string) error {
f.record("SetNodeVRAMBudget", []any{nodeID, budget})
if f.setNodeVRAMBudget != nil {

View File

@@ -483,6 +483,49 @@ func (c *Client) ListNodes(ctx context.Context) ([]localaitools.Node, error) {
return out, nil
}
func (c *Client) ListScheduling(ctx context.Context) ([]localaitools.ModelSchedulingConfig, error) {
var out []localaitools.ModelSchedulingConfig
if err := c.do(ctx, http.MethodGet, routeScheduling, nil, &out); err != nil {
if errors.Is(err, ErrHTTPNotFound) {
return []localaitools.ModelSchedulingConfig{}, nil
}
return nil, err
}
return out, nil
}
func (c *Client) GetScheduling(ctx context.Context, modelName string) (*localaitools.ModelSchedulingConfig, error) {
if modelName == "" {
return nil, errors.New("model_name is required")
}
var out localaitools.ModelSchedulingConfig
if err := c.do(ctx, http.MethodGet, routeModelScheduling(modelName), nil, &out); err != nil {
if errors.Is(err, ErrHTTPNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
func (c *Client) SetScheduling(ctx context.Context, req localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
if req.ModelName == "" {
return nil, errors.New("model_name is required")
}
var out localaitools.ModelSchedulingConfig
if err := c.do(ctx, http.MethodPost, routeScheduling, req, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) DeleteScheduling(ctx context.Context, modelName string) error {
if modelName == "" {
return errors.New("model_name is required")
}
return c.do(ctx, http.MethodDelete, routeModelScheduling(modelName), nil, nil)
}
func (c *Client) SetNodeVRAMBudget(ctx context.Context, nodeID, budget string) error {
// PUT with an empty value clears the override server-side (Task 9), so we
// use PUT uniformly rather than switching to DELETE for the clear case.

View File

@@ -84,6 +84,43 @@ func fakeLocalAI() *httptest.Server {
})
})
mux.HandleFunc("/api/nodes/scheduling", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
_ = json.NewEncoder(w).Encode([]map[string]any{{
"id": "sched-1",
"model_name": "qwen",
"min_replicas": 1,
"max_replicas": 2,
"unsatisfiable_ticks": 1,
"unsatisfiable_until": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
}})
case http.MethodPost:
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body["id"] = "sched-1"
_ = json.NewEncoder(w).Encode(body)
default:
http.Error(w, "method", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/api/nodes/scheduling/qwen", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
_ = json.NewEncoder(w).Encode(map[string]any{"model_name": "qwen", "spread_all": true})
case http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "method", http.StatusMethodNotAllowed)
}
})
return httptest.NewServer(mux)
}
@@ -197,8 +234,51 @@ var _ = Describe("httpapi.Client against the LocalAI admin REST surface", func()
Expect(bs[0].Installed).To(BeTrue())
})
})
Describe("Scheduling", func() {
It("lists scheduling configs", func() {
out, err := c.ListScheduling(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(1))
Expect(out[0].ModelName).To(Equal("qwen"))
Expect(out[0].MinReplicas).To(Equal(1))
Expect(schedulingJSONKeys(&out[0])).ToNot(Or(
HaveKey("id"),
HaveKey("unsatisfiable_until"),
HaveKey("unsatisfiable_ticks"),
HaveKey("created_at"),
HaveKey("updated_at"),
))
})
It("gets one scheduling config", func() {
out, err := c.GetScheduling(ctx, "qwen")
Expect(err).ToNot(HaveOccurred())
Expect(out.ModelName).To(Equal("qwen"))
Expect(out.SpreadAll).To(BeTrue())
})
It("sets a scheduling config", func() {
out, err := c.SetScheduling(ctx, localaitools.SetSchedulingRequest{ModelName: "qwen", MinReplicas: 1, MaxReplicas: 2})
Expect(err).ToNot(HaveOccurred())
Expect(out.ModelName).To(Equal("qwen"))
Expect(out.MaxReplicas).To(Equal(2))
})
It("deletes a scheduling config", func() {
Expect(c.DeleteScheduling(ctx, "qwen")).To(Succeed())
})
})
})
func schedulingJSONKeys(config *localaitools.ModelSchedulingConfig) map[string]any {
var out map[string]any
b, err := json.Marshal(config)
Expect(err).ToNot(HaveOccurred())
Expect(json.Unmarshal(b, &out)).To(Succeed())
return out
}
var _ = Describe("Model aliases", func() {
Describe("ListAliases", func() {
It("passes the GET /api/aliases payload through unchanged", func() {

View File

@@ -24,6 +24,7 @@ const (
routeBackendsKnown = "/backends/known"
routeBackendsApply = "/backends/apply"
routeNodes = "/api/nodes"
routeScheduling = "/api/nodes/scheduling"
routeVRAMEstimate = "/api/models/vram-estimate"
routeBranding = "/api/branding"
routeSettings = "/api/settings"
@@ -66,3 +67,7 @@ func routeVoiceProfileDelete(id string) string {
func routeNodeVRAMBudget(id string) string {
return "/api/nodes/" + url.PathEscape(id) + "/vram-budget"
}
func routeModelScheduling(modelName string) string {
return "/api/nodes/scheduling/" + url.PathEscape(modelName)
}

View File

@@ -23,6 +23,8 @@ import (
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/modeladmin"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
"github.com/mudler/LocalAI/core/services/routing/billing"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/services/routing/router"
@@ -47,6 +49,7 @@ type Client struct {
ConfigLoader *config.ModelConfigLoader
ModelLoader *model.ModelLoader
Gallery *galleryop.GalleryService
NodeRegistry *nodes.NodeRegistry
VoiceProfiles *voiceprofile.Store
// StatsRecorder and FallbackUser are optional — they back the
@@ -75,13 +78,18 @@ type Client struct {
// except ModelLoader (used only for SystemInfo's loaded-models report and
// best-effort ShutdownModel calls during config edits) and the stats
// fields (StatsRecorder, FallbackUser) which gate get_usage_stats.
func New(appConfig *config.ApplicationConfig, systemState *system.SystemState, cl *config.ModelConfigLoader, ml *model.ModelLoader, gs *galleryop.GalleryService) *Client {
func New(appConfig *config.ApplicationConfig, systemState *system.SystemState, cl *config.ModelConfigLoader, ml *model.ModelLoader, gs *galleryop.GalleryService, registries ...*nodes.NodeRegistry) *Client {
var registry *nodes.NodeRegistry
if len(registries) > 0 {
registry = registries[0]
}
return &Client{
AppConfig: appConfig,
SystemState: systemState,
ConfigLoader: cl,
ModelLoader: ml,
Gallery: gs,
NodeRegistry: registry,
VoiceProfiles: voiceprofile.NewStore(appConfig.DataPath),
modelAdmin: modeladmin.NewConfigService(cl, appConfig),
}
@@ -501,6 +509,121 @@ func (c *Client) ListNodes(_ context.Context) ([]localaitools.Node, error) {
return []localaitools.Node{}, nil
}
func (c *Client) ListScheduling(ctx context.Context) ([]localaitools.ModelSchedulingConfig, error) {
if c.NodeRegistry == nil {
return []localaitools.ModelSchedulingConfig{}, nil
}
configs, err := c.NodeRegistry.ListModelSchedulings(ctx)
if err != nil {
return nil, err
}
return localaitools.SchedulingConfigsFromNodes(configs), nil
}
func (c *Client) GetScheduling(ctx context.Context, modelName string) (*localaitools.ModelSchedulingConfig, error) {
if modelName == "" {
return nil, errors.New("model_name is required")
}
if c.NodeRegistry == nil {
return nil, errors.New("model scheduling is only available in distributed mode")
}
config, err := c.NodeRegistry.GetModelScheduling(ctx, modelName)
if err != nil {
return nil, err
}
if config == nil {
return nil, nil
}
out := localaitools.SchedulingConfigFromNode(*config)
return &out, nil
}
func (c *Client) SetScheduling(ctx context.Context, req localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
if req.ModelName == "" {
return nil, errors.New("model_name is required")
}
if c.NodeRegistry == nil {
return nil, errors.New("model scheduling is only available in distributed mode")
}
existing, err := c.NodeRegistry.GetModelScheduling(ctx, req.ModelName)
if err != nil {
return nil, fmt.Errorf("load existing scheduling config: %w", err)
}
routePolicy := ""
absThr := 0
relThr := 0.0
minMatch := 0.0
if existing != nil {
routePolicy = existing.RoutePolicy
absThr = existing.BalanceAbsThreshold
relThr = existing.BalanceRelThreshold
minMatch = existing.MinPrefixMatch
}
if req.RoutePolicy != nil {
routePolicy = *req.RoutePolicy
}
if req.BalanceAbsThreshold != nil {
absThr = *req.BalanceAbsThreshold
}
if req.BalanceRelThreshold != nil {
relThr = *req.BalanceRelThreshold
}
if req.MinPrefixMatch != nil {
minMatch = *req.MinPrefixMatch
}
if req.SpreadAll && (req.MinReplicas != 0 || req.MaxReplicas != 0) {
return nil, errors.New("spread_all and min_replicas/max_replicas are mutually exclusive")
}
if req.MinReplicas < 0 {
return nil, errors.New("min_replicas must be >= 0")
}
if req.MaxReplicas < 0 {
return nil, errors.New("max_replicas must be >= 0")
}
if req.MaxReplicas > 0 && req.MinReplicas > req.MaxReplicas {
return nil, errors.New("min_replicas must be <= max_replicas")
}
if err := prefixcache.ValidateThresholds(routePolicy, absThr, relThr, minMatch); err != nil {
return nil, err
}
var selectorJSON string
if len(req.NodeSelector) > 0 {
b, err := json.Marshal(req.NodeSelector)
if err != nil {
return nil, fmt.Errorf("invalid node_selector: %w", err)
}
selectorJSON = string(b)
}
config := &nodes.ModelSchedulingConfig{
ModelName: req.ModelName,
NodeSelector: selectorJSON,
MinReplicas: req.MinReplicas,
MaxReplicas: req.MaxReplicas,
SpreadAll: req.SpreadAll,
RoutePolicy: routePolicy,
BalanceAbsThreshold: absThr,
BalanceRelThreshold: relThr,
MinPrefixMatch: minMatch,
}
if err := c.NodeRegistry.SetModelScheduling(ctx, config); err != nil {
return nil, err
}
out := localaitools.SchedulingConfigFromNode(*config)
return &out, nil
}
func (c *Client) DeleteScheduling(ctx context.Context, modelName string) error {
if modelName == "" {
return errors.New("model_name is required")
}
if c.NodeRegistry == nil {
return errors.New("model scheduling is only available in distributed mode")
}
return c.NodeRegistry.DeleteModelScheduling(ctx, modelName)
}
func (c *Client) SetNodeVRAMBudget(_ context.Context, _, _ string) error {
// The node registry is a distributed-mode concern owned by the Application
// layer and is not wired into the in-process client (which also returns an

View File

@@ -2,6 +2,7 @@ package inproc
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -13,8 +14,11 @@ import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/nodes"
localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools"
"github.com/mudler/LocalAI/pkg/system"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Regression spec for the bug we fixed when channel sends were
@@ -124,3 +128,88 @@ var _ = Describe("inproc.Client model aliases", func() {
})
})
})
var _ = Describe("inproc.Client model scheduling", func() {
var (
ctx context.Context
registry *nodes.NodeRegistry
c *Client
stringp = func(s string) *string { return &s }
floatp = func(f float64) *float64 { return &f }
)
BeforeEach(func() {
ctx = context.Background()
db, err := gorm.Open(sqlite.Open(filepath.Join(GinkgoT().TempDir(), "nodes.db")), &gorm.Config{})
Expect(err).ToNot(HaveOccurred())
registry, err = nodes.NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
tempDir := GinkgoT().TempDir()
systemState, err := system.GetSystemState(system.WithModelPath(tempDir))
Expect(err).ToNot(HaveOccurred())
appConfig := config.NewApplicationConfig(config.WithSystemState(systemState))
c = New(appConfig, systemState, config.NewModelConfigLoader(tempDir), nil, nil, registry)
})
It("sets, lists, gets, merges, and deletes scheduling configs through the registry", func() {
created, err := c.SetScheduling(ctx, localaitools.SetSchedulingRequest{
ModelName: "qwen",
NodeSelector: map[string]string{"gpu": "nvidia"},
MinReplicas: 1,
MaxReplicas: 2,
RoutePolicy: stringp("prefix_cache"),
MinPrefixMatch: floatp(0.4),
})
Expect(err).ToNot(HaveOccurred())
Expect(created.ModelName).To(Equal("qwen"))
Expect(created.NodeSelector).To(Equal(`{"gpu":"nvidia"}`))
Expect(created.RoutePolicy).To(Equal("prefix_cache"))
Expect(created.MinPrefixMatch).To(Equal(0.4))
Expect(schedulingJSONKeys(created)).ToNot(Or(
HaveKey("id"),
HaveKey("unsatisfiable_until"),
HaveKey("unsatisfiable_ticks"),
HaveKey("created_at"),
HaveKey("updated_at"),
))
listed, err := c.ListScheduling(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(listed).To(HaveLen(1))
Expect(listed[0].ModelName).To(Equal("qwen"))
updated, err := c.SetScheduling(ctx, localaitools.SetSchedulingRequest{
ModelName: "qwen",
MinReplicas: 2,
MaxReplicas: 3,
})
Expect(err).ToNot(HaveOccurred())
Expect(updated.MinReplicas).To(Equal(2))
Expect(updated.MaxReplicas).To(Equal(3))
Expect(updated.RoutePolicy).To(Equal("prefix_cache"))
Expect(updated.MinPrefixMatch).To(Equal(0.4))
got, err := c.GetScheduling(ctx, "qwen")
Expect(err).ToNot(HaveOccurred())
Expect(got).ToNot(BeNil())
Expect(got.MaxReplicas).To(Equal(3))
Expect(c.DeleteScheduling(ctx, "qwen")).To(Succeed())
missing, err := c.GetScheduling(ctx, "qwen")
Expect(err).ToNot(HaveOccurred())
Expect(missing).To(BeNil())
})
})
func schedulingJSONKeys(config *localaitools.ModelSchedulingConfig) map[string]any {
var out map[string]any
Expect(json.Unmarshal([]byte(mustMarshal(config)), &out)).To(Succeed())
return out
}
func mustMarshal(v any) string {
b, err := json.Marshal(v)
Expect(err).ToNot(HaveOccurred())
return string(b)
}

View File

@@ -2,7 +2,7 @@
These rules are non-negotiable. The user trusts you to operate their server without unintended changes.
1. **Confirm before mutating.** Before calling any of these tools — `install_model`, `import_model_uri`, `delete_model`, `install_backend`, `upgrade_backend`, `edit_model_config`, `reload_models`, `load_model`, `toggle_model_state`, `toggle_model_pinned`, `create_voice_profile`, `delete_voice_profile` — first state in plain language what you are about to do (which tool, which target, which arguments) and wait for the user's explicit confirmation in the next turn. "Yes", "do it", "go ahead", "proceed" all count as confirmation. Anything else does not.
1. **Confirm before mutating.** Before calling any of these tools — `install_model`, `import_model_uri`, `delete_model`, `install_backend`, `upgrade_backend`, `edit_model_config`, `reload_models`, `load_model`, `toggle_model_state`, `toggle_model_pinned`, `create_voice_profile`, `delete_voice_profile`, `set_node_vram_budget`, `set_scheduling`, `delete_scheduling` — first state in plain language what you are about to do (which tool, which target, which arguments) and wait for the user's explicit confirmation in the next turn. "Yes", "do it", "go ahead", "proceed" all count as confirmation. Anything else does not.
2. **Disambiguate before mutating.** If the user's request is ambiguous (several gallery candidates match, the model name has multiple installed versions, the backend has variants), present the candidates as a numbered list and ask the user to pick before calling any mutating tool.

View File

@@ -14,6 +14,8 @@ The MCP `tools/list` endpoint also exposes the full input schema for each of the
- `vram_estimate` — Estimate VRAM use for a model under a given config.
- `system_info` — LocalAI version, paths, distributed flag, loaded models, installed backends.
- `list_nodes` — List federated worker nodes (only useful in distributed mode).
- `list_scheduling` — List distributed per-model scheduling configs.
- `get_scheduling` — Read the distributed scheduling config for one model.
- `list_voice_profiles` — List reusable voice-cloning profiles and their stable TTS voice URIs.
## Mutating (require user confirmation per safety rule 1)
@@ -30,3 +32,6 @@ The MCP `tools/list` endpoint also exposes the full input schema for each of the
- `toggle_model_pinned` — Pin or unpin a model (`action`: `pin` or `unpin`).
- `create_voice_profile` — Save a consent-confirmed base64 PCM-WAV reference and exact transcript for reuse in TTS.
- `delete_voice_profile` — Permanently delete a saved voice profile by UUID.
- `set_node_vram_budget` — Set or clear a federated node's VRAM budget override.
- `set_scheduling` — Create or update a distributed per-model scheduling config.
- `delete_scheduling` — Remove a distributed per-model scheduling config.

View File

@@ -0,0 +1,9 @@
# Skill: Manage distributed scheduling
Use this when the user asks to inspect, set, or remove distributed per-model scheduling rules.
1. Call `system_info` first. If `distributed` is false, explain that scheduling tools only affect distributed deployments.
2. For inspection, call `list_scheduling` or `get_scheduling` and summarize model name, selector, replica bounds, spread-all, and route policy fields.
3. Before calling `set_scheduling` or `delete_scheduling`, follow safety rule 1 and wait for explicit confirmation.
4. For `set_scheduling`, never combine `spread_all: true` with non-zero `min_replicas` or `max_replicas`.
5. After a confirmed mutation, call `get_scheduling` for that model and summarize the persisted config.

View File

@@ -4,11 +4,12 @@ Use this when the user asks "what's installed?", "what's running?", "show status
1. Call `system_info` for version, paths, distributed flag, loaded models, installed backends.
2. Call `list_installed_models` (no capability filter) for the full installed-model inventory.
3. If `system_info.distributed` is true, also call `list_nodes` and report worker health.
3. If `system_info.distributed` is true, also call `list_nodes` and `list_scheduling`, then report worker health and any per-model scheduling rules.
4. Present a concise summary:
- **Version & mode** (`distributed: true|false`)
- **Installed models** (count + list, each with name and capabilities)
- **Installed backends** (count + list)
- **Loaded right now** (from `loaded_models`)
- **Workers** (only when distributed)
- **Scheduling rules** (only when distributed)
5. Do not call mutating tools in this skill.

View File

@@ -0,0 +1,25 @@
package localaitools
import "github.com/mudler/LocalAI/core/services/nodes"
func SchedulingConfigFromNode(config nodes.ModelSchedulingConfig) ModelSchedulingConfig {
return ModelSchedulingConfig{
ModelName: config.ModelName,
NodeSelector: config.NodeSelector,
MinReplicas: config.MinReplicas,
MaxReplicas: config.MaxReplicas,
SpreadAll: config.SpreadAll,
RoutePolicy: config.RoutePolicy,
BalanceAbsThreshold: config.BalanceAbsThreshold,
BalanceRelThreshold: config.BalanceRelThreshold,
MinPrefixMatch: config.MinPrefixMatch,
}
}
func SchedulingConfigsFromNodes(configs []nodes.ModelSchedulingConfig) []ModelSchedulingConfig {
out := make([]ModelSchedulingConfig, 0, len(configs))
for _, config := range configs {
out = append(out, SchedulingConfigFromNode(config))
}
return out
}

View File

@@ -47,6 +47,7 @@ func NewServer(client LocalAIClient, opts Options) *mcp.Server {
registerBackendTools(srv, client, opts)
registerConfigTools(srv, client, opts)
registerSystemTools(srv, client, opts)
registerSchedulingTools(srv, client, opts)
registerStateTools(srv, client, opts)
registerBrandingTools(srv, client, opts)
registerVoiceProfileTools(srv, client, opts)

View File

@@ -92,6 +92,7 @@ var expectedFullCatalog = sortedStrings(
ToolListInstalledModels,
ToolListKnownBackends,
ToolListNodes,
ToolListScheduling,
ToolListVoiceProfiles,
ToolLoadModel,
ToolReloadModels,
@@ -105,6 +106,9 @@ var expectedFullCatalog = sortedStrings(
ToolCreateVoiceProfile,
ToolDeleteVoiceProfile,
ToolSetNodeVRAMBudget,
ToolSetScheduling,
ToolDeleteScheduling,
ToolGetScheduling,
)
// expectedReadOnlyCatalog is the tool set when DisableMutating=true. Sorted.
@@ -123,6 +127,8 @@ var expectedReadOnlyCatalog = sortedStrings(
ToolListInstalledModels,
ToolListKnownBackends,
ToolListNodes,
ToolListScheduling,
ToolGetScheduling,
ToolListVoiceProfiles,
ToolSystemInfo,
ToolVRAMEstimate,
@@ -165,6 +171,10 @@ var _ = Describe("Tool dispatch", func() {
{ToolListKnownBackends, struct{}{}, "ListKnownBackends"},
{ToolSystemInfo, struct{}{}, "SystemInfo"},
{ToolListNodes, struct{}{}, "ListNodes"},
{ToolListScheduling, struct{}{}, "ListScheduling"},
{ToolGetScheduling, DeleteSchedulingRequest{ModelName: "qwen"}, "GetScheduling"},
{ToolSetScheduling, SetSchedulingRequest{ModelName: "qwen", MinReplicas: 1, MaxReplicas: 2}, "SetScheduling"},
{ToolDeleteScheduling, DeleteSchedulingRequest{ModelName: "qwen"}, "DeleteScheduling"},
{ToolListVoiceProfiles, struct{}{}, "ListVoiceProfiles"},
{ToolInstallModel, InstallModelRequest{ModelName: "test/foo"}, "InstallModel"},
{ToolImportModelURI, ImportModelURIRequest{URI: "Qwen/Qwen3-4B-GGUF"}, "ImportModelURI"},
@@ -219,6 +229,40 @@ var _ = Describe("Tool error surfacing", func() {
})
})
var _ = Describe("Scheduling tool behavior", func() {
It("round-trips list/get/set/delete over the MCP surface", func() {
fc := &fakeClient{
listScheduling: func() ([]ModelSchedulingConfig, error) {
return []ModelSchedulingConfig{{ModelName: "qwen", MinReplicas: 1, MaxReplicas: 2}}, nil
},
getScheduling: func(modelName string) (*ModelSchedulingConfig, error) {
return &ModelSchedulingConfig{ModelName: modelName, SpreadAll: true}, nil
},
setScheduling: func(req SetSchedulingRequest) (*ModelSchedulingConfig, error) {
return &ModelSchedulingConfig{ModelName: req.ModelName, MinReplicas: req.MinReplicas, MaxReplicas: req.MaxReplicas}, nil
},
}
ctx, sess, done := connectInMemory(fc, Options{})
DeferCleanup(done)
listRes := callTool(ctx, sess, ToolListScheduling, struct{}{})
Expect(listRes.IsError).To(BeFalse(), resultText(listRes))
Expect(resultText(listRes)).To(ContainSubstring(`"model_name": "qwen"`))
getRes := callTool(ctx, sess, ToolGetScheduling, DeleteSchedulingRequest{ModelName: "qwen"})
Expect(getRes.IsError).To(BeFalse(), resultText(getRes))
Expect(resultText(getRes)).To(ContainSubstring(`"spread_all": true`))
setRes := callTool(ctx, sess, ToolSetScheduling, SetSchedulingRequest{ModelName: "qwen", MinReplicas: 1, MaxReplicas: 2})
Expect(setRes.IsError).To(BeFalse(), resultText(setRes))
Expect(resultText(setRes)).To(ContainSubstring(`"max_replicas": 2`))
deleteRes := callTool(ctx, sess, ToolDeleteScheduling, DeleteSchedulingRequest{ModelName: "qwen"})
Expect(deleteRes.IsError).To(BeFalse(), resultText(deleteRes))
Expect(resultText(deleteRes)).To(ContainSubstring(`"deleted": "qwen"`))
})
})
var _ = Describe("Argument validation", func() {
type validationCase struct {
desc string
@@ -235,6 +279,9 @@ var _ = Describe("Argument validation", func() {
{"toggle_model_state rejects unknown action", ToolToggleModelState, map[string]any{"name": "foo", "action": "noop"}, "action must be one of"},
{"edit_model_config rejects empty patch", ToolEditModelConfig, map[string]any{"name": "foo", "patch": map[string]any{}}, "patch is required"},
{"create_voice_profile requires consent", ToolCreateVoiceProfile, CreateVoiceProfileRequest{Name: "Voice", Transcript: "words", AudioBase64: "UklGRg=="}, "consent_confirmed must be true"},
{"set_scheduling requires model_name", ToolSetScheduling, SetSchedulingRequest{}, "model_name is required"},
{"set_scheduling rejects invalid replica range", ToolSetScheduling, SetSchedulingRequest{ModelName: "qwen", MinReplicas: 3, MaxReplicas: 1}, "min_replicas must be <= max_replicas"},
{"delete_scheduling requires model_name", ToolDeleteScheduling, DeleteSchedulingRequest{}, "model_name is required"},
}
for _, c := range cases {

View File

@@ -17,6 +17,8 @@ const (
ToolListKnownBackends = "list_known_backends"
ToolSystemInfo = "system_info"
ToolListNodes = "list_nodes"
ToolListScheduling = "list_scheduling"
ToolGetScheduling = "get_scheduling"
ToolVRAMEstimate = "vram_estimate"
ToolGetBranding = "get_branding"
ToolGetUsageStats = "get_usage_stats"
@@ -42,6 +44,8 @@ const (
ToolCreateVoiceProfile = "create_voice_profile"
ToolDeleteVoiceProfile = "delete_voice_profile"
ToolSetNodeVRAMBudget = "set_node_vram_budget"
ToolSetScheduling = "set_scheduling"
ToolDeleteScheduling = "delete_scheduling"
// ToolListAliases is read-only but lives here so the alias tools stay
// grouped; the catalog tests assert its read-only placement.

View File

@@ -0,0 +1,77 @@
package localaitools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func registerSchedulingTools(s *mcp.Server, client LocalAIClient, opts Options) {
mcp.AddTool(s, &mcp.Tool{
Name: ToolListScheduling,
Description: "List distributed per-model scheduling configs (only meaningful in distributed mode).",
}, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
configs, err := client.ListScheduling(ctx)
if err != nil {
return errorResult(err), nil, nil
}
return jsonResult(configs), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: ToolGetScheduling,
Description: "Get the distributed scheduling config for one model, or null when none is configured.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, args DeleteSchedulingRequest) (*mcp.CallToolResult, any, error) {
if args.ModelName == "" {
return errorResultf("model_name is required"), nil, nil
}
config, err := client.GetScheduling(ctx, args.ModelName)
if err != nil {
return errorResult(err), nil, nil
}
return jsonResult(config), nil, nil
})
if opts.DisableMutating {
return
}
mcp.AddTool(s, &mcp.Tool{
Name: ToolSetScheduling,
Description: "Create or update a distributed per-model scheduling config. Requires user confirmation per safety rule 1.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, args SetSchedulingRequest) (*mcp.CallToolResult, any, error) {
if args.ModelName == "" {
return errorResultf("model_name is required"), nil, nil
}
if args.SpreadAll && (args.MinReplicas != 0 || args.MaxReplicas != 0) {
return errorResultf("spread_all and min_replicas/max_replicas are mutually exclusive"), nil, nil
}
if args.MinReplicas < 0 {
return errorResultf("min_replicas must be >= 0"), nil, nil
}
if args.MaxReplicas < 0 {
return errorResultf("max_replicas must be >= 0"), nil, nil
}
if args.MaxReplicas > 0 && args.MinReplicas > args.MaxReplicas {
return errorResultf("min_replicas must be <= max_replicas"), nil, nil
}
config, err := client.SetScheduling(ctx, args)
if err != nil {
return errorResult(err), nil, nil
}
return jsonResult(config), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: ToolDeleteScheduling,
Description: "Delete a distributed per-model scheduling config. Requires user confirmation per safety rule 1.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, args DeleteSchedulingRequest) (*mcp.CallToolResult, any, error) {
if args.ModelName == "" {
return errorResultf("model_name is required"), nil, nil
}
if err := client.DeleteScheduling(ctx, args.ModelName); err != nil {
return errorResult(err), nil, nil
}
return jsonResult(map[string]string{"deleted": args.ModelName}), nil, nil
})
}

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