Compare commits

...

83 Commits

Author SHA1 Message Date
localai-org-maint-bot
ac25d08760 fix(ds4): resolve repository imports to GGUF
Select a concrete DeepSeek V4 Flash GGUF from Hugging Face metadata while honoring quantization preference order. Reject unresolved repository imports instead of emitting an invalid bare URI.

Assisted-by: Codex:gpt-5
2026-08-04 04:27:57 +00: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
localai-org-maint-bot
7f9ffd9f54 gallery: add AMD Instella MoE 16B variants (#11308)
Add Q4_K_M and Q8_0 GGUF builds for the trending Instella-MoE-16B-A3B-Think model, with host-selectable variant metadata and verified Hugging Face LFS hashes.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 12:16:29 +02:00
mudler's LocalAI [bot]
5cb0c1a872 feat(ui): close the gap between the shipped UI and the design mocks (#11307)
* feat(ui): give the Operate overview real numbers and traces a latency shape

First two items from a component-by-component comparison against the mocks.
The pattern that audit found: everything newly built matched, everything
pre-existing got the palette but not the layout, and an "absent rather than
empty" rule hid most of the overview exactly when someone was looking at an
idle installation.

**The headline grid is always rendered**, including at zero, with a fourth cell
for host memory. Hiding it removed the page's structure precisely when it was
most likely to be read, and "0 failed" is information — an absent panel is not.
The quiet case is now said in a line underneath instead of by showing nothing.

**The sections state counts** rather than listing their destinations: backends,
models, updates and running operations instead of the words "Usage and traces".
That needed installed backend and model counts in the summary context, which
are two more cheap reads on the poll that was already running.

**Traces rows carry latency as a bar as well as a figure**, scaled against the
slowest request currently in view and turning amber past two seconds. The table
had no latency column at all — the number was buried in the expanded detail, so
the shape of the tail was invisible while scanning. Scaling against the view
rather than an absolute ceiling is deliberate: what matters when reading a page
of traces is which of these are the outliers, and an absolute scale flattens
every row on a fast installation into nothing.

Full e2e suite: 409 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): name the engine on Home's resident models, and add jump-back-in

Third item from the mock comparison.

The mock showed each resident model with the engine serving it. /system carried
only the id, so the audit recorded this as blocked on a server field — but the
config loader is already in scope where that response is built, so it is one
lookup. SysInfoModel gains an optional `backend`, resolved from the model's
config and omitted rather than guessed when there is none (a loose file, or a
config since removed). Home renders the column blank in that case; the test
pins both halves of that.

Memory per model stays out. It is not one lookup — it would mean asking each
backend process — and inventing a number beside a real one is worse than
leaving the column off.

"Jump back in" is the block the mock had and Home did not. The quick-links row
above it is a set of first-run actions; these are the three places someone
returns to, each stated with what it currently holds rather than as a bare
label.

Go: routes suite passes. Full e2e suite: 412 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): rank the recommended models as lanes instead of equal cards

The hardware recommendations were a grid of equally-weighted cards. The list is
already sorted by fit, and a grid throws that order away: three cards side by
side say "pick one", when the page has actually formed an opinion about which
one.

They are lanes now, read top to bottom in fit order, with the leader carrying
the single amber "Best fit" label and the rest marked "Also fits". One opinion
per page — the alternatives are alternatives, not runners-up each worth their
own colour, which is how a strip of coloured badges ends up meaning nothing.

Below 720px the size and VRAM columns drop and the lane keeps the name and the
install action, which are the two things a narrow screen needs.

The existing panel spec moves off .rec-models-item onto .lane rather than being
deleted; dismissal, collapse, keyboard operation and install all still pass
unchanged, and there is a new assertion that exactly one row is called out.

Full e2e suite: 413 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): drop capsule chips app-wide, and un-break the empty voice library

**Pills are gone.** A capsule radius reads as a tag floating on the surface,
which fights a system whose structure is hairlines and square corners — and
with chips on Discover, Host, Activity and the biometrics pages, "some pages
have pills" was the real inconsistency rather than any one page.

Sixteen selectors move to the small radius: filter buttons, tab pills, activity
and biometrics chips, file and count badges, the jump-to-latest control, the
nav badge. Round *buttons* keep their circle — .lightbox__nav and
.home-send-btn are circles, not capsules — as do every progress track, status
dot and avatar, which are round because they are round, not because they are
tags.

**The empty voice library was unusable.** `.voice-library-empty` sets
min-height: 430px, border: 0 and background: transparent — a description of the
empty PANEL — and it had been attached to the action instead. The create button
was therefore a 430px transparent box that pushed itself out of the panel and
could not be seen. Moved onto the container it describes, which now centres its
action rather than letting it fall off the bottom. Same class-mangling shape as
the Agents header fixed earlier.

Full e2e suite: 416 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): put Host's headline figures on the shared hairline strip

Host had shadowed, clickable StatCards above a page that already has a rail, a
pane and a tab bar — a second dashboard language on one screen, and a different
one again from the figures inside its own detail pane.

The Operate overview's figure grid is generalised into a shared `.stat-strip`
and Host adopts it, so the two pages read as one system: same cell, same figure
scale, same tone vocabulary, and the same hairline grid the split-view StatGrid
already uses. The cells stay clickable and still route into the tab and filter
they describe, because a count is worth more when it is also the way to the
thing counted.

Tone is spent only where the number means something — running and updates when
non-zero — since a strip where every cell is coloured has no emphasis left.

Two bugs made on the way, both now covered:

- The first version put `<button>` elements inside a `<dl>` with `<dt>`/`<dd>`
  inside the buttons. Neither is valid, the browser re-parents both, and the
  cells collapsed. These cells are a set of controls, so a plain container of
  buttons is also the honest markup.
- Even correct, the strip rendered 2px tall: `.page--app` is a flex column
  whose split view takes flex:1, so a child with no intrinsic minimum is shrunk
  away. The old cards survived only because `.stat-card` carried
  min-height:96px. The strip now declines to shrink, with a test pinning it.

The stat-card specs are retargeted rather than deleted: they were written to
guard a class collision on a page that no longer uses cards, so they now guard
the strip's labels and its height.

Full e2e suite: 417 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): make Backends notices an edge rather than a filled card

The install and upgrade banners were tinted cards with a full border. A filled
panel makes every notice shout at the weight of an error, which is how notices
stop being read — and Backends shows one on most visits, so it was shouting
routinely.

They are now a hairline with a coloured left edge, the same treatment the
Operate overview gives rows that want a decision, so "this needs you" looks the
same wherever it appears. Counts in the notice take the monospace tabular
figures the rest of the console uses.

Also drops the last inline style on the page, and refreshes the inline-style
baseline, which has read 624 against a real count since #11288 landed. The gate
exits 0 either way, so nothing was failing — but a baseline 86 above the truth
would have let that many inline styles back in unnoticed. Now at 538, which
tightens the ratchet rather than loosening it.

The spec creates the upgrade it asserts on rather than skipping when the mock
has no notice: a test that skips is a test that proves nothing.

Full e2e suite: 418 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): finish the mock parity list, and stop hiding the recommendations

The last two items from the audit, plus a correction.

**Discover's use-case shelf is lanes.** These are a list of ways in, read in
order; a grid of equal cards asks the reader to compare them, which is not the
choice on offer.

**The request panel reaches every generator.** Video, 3D, Sound and Audio FX
join Images and Speech, so each one teaches its own endpoint rather than two of
six doing it. Audio FX records the fields that shape the request rather than
the bytes, since its payload is multipart.

**Recommendations no longer collapse themselves.** They were folded away by
default once anything was installed. That is the page's one opinion about this
host, and an opinion hidden by default is one the reader never gets. Someone
who disagrees can still collapse it and that choice is remembered — the
difference is that we no longer make it for them. Three specs asserted the old
default and now assert the new one.

The use-case heading also sat a line's width from the text it introduces, so
the two read as one paragraph. It has air under it now, and the shelf is
separated from the recommendations above it.

Two tests removed rather than kept: a generator loop whose only real assertion
was `expect(endpoint.length).toBeGreaterThan(0)`, and an earlier card-gap guard
that could only skip. A test that cannot fail is worse than no test, because it
reads as coverage.

Full e2e suite: 418 passed, 4 skipped. Inline styles at baseline.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): make the Host figures legible and give the strip its spacing back

Three defects introduced by the Host redesign, all found by looking at the
running app rather than by the suite.

**The figures were invisible.** "Running now" and "Updates available" rendered
pure black on the dark ground. Two causes compounding: the `--muted` tone alias
never landed, because the source rule has extra spaces before its brace and the
exact-match edit missed it silently; and a `<button>` does not inherit colour,
so with no tone rule the value fell back to the user agent's `buttontext`.
Both fixed, and a test now fails on any figure computing to pure black.

**The strip sat flush against the resources panel.** `.stat-strip` declares
`margin: 0 0 ...` and is declared later in the file than `.manage-summary`, so
the shorthand quietly won and the top margin became zero. Raised to
`.stat-strip.manage-summary` so it beats the shorthand on specificity rather
than on declaration order, which is the kind of thing that breaks again the
next time a rule moves.

**Discover's use-case heading had a doubled gap.** `.zero-pane` is a flex
column that already separates its children; adding a margin on top of the gap
stacked the two. The margin is gone and the heading keeps only its own breathing
room.

Full e2e suite: 420 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): make Studio's tabs path segments rather than a query parameter

`/app/studio?tab=images` reads like a filter applied to a page. It is
navigation: a different generator, with its own state and its own deep link. It
is now `/app/studio/images`, with the overview at `/app/studio`.

Legacy `?tab=` links are redirected once to the path form, replacing the
history entry so Back does not bounce between two spellings of the same place.
Bookmarks and older links keep working and land on the canonical URL rather
than a second version of it, which is the part worth having a test for.

The nine `?tab=` references were all in specs, none in docs, so the migration
is contained. They move to paths, and a new spec pins the redirect.

Full e2e suite: 421 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): make the hardware recommendation a section, not a dismissable card

It was a bordered card with a collapse control and a close button, sitting
inside a pane that is otherwise hairline sections. Two problems: it read as
something bolted onto the page rather than part of it, and treating it as an
interruption to be shut is the wrong frame for the one thing the page has to
say about the machine it is running on.

It is now a plain section with the same heading treatment as the shelves below
it. The collapse state, the dismissal, their storage keys and the legacy key
read for backwards compatibility all go with it, along with the installedCount
prop that existed only to pick a default collapse.

Five specs described behaviour that no longer exists and are removed rather
than adjusted — collapsing, dismissing, persistence of both, and the toggle's
keyboard handling. One new spec asserts the replacement contract: no control
with aria-expanded, no dismiss, and no card border.

Full e2e suite: 416 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): restore every stripped icon and every default-chrome control

You reported two broken icons. They were not two: an earlier automated edit had
stripped the `fa-*` class from twenty `<i>` elements across eleven pages, and an
`<i>` with no icon class renders nothing at all. Settings' save button, the
voice-profile back link, and eighteen others — agent row actions, task and job
buttons, import and create actions — were all drawing empty space.

Each is restored from its own context rather than a blanket icon: the agent row
gets pause/play, pen, comments, file-export and trash; the fine-tune toggle
swaps plus for xmark as it opens; the P2P documentation link gets the
external-link glyph.

The same edit left controls without their classes. Fine-tune's "Import config"
was rendering in the browser's own chrome, and `.p2p-cmd__copy` set a border
but no background, so it fell back to `buttonface` — a pale grey chip on a dark
command block. FineTune's "New job" also had its icon classes folded into the
button's className, the same mangling already fixed on the Agents header.

Rather than fix the reported two and wait for the next report, this adds a
standing audit: twenty-five routes are walked and the test fails on any visible
control rendering with user-agent chrome, or any `<i>` without an `fa-*` class.
It found the three remaining cases after the first sweep, and it is the reason
the next one cannot ship quietly.

Full e2e suite: 417 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
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 12:16:09 +02:00
mudler's LocalAI [bot]
cd890b6a26 chore: ⬆️ Update leejet/stable-diffusion.cpp to db99efdd6d2a43c7937fd55b3359206c680a75b0 (#11299)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-03 08:40:23 +02:00
mudler's LocalAI [bot]
1c0380ad44 chore: ⬆️ Update 0xShug0/audio.cpp to 5a8312ef7b8aa7cf14e9a24ac568cabd8725d68a (#11302)
⬆️ 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-03 08:36:53 +02:00
mudler's LocalAI [bot]
cb6e4d4391 chore: ⬆️ Update CrispStrobe/CrispASR to fcb79282a6bc52e13d858026c42b24fb6e63c97a (#11304)
⬆️ 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-03 08:35:38 +02:00
localai-org-maint-bot
cba54c5ea1 gallery: add grug-27b GGUF variants (#11311)
Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-03 08:35:16 +02:00
mudler's LocalAI [bot]
f951419207 chore(model-gallery): propose variant groupings for review (#11312)
chore(model-gallery): propose variant groupings

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 08:34:21 +02:00
mudler's LocalAI [bot]
58ea2f5d79 feat(ui): give Operate and Studio a front door, and fix two layout regressions (#11305)
* feat(ui): give Operate a front door and fold six rail groups into four

Opening Operate ran firstVisiblePath() and landed on Backends, because
Backends happens to be written first in operateConsole.groups. The section
that should answer "is anything wrong" opened on a package manager, and
nothing was reported until you visited it.

Adds /app/operate. Its one irreplaceable block is "Needs attention", which
is empty when nothing is wrong and says so in a line rather than rendering a
reassuring green panel. It collects stale backends, failed operations and
unhealthy nodes. Everything else on the page is a summary you could already
assemble by visiting four others.

The rail regroups from six headings to four: Inference and Activity were both
"the runtime right now", Access and System were both administration. No
destination is removed and no gate changes, so isConsoleItemVisible and
consolePaths are untouched. Overview leads the first group, which is what
makes firstVisiblePath() return it without knowing it exists.

Rail entries now carry a signal beside the label. This does not replace the
sidebar badge and is not built as if it does: the badge stays on the
always-visible sidebar entry for the reason recorded in Sidebar.jsx, that the
rail exists only on Operate routes and can be collapsed. The signals are
orientation while inside Operate, so they are aria-hidden and nothing urgent
depends on them alone.

OperateSummaryContext polls once for the whole console, following
OperationsContext, which exists because per-consumer setInterval against one
endpoint was the defect it fixed. It is mounted by ConsoleLayout for the
Operate console only, so "poll only while in Operate" needs no route check.
Built on usePolling, so it pauses on a hidden tab. Operations are read from
OperationsContext rather than polled a second time, and each source degrades
to no-signal on its own so one dead endpoint cannot blank the rest. It reads
the cached GET /api/backends/upgrades and never the POST that forces a real
registry check.

Traces and Usage get no signal yet: /api/traces returns the list, so a count
would mean fetching every trace to render one number. A counts endpoint is
the honest fix and is scoped separately.

Full e2e suite green (369 passed, 4 skipped), including a render-smoke entry
for the new route.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): open Studio on what this machine can actually make

Studio was a tab strip over six generators that opened on Images, which was
never a decision, only the first entry in BASE_TABS. Nothing said which
modalities this installation could run, so the way to learn that video had no
model was to pick the tab and find an empty select.

Adds an overview tab and makes it the fallback. Explicit tabs still win, so
existing deep links keep working; anything unrecognised or gated now lands on
the overview rather than Images.

Each tab carries a dot: filled when an installed model advertises that
modality, hollow when nothing serves it. That is the feature in one detail,
turning the strip from navigation into a report of what the machine can do
before anything is clicked. The dot is aria-hidden because the overview states
the same facts in words and the dots change as models load.

Two kinds of unavailable, which had to stop looking alike:
  - switched off, via a permission: no tab and no lane, unchanged
  - available with no model: a lane, and a route to installing one

Studio now owns one MODALITIES table so the tab strip and the overview cannot
disagree about what exists, and calls useModels() once, unfiltered, grouping in
the browser. useModels(capability) fetches the whole list and filters locally,
so a hook per modality would have been six identical requests to
/api/models/capabilities on every mount. There is a test for that.

Recent outputs read every localStorage store through a new
readAllMediaHistory(), which avoids mounting five hooks that carry save timers
the overview has no use for. 3D is read separately through use3DHistory rather
than folded in: its entries are GLB blobs in IndexedDB, so they cannot come
from the same synchronous read.

Typical cost is the median of this machine's own history, not a guess, and
renders as a dash when there is nothing to go on.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): stop the stat cards and the console rail breaking on small screens

Two unrelated causes behind one report that /app/manage looks wrong when the
window is narrow.

The stat cards were being laid out by the wrong rule at every width. Two
different components both claimed `.stat-grid`: the dashboard card strip that
holds .stat-card children, and the detail-pane StatGrid the split views
introduced further down App.css. Being later, the second won every shared
property, so the cards got its 120px columns and its 1px hairline gap in place
of their own 180px columns and spacing-md. Four cards were packed onto a row
that fits two, labels wrapped to three lines and clipped, and the icon crowded
the value. Renamed the strip to `.stat-cards`, after the children it actually
holds, which also removes the mismatch of a `.stat-grid` container full of
`.stat-card`s. The split-view component keeps `.stat-grid` and its BEM parts.

The expanded console rail had no bounded height. Thirteen destinations stacked
in one column is taller than a phone, so opening the menu pushed the page's own
heading past the fold: the menu replaced the page rather than annotating it.
Capped at 55vh with internal scrolling below 768px, so the content behind stays
reachable.

Both are asserted on behaviour rather than markup: no stat-card label may be
clipped, the card gap must not be the detail pane's hairline, and expanding the
rail must leave the page heading on screen.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): retemper the palette to localai.io and add the lane primitive

The token half of the style transfer, plus the shared list idiom the two
overviews had each grown their own copy of.

theme.css moves from Nord to the website's palette, variable names preserved so
every consumer moves with it: ground #13171f -> #0d1117, accent frost cyan
#88c0d0 -> action blue #4f8cff, success sage -> mint #56d6a4, warning -> the
amber #f1b95d the site spends only on the thing asking for a decision. Eyebrows
go mint. Dividers become an opaque #29384a hairline rather than alpha over a
varying surface, which is what makes stacked surfaces read crisply on the site.

Light is derived, not inverted. The site ships one theme and never had to
answer this, but the app does: blue darkens to #2f62d8, mint to #0d8b60 and
amber to #8a5d0b, all clearing 4.5:1 on a cool paper ground, where the
dark-mode values sit near 2:1. Same three roles, different values.

Three files restate the palette because CSS variables cannot reach them:
cmTheme.js (the whole CodeMirror theme), VoiceVisualizer and WaveformPlayer
(canvas). Left alone they would have quietly kept the app half-Nord.

The `.lane` primitive replaces the near-identical row CSS that OperateOverview
and StudioOverview had each written: a full-bleed row on a hairline that insets
on hover, with no card and no shadow. Callers supply only the column template.
Both pages now use it, along with `.lane-head` for section rhythm and a
`.page-pad` container for top-level pages outside a console shell — without
which Studio sat flush against the sidebar with its eyebrow clipped.

Studio's tab strip wraps rather than running off the edge at narrow widths.

Full e2e suite: 386 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): put Home's resident models on lanes and give the footer one line

Home's status line was three chips saying a thing was true. It now reports
figures: how many models are resident, how many nodes are healthy, what share
of memory is in use, set in tabular monospace so the digits line up. A chip
answers whether; a figure answers how much, which is what someone opening the
page at a glance is after.

Resident models move from status chips to lanes, with the id set in a new
`.lane__name--id` because an id is something you might type or paste and the UI
face makes it read as a label. /api/system-information carries only the id, so
there is deliberately no backend or memory column: inventing one would mean a
server change this does not make.

The footer was three centred rows and cost the bottom sixth of every page for
chrome. It is one line now, version left and links right, wrapping to centred
when the viewport is too narrow to hold both. Every link it had, it keeps.

Full e2e suite: 392 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): correct three contrast failures and stop a guaranteed-404 poll

A contrast audit of the new palette found three values below WCAG AA, one of
which the previous commit message claimed was fine:

- White on the #4f8cff button is 3.22:1, which is large-text only. The website
  does exactly this, but a button label in an app is not large text, so the
  label goes to dark ink at 5.88:1. Light mode keeps white, which is 5.44:1 on
  its darker blue.
- Light-mode success was 4.08:1 on paper, not the 4.5 claimed. Darkened to
  #0a734f, 5.56:1.
- Nord red was already 4.28:1 on raised surfaces, a pre-existing miss carried
  over unexamined. Lifted to #c96f78, 5.02:1.

Lanes gain the two states they were missing: a 44px target on coarse pointers,
matching what EntityRail already does so the two list idioms feel the same
under a thumb, and a reduced-motion variant that keeps the background feedback
while dropping the hover inset, which is a position change.

The Operate summary no longer asks for /api/nodes on a single-node install. The
cluster API answers 503 when distributed mode is off, so it was a guaranteed
miss every fifteen seconds; it is now gated on useDistributedMode, the same
condition the rail already uses for the Nodes entry.

Full e2e suite: 392 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): restore the gap between overview blocks, and stop claiming zero nodes

Two defects a design review surfaced.

`.lane-head:first-child { margin-top: 0 }` was meant to stop the first block on
a page carrying a top margin. But every <section> makes its lane-head a first
child, so the reset applied to all of them and the gap between blocks vanished:
"Sections" sat flush against the attention row above it. The header supplies its
own bottom margin, so a uniform top margin is correct everywhere.

The Cluster summary read "0 nodes" on a single-node install, which looks like a
fault when the cluster API is simply switched off. It now says "Single node".

Full e2e suite: 392 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): open dark by default, and stop clipping the collapsed sidebar footer

Dark is the identity rather than a preference: localai.io ships one theme and
it is this one, so an install should look like LocalAI before anyone has chosen
anything. The OS setting no longer selects light on first load. The toggle
still does, and a stored choice wins forever after, which the tests assert
both ways.

The collapsed sidebar footer stacked its controls but kept the expanded row's
inline padding, so their edges were clipped against the 51px rail.

Full e2e suite: 394 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(api): count traces server-side and give the Operate overview real totals

The overview's headline block had no source. /api/traces returns the trace
list, so "37 errors in 24h" meant fetching every buffered exchange to count it
in the browser — waste that grows with the buffer, to produce three integers.

Adds GET /api/traces/summary: totals, failures, p95 and a bucketed series for
sparklines, over a window that defaults to 24 hours and is capped at a week.

Deliberate calls, each with a spec:
- A 4xx is the caller getting it wrong, not the installation being unhealthy,
  so only 5xx and transport errors count as failures.
- p95 is a nearest-rank percentile rather than the slowest request, which is
  what a max would report and what makes latency panels lie.
- Buckets are oldest-first so a sparkline reads left to right, and the slice is
  never nil: nil serialises as null and breaks .map() on the other side, which
  is a silent runtime error rather than an empty chart.
- Exchanges outside the window are not counted at all.

The route is registered before /api/traces/:id so "summary" is not captured as
a trace ID.

On the client, Traces and Usage gain the rail signals they were shipped
without, the Observability section summary now states counts instead of listing
its destinations, and an installation that has served nothing says so rather
than showing three zeroes dressed as telemetry.

Sparkline is a bare stroke with an emphasised endpoint and no axes: the figure
above it already states the value, so its only job is the shape.

Go: 185 middleware specs pass. Full e2e suite: 396 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): stop the memory chart calling a trade-off an error

The VRAM-by-context chart rendered any build over the limit in error red, and
escalated the verdict to the error tone as soon as two context sizes crossed
it. But an over-limit build still installs — #11288 keeps a test on exactly
that — so red overstates what is happening. A model that fits at 32k and not
64k is a trade-off, not a fault.

Over-limit bars and the limit line now use the warning tone, which is the
constraint colour used everywhere else in this branch: know what you are doing,
not you may not. The error tone is reserved for "fits nowhere", where the model
genuinely cannot run on this host.

Full e2e suite: 397 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): give the new surfaces orchestrated motion

Uses the reveal system already in the codebase rather than adding a library:
pageReveal, .reveal-stagger and staggerStyle() were built for exactly this, and
anime.js would be ~17KB duplicating four lines of CSS for list reveals.

The overview's headline figures, attention rows and section lanes stagger in,
as do Studio's modality lanes and recent outputs, so a page assembles in the
order it is read instead of appearing all at once.

Two additions beyond stagger. Rail signals transition on opacity when a poll
lands, so a number changing reads as an update rather than a jump cut, and it
stays on the compositor so it cannot reflow the rail. The attention block
animates its left edge in — the one thing on the page that should announce
itself, and on the border rather than the text so nothing moves under a reader.

Both are dropped entirely under prefers-reduced-motion, alongside the lane
hover inset already handled.

Full e2e suite: 397 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): put the generators on a hairline field stack and record the request

The workbench treatment from the mocks, applied where it costs least: both
changes land on shared surfaces, so all six generators get them at once rather
than drifting apart page by page.

The control column stops being a shadowed card of boxed groups and becomes a
hairline field stack — the panel is the page's left half, not an object
floating on it — with uppercase micro-labels matching the eyebrow treatment
used elsewhere. Because .media-controls is shared, Images, Video, 3D, Speech,
Sound and Audio FX all move together.

RequestPanel shows the request the form actually built, with a copy-as-curl.
LocalAI is API-first and Studio is the best place in the app to teach its own
endpoints: the form stops being a black box, and a result worth keeping can be
reproduced from a shell without reverse-engineering which fields the page sent.
It records what was sent rather than what the form currently holds, and renders
nothing until a request has been made — a panel describing a request nobody
made is a tutorial, not a record. Wired into Images and Speech.

Full e2e suite: 401 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): make Chat a transcript instead of a bubble thread

Rounded, filled, asymmetric bubbles fight a system built on hairlines, and they
carry the speaker in shape and side rather than in words. The assistant side
had already given up its bubble; this finishes the job.

Both roles now run full width down one column, separated by a rule, each with a
mono role label. The user turn keeps a left edge in the action tone so the two
are still told apart at a glance, without a fill or a corner radius. The
avatars go: the accent and the label carry the speaker, so the glyph was
decoration once neither side had a bubble.

Saying who is speaking in words rather than in geometry is also what survives
being read aloud, printed, or looked at by someone who cannot pick the sides
apart by colour.

Full e2e suite: 404 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* feat(ui): dress the API reference in LocalAI's palette

The Swagger page was the last surface still shipping in someone else's colours,
which is conspicuous now that everything it links from is dark.

Swagger UI has no theming hook, so rather than fork it we serve our own index
ahead of the library's wildcard and restate the palette over its stylesheet.
The library's own bundle and assets are still what load, so a swagger-ui
upgrade cannot silently break the page — this is a skin, not a fork.

Two things needed real care. Swagger tints the entire operation row per method
via .opblock.opblock-post and friends, so the palette had to match that
specificity rather than reach for !important; the method now lives on one edge
instead of washing across the row, because a page where every row is a status
colour has no status colour left. And the filled method chip put white on pale
green, which was the least readable thing on the page — it is an outlined mono
chip now, carrying the method in its border and text.

Palette values are copied from theme.css rather than referenced: this page is
served by Go and never sees the app's CSS. The comment says so, and says to
keep them in step.

Go: routes and middleware suites pass. Full e2e suite: 405 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash]

* fix(ui): make tall split-view pages reachable, repair the Agents header, scale titles

Three things found by actually using the app rather than measuring it.

**Host was unusable.** The shell above a split view is overflow:hidden so the
document cannot grow, which left anything taller than the viewport simply
unreachable — and Host stacks a resources card, four stat cards and a tab bar
above its split, so the bottom of the pane fell off at every window height with
nothing to scroll. Every sweep I ran for this was horizontal, which is why it
kept coming back clean.

The page now scrolls inside the pinned shell. The pane keeps its own scroller:
letting it grow instead pushes the document taller and stretches the rail to
match, which is the regression e2e/discover-height.spec.js exists to catch, and
which the first version of this fix duly caused.

**The Agents header controls were unstyled** — "Create Agent" was rendering
with the browser's default chrome. The markup had been mangled at some point:
six unrelated classes merged into one string on the link, and the label and
button left with none at all and empty icons. Repaired, with the inline flex
replaced by a shared .header-actions class.

**Page titles take the editorial scale from the site**: larger, tracked at
-0.04em, on a line height near 1, so a two-word title reads as a statement
rather than a label. The typeface is unchanged — DESIGN.md keeps the existing
type system — so the whole difference is scale, tracking and leading, which is
where the site gets its voice from. This was the biggest reason the running app
still did not look like the mocks.

Full e2e suite: 404 passed, 4 skipped.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
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 00:16:08 +02:00
mudler's LocalAI [bot]
b89b0f73e5 chore(model-gallery): ⬆️ update checksum (#11306)
⬆️ 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-02 22:59:45 +02:00
mudler's LocalAI [bot]
45cd47cb99 chore: ⬆️ Update ikawrakow/ik_llama.cpp to cb9147fd0d9c08a9a84eee5ac405a73f4e10e3e1 (#11300)
⬆️ 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-02 22:59:17 +02:00
mudler's LocalAI [bot]
1aa97381f3 perf(gallery): warm variant descriptions alongside VRAM estimates (#11297)
Follow-up to #11288, which warmed the VRAM estimate caches at startup and left
the variant picker paying its own way.

Describing an entry's variants probes the weight files of every build it
offers, so the first time a model is opened costs 1.2-1.9s against a cold
cache. That is the same cost as an estimate wearing a different hat, and it
lands in the same caches underneath, so it belongs in the same pass rather than
in a second mechanism.

The warm-up now describes variants for the entries it walks. Entries that
declare none cost nothing: the call is gated on HasVariants rather than
attempted and discarded. The host resolve env is derived once for the run,
since it describes the machine rather than the entry.

Failure handling matches the estimate half. An entry whose variants cannot be
described is logged at debug and skipped, and the estimate for that same entry
is unaffected, because neither half is allowed to fail the other.

Measured against a live instance with 1,595 models, first ever call to
/api/models/variants/:id after a cold boot:

  before   1.2-1.9s
  after    2ms

The warm-up's own cost barely moves: 3m0s to 3m19s for 300 entries, of which
40 declared variants. It stays bounded by the same knobs, and
LOCALAI_VRAM_WARM_LIMIT=0 still turns the whole thing off.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-02 19:42:54 +02:00
mudler's LocalAI [bot]
74b7ea2829 feat(ui): replace the gallery and inventory tables with a rail and a detail pane (#11288)
* feat(ui): rename the Install Models nav entry to Discover

"Install Models" named the action rather than the destination, and it was
the only multi-word entry in a rail of one-word ones (Home, Chat, Studio,
Talk, Build, Operate). A bare "Models" was the obvious fix but it collides
with the installed-models view under Host, which is a different page for a
different job.

"Discover" keeps the rhythm and says what the page is for. The icon moves
from a download arrow to a compass for the same reason: the page is browsed
before it is installed from.

Translated in all seven locales rather than left to fall back, so a locale
switch does not leave the entry in English.

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

* feat(ui): replace the gallery table with a rail and a detail pane

The eight-column table was not the real problem; the click-to-expand row
underneath it was. Variants, files and a VRAM estimate never fitted inside a
<tr>, so they were pushed into a drawer that could hold one model at a time,
could not be linked to, and had no room to say anything useful.

The gallery is now a rail to scan and a pane that answers. The pane has two
states and no third: with nothing selected it is the discovery page, and with
a model selected it is that model's detail. Selection lives in the URL, so a
model is linkable and Back steps out of the detail instead of off the page.

The rail groups by capability while browsing and flattens to results the
moment a term is typed. That is a rule rather than a toggle: once someone has
said what they are looking for, the buckets are between them and the answer,
and making the user choose would be handing them our problem.

The detail pane plots VRAM against context length with the host's own limit
drawn across it. This is new information, not a restyle. A single number
invites "so will it run?", and the honest answer is usually "yes, up to a 32k
context", which is a shape rather than a number. The estimates were already
fetched for every context size, so it costs no new request. Backends that
take no context length say so instead of being given a meaningless chart, and
a host with no GPU gets no chart at all rather than bars with nothing to
compare against.

The split-button variant menu goes with the actions column. The pane lists
every build with its backend, quantization, size, fit and a details
disclosure, each installable, which is what the dropdown was a cramped
substitute for. Its tests move onto that list; the three contracts it alone
carried (fetch-once caching, the loading state, an unfit build staying
installable) are backfilled against the pane.

RecommendedModels moves inside the pane, where it has the width to argue for
a model instead of listing one, and keeps its own dismissal and collapse.

Rail entries carry no description. Two lines is the budget and the second is
better spent on whether the thing will run; the stripped-Markdown contract
moves to the pane's lede, tooltip included.

e2e: 123 passing across models-gallery, navigation, recommended-panel,
model-artifact-operation, operations-strip and page-render-smoke. Inline
styles in Models.jsx drop from 82 to 41.

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

* refactor(ui): extract the split view into shared components

Discover shipped its rail, pane and detail header as private functions inside
Models.jsx. Backends and Host have the same defect and want the same shape, so
leaving them there guarantees three rails that drift.

SplitView, EntityRail, DetailHeader and StatGrid now live under
components/split/. EntityRail is deliberately data-driven: a surface maps its
own entity onto { id, name, icon, meta, stripe, groupId } and keeps its
vocabulary to itself, which is what stops the rail learning about models,
backends and loaded state all at once.

The CSS moves with it. What was .discover__rail is .entity-rail, .discover__
pane is .split-view__pane and so on, because a class named after one page is a
lie on the next two. Only what is genuinely Discover's stays behind the old
prefix: the shelves, the hero and the VRAM-by-context chart.

Two additions the shared rail needs and Discover did not: a state stripe, for
surfaces read by condition before they are read by name, and an empty label.
Discover passes neither.

No behaviour change. e2e 100 passing across models-gallery, navigation and
models-recommended-panel.

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

* feat(ui): put the backend gallery on the split view

Same defect as the model gallery, so the same shape: a seven-column table over
a click-to-expand row that was the only place the repository, licence, tags and
links could go.

The rail groups backends by the use case they serve, sharing Discover's
taxonomy on purpose: a backend is the runtime a use case needs, so "vision"
ought to mean the same thing one level down. It flattens on a query for the
same reason it does on Discover.

The zero state is the one real departure. A backend's fitness is not free
memory, it is the accelerator and platform it was built for, so the pane leads
with what this host is, then what is not installed yet, then whether anything
installed has gone stale. The table listed 37 runtimes and left "which of these
can even run here" entirely to the reader.

Distribution moves into the pane, which is the one thing a row could never
carry: which nodes hold a copy and which do not, with the install-on-more
control next to it rather than squeezed against a chip.

The distributed and target-node action logic is unchanged, including the guard
that keeps a hardware-specific build off the fan-out path. The split-button
popover loses its per-row anchoring because there are no rows; one pane, one
anchor.

Selection lives in ?backend=, preserving the ?target= scope rather than
clobbering it.

e2e: 139 passing across models-gallery, navigation, backends-management,
models-recommended-panel, nodes-per-node-backend-actions, page-render-smoke,
operations-strip and model-artifact-operation. The backends spec gains six
split-view tests; its three description-cell tests move onto the pane lede.

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

* feat(ui): put the Host inventory on the split view

The last of the three surfaces, and the one that is not a catalog. Both tabs
had the same click-to-expand row, so the shell transfers; what does not
transfer is the zero state, because there is nothing to discover in your own
inventory.

With nothing selected the pane reports what is happening: how many models are
loaded, what failed, what has an update, and which models are holding VRAM
right now. Every number was already on the page. None of them had been
assembled into one statement, so "what is going on" was a question the tabs
could not answer however long you looked at them.

The rail buckets by state rather than capability - Running, Idle, Disabled for
models; Update available, Installed for backends - which is the opposite of the
galleries and deliberately so: nobody opens Host wondering which of their
models does vision. Entries carry a state stripe for the same reason.

Load and Stop are promoted out of the kebab, because that is what an operator
came for; the rest stays behind the menu rather than diluting it. Adopted,
pinned and alias badges follow the model into the pane: they are facts about
the thing, not about its state, and the rail line is spent on state.

Deliberately NOT done: folding the two tabs into one rail, as the mock had it.
It costs five URL parameters, the manage-tab localStorage key and the
stat-card shortcuts, all of which are live deep-links today. The tabs stay as
the group selector; merging them is a follow-up with its own migration.

e2e: full suite 355 passing. New host-split-view spec; alias-template,
manage-logs-link, manage-action-menu-position and model-editor-back-nav move
off `.table` and the row kebab onto the rail and the pane.

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

* polish(ui): accessibility and consistency pass over the three split views

Findings from a pass over what the previous four commits actually shipped,
rather than what they were supposed to.

The rail was not a listbox. ARIA lets a listbox contain options and groups,
and nothing else, but each group's collapse control is a button that has to
sit inside the scroller with the entries it folds. It is now a labelled group
of buttons, which is the honest description; selection is announced with
aria-current and the arrow keys are unaffected.

Every entry was its own tab stop, so tabbing past a forty-entry rail to reach
the pane took forty keystrokes. Roving tabindex makes the rail one stop, and
arrowing now moves focus with the selection instead of leaving it behind on an
entry Tab can no longer reach.

The rail rounds its corners with overflow:hidden, which was clipping the focus
ring off the first and last entries entirely. Inset outlines fix it.

A 30px row is fine under a mouse and too small under a thumb, so coarse
pointers get a 44px target without costing density on a desktop.

One slot said three different things: "9 models loaded" on Discover, "12
loaded" on Backends, "3 of 9" on Host. All three lists are a page of a larger
set, so all three now say so the same way.

Also removed: an emptyLabel prop on EntityRail that nothing passed, its dead
CSS rule, and MODELS_COLSPAN and ResourceRowDesc, which died with the tables.

e2e: full suite 355 passing.

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

* fix(ui): correct three defects only a real gallery exposed

Running the branch against a live instance with 1,595 models and 1,017
backends, rather than against mocked fixtures, surfaced three things the e2e
suite could not.

Grouping did nothing. The rails matched on the use-case keys the filter chips
send (`chat`, `tts`, `transcript`), but those are a server-side vocabulary the
handler maps onto entries. What entries actually carry is free-form and
inconsistent: models come back tagged `llm`, `gguf`, `vision`, `coding`, and
backends `LLM`, `text-to-text`, `audio-transcription`. Nothing matched, so
every model landed in "Everything else" and the feature was decorative.

Grouping now lives in utils/entityGroups.js, shared by both galleries, matching
case-insensitively against the vocabulary the API really uses, with the entry's
backend as a fallback signal - a backend named `whisper` is a speech backend
whatever its tags say. Order is specific before general and that is
load-bearing: a vision model is tagged `llm` too, so testing text first would
swallow it.

The zero state claimed GPU memory on a machine with no GPU. The resources
endpoint reports system RAM in the same field when gpu_count is 0, so the hero
read "84.4 GB of GPU memory" next to the recommendations panel correctly
saying "No GPU detected". The number was never wrong, only its label; it now
says system memory unless a GPU is actually present.

The page title still said "Install Models" under a nav entry saying Discover.

Also: the keyboard test named the model it expected to arrive at, which made it
a hostage of the grouping table and broke the moment the buckets were fixed. It
now asserts that the selection moves and returns.

e2e: full suite 355 passing.

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

* fix(ui): the filters and the rail were fighting over the same job

Four things you find odd on Discover, and they turn out to be one mistake seen
from four sides.

The rail grouped the current page. The listing is paginated at nine rows, so
those bucket headers described nine entries out of 1,595, and turning a page
reshuffled the sections under the reader. The structure was never stable
because it was computed over the wrong set.

The chips were redundant for the same reason, seen from the other side. They
send tag= and filter all 1,595 server-side. The rail grouped nine of them
client-side by the same axis. Two controls for one job, and the weaker one was
the one this branch added, so it goes. Grouping stays only on Host, where the
list is complete, local, and bucketed by state rather than capability.

The search bar felt odd because it sat in a full-width band while the thing it
narrowed was a 290px rail below and to the left. The whole band now lives in
the rail column: search, backend, use cases, refinements, then the list it
narrows. One column to say what you want, one to show what you got. Nineteen
chips do not fit at that width, so they fold into a disclosure that states the
selection. A disclosure and not a popover, deliberately: picking use cases is
multi-select and interleaves with the backend select and the toggles below,
and a popover dismisses itself the moment you touch either.

The header held two counts and two buttons at arm's length from all of it. The
counts were the third statement of the same number on one screen, after the
rail's "9 of 1,247" and the pane's own headline, so they go. The buttons move
into the pane's zero state, which is the surface that answers "what do I do
here".

Also: the two first-run empty states wore .loading-center, which is
display:flex in the default row direction because it exists to centre one
spinner. With four children that put the icon, the heading, the sentence and
the buttons on a single line with no gap. They are now a proper full-height
empty state.

e2e: full suite 353 passing. Grouping tests are replaced by ones asserting the
rail stays flat; chip tests open the disclosure first; two filter-layout tests
that asserted the old three-band arrangement now assert the column.

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

* polish(ui): make Discover a full-height view, group the chips, name the refinements

Four things, all of them the same complaint: the page read as a document with
controls scattered on it rather than as one view.

The header is fused. A title block with its own padding, a subtitle and two
counts made the split view look like an attachment to a document that happened
to sit below it. It is now a slim bar carrying the title, the count and the two
page-level actions, and the split fills the rest of the window. Rail and pane
scroll independently, so the filters and the pane's headline stay put while a
long list moves under them.

The chips group. Nineteen in a flat row is a lot to scan even behind a
disclosure, and they already belong to the four families the rest of the UI
speaks, so they are bucketed by those. "All" sits on its own above them without
a heading, because it is a reset rather than a use case.

The refinements stop looking dumped. When the band became a column they were
three controls left where they landed; they now read as a named section with
one control per row.

The zero state suggests again. It had decayed into a "Browsing / 9 of 1,247 /
select a model" line that restated the count for the third time on one screen.
It now offers the four use cases as tiles that set the filter, which is the
shelf idea from the mock without inventing curation or paying for a second
fetch.

Two bugs found by looking at it rather than at the tests: the disclosure was
clamped to 190px, which cut it off partway through its third section so two of
the five never appeared at all; and the creation actions rendered twice, once
in the new bar and once in the pane hero a few pixels away.

e2e: full suite 353 passing. The chip-row test now holds its contract across
the per-family rows rather than a single one, and additionally asserts every
family is present and non-empty.

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

* fix(ui): pin the split view's height so a long detail scrolls the pane

Selecting a model with a long description grew the whole page and dragged the
rail down with it, which is the opposite of what "full height" was supposed to
buy.

The flex chain was right and the ceiling was missing. .app-layout and
.main-content are min-height:100dvh, which is a floor: flex distributes free
space but nothing caps growth, so a pane taller than the viewport expanded the
column, the document scrolled, and the rail stretched to match. height:100% on
the pane then resolved against an auto-height parent and did nothing.

The chat route already solves this by pinning .main-content to 100dvh. The
same treatment now applies to any route containing a .page--app, selected with
:has() so the shell does not have to learn which pages happen to be split
views. Below the stacking breakpoint the pin is lifted, because two stacked
halves in two short scrollers is worse than a page that scrolls.

Measured on a live instance: document height stays at the viewport across
selection (950px either side) and the pane overflows internally instead.

Adds discover-height.spec.js, which asserts the page height and the rail height
are unchanged by selection and that the pane is the thing that scrolls. The
existing specs could not have caught this: they mock short descriptions, and
the bug only appears when the pane has more content than the viewport holds.

e2e: full suite 355 passing.

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

* feat(ui): give Backends and Host the full-height view, and fix the Update button

Backends now matches Discover: the header fuses into a slim bar carrying the
title, the count and the page-level actions, the filters move into the rail
column where they narrow the rail and nothing else, and the split fills the
window. Its seven chips fit at rail width, so unlike Discover's nineteen they
need no disclosure. Host gets the bar and the height; its resource monitor,
summary cards and tabs stay above the split, because those are read once while
the rail and the pane are worked in.

Two things the height change surfaced.

The console layout is a flex row with align-items:flex-start, so its body sizes
to content. Right for the pages it was built for, wrong for a split view, which
needs a ceiling to scroll inside: without it the Backends rail ran past the
viewport and over the footer. Pinned with :has() so only split-view routes are
affected.

The filters vanished when nothing matched. Both galleries swapped the whole
shell for an empty state, which took the search box and the chips with it, so
the page said "try adjusting your search or filters" while offering neither.
The shell now stays and the empty state moves into the pane.

Also fixes the Update control on Host, which had no className at all and
rendered as bare text, next to a status span that had picked up btn classes and
two copies of `fas` and so rendered as a button you cannot press. They have
swapped appearances back.

e2e: full suite 355 passing. The render-smoke selector learns .view-bar__title,
since the pages it checks no longer all use PageHeader.

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

* fix(ui): keep the view mounted while searching, and bring rail grouping back

Searching replaced the whole view with a loader. The search box lives in the
rail column, so every debounced refetch unmounted the field being typed into
and dropped its focus with it. The list, the filters and the pane went too.

The shell now stays and the rail says it is busy: a sweep bar under its header
and the stale list dimmed, so the eye knows the answer is being replaced
without losing its place. A cold start still gets the skeleton, because there
is nothing to keep.

The condition for that is "nothing has loaded yet", not "the list is empty".
Those differ exactly when someone is editing a query that matched nothing, and
getting it wrong there would unmount the view on the keystroke after a
no-results search - the worst possible moment.

Grouping comes back on both galleries. It was removed because nine rows could
not fill five buckets, so a page turn rebuilt the rail's whole structure. That
was a symptom of the page size rather than of grouping: the rail now asks for
30 rows instead of 9 (Backends 60 instead of 21), which is enough for the
sections to read as structure and turns five times fewer pages. The order of
the sections is fixed, so what changes between pages is membership, not
arrangement.

Grouped while browsing, flat while searching, as before: once a term is typed
the buckets stand between the reader and the answer.

Also gives GalleryLoader a class and a testid instead of six inline style
declarations on a bare div, which is why nothing could select it.

e2e: full suite 359 passing, including a new spec asserting the search box
keeps its focus and its value across a refetch, and that a cold start still
shows the skeleton.

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

* perf(gallery): stop invalidating the VRAM estimate caches on every request

Searching or turning a page felt slow. It was not the search and not the
listing: /api/models answers in 3-9ms. It was the VRAM estimate, which the
gallery asks for once per row, and which took ~2.3s every single time however
often the same model was asked about.

pkg/vram already caches what makes that expensive - the remote content-length
probes, the GGUF metadata reads and the HF repo sizes. Those caches key on a
gallery generation counter, and AvailableGalleryModelsCached triggered a
background refresh on every call, with each refresh bumping the counter. One
page view is one listing request plus thirty estimate requests, each of which
re-read the gallery and started another refresh, so the generation moved
constantly and every cache entry was stale before it could ever be read. The
caches were dead in production.

Three changes, each doing one thing:

A refresh interval. The cached list is still served immediately; this only
decides how often re-fetching from upstream is worth starting. Five minutes,
as a package variable so tests can drive it without waiting.

A generation bump only when the gallery actually changed. An unchanged gallery
re-fetched on schedule must not throw away work that is still valid, which is
the difference between an estimate costing nothing and costing a network round
trip.

A separate "loaded" flag. The cache engaged on `cached != nil`, so a gallery
that legitimately holds nothing read as never-loaded and took the blocking path
on every call, bumping the generation each time. Found by the test for the
interval, which could not pass while this was true.

Measured against a live instance with 1,595 models:

  one estimate, repeated     2.3s  -> 2ms
  a page of 30, in parallel  10s   -> 0.04s

A first, genuinely unseen model still costs its remote probe. That is inherent;
what changed is that it is now paid once per model per gallery version rather
than once per request.

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

* perf(gallery): warm VRAM estimates at startup, and stop the UI waiting on them

Two halves of the same complaint: the gallery stalls on VRAM estimation.

Server side, the estimates are now warmed in the background at startup.
Estimating an entry nobody has asked about costs a remote probe of its weight
files, and the gallery needs one per row, so the first visitor was paying for
the whole page. The warm-up walks the gallery in the order the UI lists it, so
the first page is ready before anyone reaches it.

It is bounded and it never blocks: 300 entries at 4 at a time by default, on
its own goroutine, stopping with the server's context. Warming the whole
gallery would be thousands of probes on every boot, which is rude to the
upstream and slow to finish; warming nothing leaves the first page paying two
seconds a row. Anything past the limit still warms itself on first view.
LOCALAI_VRAM_WARM_LIMIT=0 turns it off for an air-gapped host,
LOCALAI_VRAM_WARM_CONCURRENCY=1 slows it for a metered link.

Client side, the page no longer waits on estimates it does not need yet. It
fired one request per row at once; a browser allows about six connections per
host, so thirty estimates took every slot and the request behind a click - the
variant list, an install - queued behind work nobody asked for. That is the
freeze: the list was already usable, and the UI was busy fetching sizes. Four
at a time leaves room for the interactive request to overtake, and a row whose
estimate is still in flight says "sizing…" rather than leaving a blank where a
number will appear.

buildEstimateInput moves to core/gallery as EstimateInput, since the handler
and the warmer both need it.

Measured against 1,595 models, from a cold boot:

  page 1, 30 estimates in parallel   10s -> 0.04s
  full warm-up (299 of 300 entries)  3m, in the background

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

* chore: untrack data/.local_user_id and ignore the runtime data dir

`local-ai run` writes its instance state under ./data when started from the
repo root, which is exactly what a contributor testing a build does. The
identity file ended up committed on this branch by a `git add -A` while
verifying the gallery changes against a live instance.

Anchored, so it matches the runtime directory at the repo root and not a
`data` directory nested inside some package.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-02 19:28:36 +02:00
mudler's LocalAI [bot]
8a80830f33 chore: ⬆️ Update ggml-org/llama.cpp to a7a6d0d269c896218b6c78e0933bd6a17519d3f6 (#11283)
⬆️ 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-02 18:15:49 +02:00
localai-org-maint-bot
7621939028 gallery: add Qwythos 27B variants (#11292)
Add the recommended Q4_K_M build and an MTP-enabled variant with the shared vision projector. Tag the existing Qwythos 9B MTP entry so serving-feature ranking recognizes it.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-02 18:14:40 +02:00
localai-org-maint-bot
cff69a05bf gallery: add Qwen3.6 27B Q8 variant (#11293)
Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-02 18:14:08 +02:00
localai-org-maint-bot
896b4b6785 gallery: add VibeVoice ASR BitNet variants (#11296)
Add the recommended TQ2 build and a smaller aggressive quantization for the CrispASR backend.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-02 18:13:49 +02:00
mudler's LocalAI [bot]
0990be35b7 chore: ⬆️ Update PrismML-Eng/llama.cpp to 9ca265a57f85f2117942490f421f64a226dd9847 (#11280)
⬆️ Update PrismML-Eng/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-02 17:55:32 +02:00
mudler's LocalAI [bot]
d0119bf62c feat(chat): local-ai chat is now a terminal agent (#11291)
* chore(deps): bump cogito to v0.11 ahead of the nib harness

nib is the agent harness that becomes 'local-ai chat'. It requires cogito
v0.11, so pull that bump forward on its own: minimal version selection would
apply it to LocalAI anyway, and both repos use cogito and cogito/clients.
Landing it separately keeps the harness change reviewable.

nib itself is not pinned yet. Nothing in LocalAI imports it, and 'go mod
tidy' runs as a goreleaser before-hook in CI, so an unimported require line
does not survive. It lands with its first importer.

No LocalAI call site needed a change. Both cogito.WithMaxAttempts callers
guard the argument above zero, so v0.11's new clamp is unreachable, and
LocalAI's Multimedia values implement only URL(), so v0.11's new
TypedMultimedia routing treats them as images exactly as v0.10 did.

Binary size (cmd/local-ai): 200,301,381 -> 200,336,045 bytes (+34,664).
A throwaway probe that links nib measured 201,042,243 bytes (+740,862 over
the pre-change baseline).

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): resolve and seed the agent state directory

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): write the agent config atomically and tighten its modes

Replacing config.yaml in place truncated it first, so an interrupted write
would have destroyed the api_key nib keeps in the same file. Stage through a
sibling temp file and rename over the target instead, and match nib's 0700
directory mode.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): probe the endpoint and classify failures

Probe lists what a LocalAI endpoint advertises and separates the two
failures that need different advice: nothing listening, and rejected
credentials.

go-openai reports a rejected key as one of two concrete types depending
on the error body, and both occur against a real LocalAI. The normal
error handler sends an OpenAI error envelope, which arrives as
*openai.APIError; the opaque-errors handler replies with a bare status
and no body, which arrives as *openai.RequestError. Classifying on only
one of them misses half the cases, so the status is read from either.

A cancelled probe is not reported as an unreachable server, because it
learned nothing about the endpoint, and neither is a reply that could
not be parsed, because something did answer. Both would otherwise send
the user off to start a server that may already be running.

The model list is returned verbatim and in server order. LocalAI lists
whatever it finds in the models directory, including stray archives and
dotfiles, and deciding which advertised ids are real belongs to whoever
presents them.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): resolve the model from flag, config, or the server

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(chat): pin that model resolution sorts a copy of the caller's slice

The sort spec asserted only on what the chooser was offered, so replacing the
defensive copy with an in-place sort of req.Available still passed all 37
specs. Assert the input slice's order after the call, so the guarantee cannot
be dropped silently by a later refactor.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): offer to start a server when none is reachable

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): bound the server wait and pin readiness and stop semantics

Set cmd.WaitDelay so a backend subprocess holding the child's stderr pipe
cannot block cmd.Wait forever, which would leave exited unclosed, burn the
whole shutdown grace on a clean exit, and leak the waiter goroutine.

Two test gaps closed alongside it: the readiness spec now counts polls, so
treating 503 as ready is observable, and Stop's single-interrupt contract is
pinned by giving StartedServer interrupt/kill hooks that a spec can count.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(chat): drive Stop through one process interface, hide exec plumbing

Two independent interrupt/kill func fields plus a nil check admitted wirings
no test could distinguish: the pair swapped, so a SIGKILL would strand the
backends SIGINT exists to let local-ai run clean up, or kill left nil, so a
wedged server never escalates. One two-method interface that *os.Process
already satisfies leaves nothing to swap and nothing to nil.

Also translate exec.ErrWaitDelay, whose text names an os/exec struct field,
into what the user can act on. os/exec only substitutes that sentinel when the
process exited without an error of its own, so no exit status is swallowed.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): replace the REPL with the built-in agent

local-ai chat is now the nib agent harness compiled into the binary: tool
use behind an approval gate, sub-agents, MCP, plugins, and skills, all
auto-configured against the local server.

The REPL goes with it. Its model listing and its 401 classifier were
duplicates of the ones Probe now owns, and the classifier was the version
that misreads a bare 401 with no OpenAI error envelope, so keeping either
would leave the package with two divergent answers to the same question.

github.com/mudler/nib lands in go.mod in this commit rather than earlier:
go mod tidy runs as a goreleaser before-hook on every PR, so a require
line with no importer is stripped before it reaches CI.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(chat): split the pre-agent phase out of Run and pin it

Everything before the handoff is testable and nothing after it is: once
app.Run owns the terminal there is no seam left. prepare draws that line,
takes interactivity as a parameter so the prompts can be driven over a
pipe, and hands Run the state dir, the model, and any server it started.

The questions move onto one prompter that owns its buffered reader. A
fresh bufio.Reader per question reads ahead and discards what it buffered,
so the model choice typed behind an answer to "start a server?" was lost
and the next question saw EOF.

choose answers with a list index and refuses an empty offer, so a value
that was never on the list cannot reach ResolveModel, which persists it
and starts every later run against it.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): bound each server check with a deadline

Nothing bounded the model listing, so pointing chat at an address that
accepts the connection and then never replies left the user with no
output and no offer to start a server.

The budget is context.WithTimeout rather than a cancel plus a timer.
Probe deliberately refuses to call an endpoint unreachable on a
context.Canceled, since a caller who gave up learned nothing about the
server, and only honours a deadline. A cancel-based budget therefore
expires as the one error that suppresses ErrUnreachable, exactly for the
hung servers the offer exists to rescue.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): tell the user when their model choice cannot be saved

The choice is meant to be asked for once. When saving it fails the user
is silently asked again on the next run, and the only trace was an
xlog.Warn: the agent runs at log level error, and a --log-level=error run
swallows it entirely.

ModelRequest gains Notify for exactly this class of problem, one that is
worth telling the user about but not worth failing over, and the chat
wiring points it at the same writer the question was asked on.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): stop a session's server when the process is signalled

A server started for the session is stopped by a deferred call, and a
signal skips deferred calls: a SIGTERM between the spawn and the exit
left 'local-ai run' reparented to init with nothing left that knew to
shut it down. Ctrl+C was already safe, but only incidentally, because the
child shares this process' foreground process group.

A signal handler rather than Pdeathsig on the child. Pdeathsig is
Linux-only and, in Go, is delivered when the OS thread that forked exits
rather than when the process does, so it can fire on a healthy parent.
Setpgid would break the Ctrl+C that works today by taking the child out
of the foreground group.

SIGHUP joins SIGINT and SIGTERM: a terminal program whose terminal is
gone has nobody left to talk to. The same context is what cancels the
agent, which nib leaves to its embedder.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): only skip the server checks for work that stays local

Two argument shapes were classified wrongly. Every 'mcp ...' invocation
counted as management, so 'local-ai chat mcp --stdio', which serves the
agent over MCP and needs a model like any other session, was handed an
empty one. And --init, whose shell snippet a user pastes into an rc file
long before any server exists, went the other way: it demanded a running
server to print a static string.

The mcp split is asked of nib's own IsMCPManageSubcommand rather than
restated here, so a verb added upstream cannot drift out of this list.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): exit with the agent's status instead of reporting it twice

nib writes what went wrong to stderr and returns nothing but an exit
code, so returning that error unchanged had main log "Error running the
application error=exit status 1" underneath the message the user had just
read. The refusal to render the full-screen interface into a pipe is the
one they meet in practice: it names --cli, and burying that hides the fix.

ExitCodeError says "already reported, exit with this status". main
honours it and prints nothing more, so a piped or redirected chat still
fails a script the way it should.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* style(chat): route interactive chatter through one writer helper

The prompts and notices all write to a terminal, where a failed write is
not worth failing the session over and the read that follows the question
reports the real problem. say says that once instead of five discarded
error returns.

The command's one-line help comes along: chat is no longer "an
interactive chat session".

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(chat): record why the agent gets this process' streams

Injecting them is what makes nib refuse to draw its full-screen interface
into a pipe and name --cli, instead of rendering onto a terminal the
caller may not own. The tradeoff is worth stating where the wiring is.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): stop the session's server on cancellation, not on the way out

The deferred Stop is reached only if the agent returns, and cancelling
the context does not make it: nib hands the TUI to bubbletea without the
context, so what actually unwinds a running session today is bubbletea's
own SIGINT and SIGTERM handler. SIGHUP has no such backstop, and
registering for it removed the default disposition that used to end the
process outright, so kill -HUP left a live TUI with a cancelled context
and the started server still running.

runSession watches the context alongside the agent and stops the server
the moment it is cancelled, so the guarantee no longer depends on what
the agent does with cancellation. Stop is idempotent, so the deferred
call stays correct and free.

The doc comment on shutdownContext described the mechanism it was
supposed to work by rather than the one that does. Corrected, bubbletea's
handler included.

ResolveModel now checks the chooser's answer against what it offered.
The shipped chooser answers by list index and cannot be wrong, but
ModelChooser is exported, the answer is persisted, and every later run
starts against it, so the invariant belongs at the consumer.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(chat): bump nib to v0.5.1

v0.5.1 carries four fixes that matter to 'local-ai chat':

- --init now names the embedder's command, so the emitted widget invokes
  'local-ai chat' rather than a bare 'nib' the user does not have.
- A piped CLI session that succeeds exits 0 instead of failing with EOF.
- EOF at a tool-approval prompt denies the call rather than approving it,
  and the session exits 3 (app.ExitCodeApprovalNoInput) so a script can tell
  "answered" from "refused to act" without reading stdout. Read-only tools
  are unaffected and still run. ExitStatus already unwraps app.ExitError,
  so the code propagates with no change here.
- RunTUI passes the context to bubbletea and gives up bubbletea's own signal
  handler, which makes shutdownContext the single owner of the signal and
  stops a SIGHUP leaving a wedged TUI behind.

Verified against a live server on 127.0.0.1:8080: the three --init shells,
a piped prompt exiting 0, a denied 'touch' that left no file and exited 3,
a read-only 'ls' that still ran and exited 0, and a SIGHUP that unwound a
TUI running under a pty.

Two comment blocks in run.go described the old TUI behavior and are now
wrong, so they are corrected in the same change. No behavior change: both
shutdownContext and runSession are untouched, and stopping the server on
cancellation is still worth keeping independent of how promptly nib unwinds.

One known gap, not addressed here. The widget --init now emits runs
'output=$(local-ai chat --height 50%)', and runAgent injects Stdout
unconditionally, so under $(...) nib refuses the TUI for a non-terminal
stream. This is the cost the runAgent comment already anticipated, now that
the snippets no longer hardcode standalone nib. Ctrl+Space should not be
documented until that is decided.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): let nib own stdout, so the Ctrl+Space widget works

The widget 'local-ai chat --init' emits runs
'output=$(local-ai chat --height 50%)', which puts a pipe on stdout by
construction. runAgent injected os.Stdout unconditionally, and nib refuses
every mode but --cli when a stream it was handed is not a terminal, so
Ctrl+Space printed "Re-run with --cli to use the injected streams" and
inserted nothing. Verified against a pty before and after.

nib reads a nil stream as "not injected" and falls back to the process
stream, which is how an embedder asks for nib's own behavior. That is what
stdout needs: the interface renders on /dev/tty but writes the chosen
command to stdout even when stdout is a pipe, and that write is the whole
of the shell-capture idiom.

Stdin is deliberately left injected. A piped or redirected stdin really is
ignored by the interface, so the refusal is the honest answer there, and it
is the one users meet: 'echo q | local-ai chat' still says to re-run with
--cli, once, exit 1. Nilling stdin the way stdout is nilled would delete
that silently. Stderr is not gated by nib at all and is unchanged.

One case does change and cannot be kept: 'local-ai chat > out.txt' from a
terminal no longer refuses, because it is indistinguishable from the
widget. It renders on /dev/tty and writes the capture line to the file,
which is what standalone nib does.

The app.Options literal moves into agentOptions so the decision is
reachable from a spec rather than being a detail of a function that takes
the terminal. Both sides of the asymmetry are pinned: reinstating
'Stdout: opts.Out' fails "hands nib nothing for the process stdout", and
nilling stdin fails "hands the process stdin over".

Also rewrites the last comments describing the pre-v0.5.1 behavior.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(chat): say what the stream refusal actually keys on

Two comments still called it the refusal to render the interface "into a
pipe". That was true when both stdin and stdout were injected, but a pipe on
stdout no longer refuses, so the wording now points at precisely the case
that was un-refused to make Ctrl+Space work. Only a stdin that cannot be
read triggers it, and both comments now say so and name the command a user
meets it with, 'echo q | local-ai chat'.

The agentOptions doc also said a "file a caller chose" stays injected and
refused, which reads as though 'local-ai chat > out.txt' still refuses. It
does not: a shell redirect arrives as os.Stdout and is nil-ed like the
widget's pipe, because the two differ only in being a regular file rather
than a FIFO and nib's gate does not look at that. What stays injected is a
writer an in-process caller chose for itself. Says that now, in the doc and
in the spec comment that had the same ambiguity.

Comments only. No behavior change.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: local-ai chat is now the built-in terminal agent

`local-ai chat` was a plain chat prompt and is now an agent that runs
shell commands behind an approval gate, so the pages that described a
REPL were wrong rather than merely thin.

Adds a Terminal agent feature page at /features/terminal-agent covering
the approval gate, piped runs and their exit codes, Ctrl+Space, model
resolution, state directory, and the pass-through management commands
(including the `--yes` caveat that leaves a plugin installed but
disabled in a script).

The three-way "looking for something else" notice becomes four-way and
moves into an agentic-routing shortcode. Four hand-kept copies of the
same paragraph is what produced the drift the new page would otherwise
have added to; the shortcode takes `current=` so each page still marks
itself, and errors the build on a name that is not one of the four.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* website: the agent is in the binary, not a second install

The nib section sold a separate tool you also install, with a GitHub
link as the only way in, which is now the wrong order: the agent ships
compiled into local-ai, and the standalone binary is the second reason
to care rather than the first.

Leads with `local-ai chat`, keeps nib as the SSH-anywhere story, and
adds a docs CTA pointing at the new Terminal agent page. id="nib" is
left alone because localai.io/#nib is linked from outside.

The two credits on the demo clip named nib as the thing that drove the
machine; they now credit the agent in LocalAI, which is the same agent.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* website: fix the exit keys, the plugin warning, and the redirect gap

Three claims on the chat-agent pages that the code does not back.

try-it-out told readers to press Ctrl+D. nib has no Ctrl+D handler: the
full-screen interface quits on Esc or Ctrl+C, and Ctrl+D is only an exit
in --cli, where it arrives as ordinary tty EOF. That sentence had
replaced the removed /exit and /quit text, so the page was left with no
working way to leave a session. Document both modes, since they differ.

The plugin warning said nothing tells you the install stopped short. It
does: the command prints that the plugin was left disabled. What it does
not do is say so in its exit code, which is 0 either way. That is the
part a script cannot work around, and it is the reason to pass --yes.
Overstating it in the paragraph that gives the advice only makes the
advice easier to dismiss.

Redirecting stdout no longer refuses; the interface goes to /dev/tty and
only the yanked command reaches the file. It is what lets the Ctrl+Space
widget capture a command at all, since a redirect and out=$(...) are the
same thing to the stream gate. It was documented nowhere. A non-terminal
stdin is still refused, and the new text says which of the two it is.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): make the CLI flags outrank the agent config file

local-ai chat routed --endpoint, --model, --api-key, --trace-dir and --yolo
through nib's app.Options.Defaults. Defaults are seeds: they sit beneath the
config file, so the file silently undoes them. That made the flags accepted and
inert, and not in an edge case, since EnsureStateDir writes base_url on the
first run and the interactive picker writes model, so from the second run on
the file carried a value for both.

Observed against a live server: with base_url: http://127.0.0.1:9999/v1 in the
config and --endpoint http://127.0.0.1:8080 on the command line, the probe hit
8080 and every agent turn posted to 9999. With model: gemma-4-e2b-it-qat-q4_0
in the config, --model lfm2.5-8b-a1b was ignored on the wire.

nib v0.6.0 adds app.Options.Overrides, applied above the config file and above
the bare environment block. Move the whole block there: all five values are
decisions this invocation already made on the user's behalf, and a flag the
config file can undo is not a flag. Nothing is left in Defaults, because
LocalAI's one genuine seed, the initial base_url, is written into the config
file by EnsureStateDir rather than handed to nib.

Two limits come with the channel and are documented on agentOptions rather than
worked around. An override can only raise a field, since nib cannot tell "set
to the zero value" from "not set", so --yolo can turn approval off but nothing
on the command line turns it back on over an approval_mode: auto in the file.
And nib's own NIB_TRACE_DIR and NIB_YOLO are resolved after the config load and
still outrank these, deliberately, upstream.

The existing spec pinned that the right values reach app.Options, which they
always did, which is exactly why it could not see nib discarding them. The new
specs resolve the config the way app.Run resolves it, against a real config
file that disagrees with every flag, and one asserts Defaults stays empty.

docs/content/features/terminal-agent.md already documented --model as winning
over the saved model; that was false before this change and is true now, so no
docs edit was needed.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): document intentional config file read

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-02 09:23:26 +02:00
mudler's LocalAI [bot]
359bd4850d docs(blog): bring the 4.8 release post up to the final changelog (#11287)
The post was written against the first draft of the release notes, when the
cycle stood at 214 PRs over thirteen days. It closed at 321 PRs over eighteen
days, and three of the larger user-facing changes landed after it was written.

- Correct the counts throughout: 321 PRs, eighteen days, 24 contributors
  (11 first-time), gallery 1,221 to 1,505.
- Add sections for the three new capabilities: 3D generation as a modality
  (Generate3D, FLAG_3D, /v1/3d/generations, trellis2cpp), audio.cpp serving
  six audio endpoints from one process, and the operations bar becoming the
  Activity page.
- Cover the two further hardening fixes (tar hardlink escape, cyclic $ref
  stack overflow) alongside the TRL one.
- Note the Valkey store, systemd socket activation, persistent trace history,
  in-place chat edits, the self-contained SYCL backend and the site split.
- Group the new-engine sections together rather than splitting them across
  the operational ones.

Embeds the existing vllm-race and magpie clips, and adds a 3D generation clip
cut from the demo recording to the conventions in .agents/preparing-a-release.md
(no audio track, 14s, named for the feature). blog.css styled figure img but
not figure video, so a clip in a post rendered outside the card; both selectors
now share the rule.


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-02 00:37:19 +02:00
mudler's LocalAI [bot]
a49f115b0d chore: ⬆️ Update ikawrakow/ik_llama.cpp to 0be97a7a5ad113f33e08729261649ccea2cdc5ff (#11282)
⬆️ 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-01 23:53:14 +02:00
mudler's LocalAI [bot]
0d6b38e709 chore: ⬆️ Update 0xShug0/audio.cpp to 545e29a6f2fde24298cb3b0f07baab4352987ac9 (#11281)
⬆️ 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-01 23:52:58 +02:00
mudler's LocalAI [bot]
bef30732cd chore: ⬆️ Update CrispStrobe/CrispASR to 66ac7843e319b588f5410051c575affd19424fb3 (#11279)
⬆️ 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-01 23:52:44 +02:00
mudler's LocalAI [bot]
5b7ca31bd1 chore(model-gallery): ⬆️ update checksum (#11285)
⬆️ 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-01 23:52:32 +02:00
mudler's LocalAI [bot]
21ecc799e5 fix(qwen3-tts-cpp): hold qwentts.cpp at 35ebe537, upstream master hangs in synthesis (#11286)
tests-qwen3-tts-cpp has been failing on master since 2026-07-31. The suite
loads every component fine and then stops: TTS() never returns from the
native call, so a job that takes ~5 minutes runs into the 20 minute Go test
timeout instead.

    goroutine 74 [syscall, 19 minutes]:
    github.com/ebitengine/purego.RegisterFunc.func4
    qwen3-tts-cpp.(*Qwen3TtsCpp).TTS  goqwen3ttscpp.go:154
    qwen3-tts-cpp.init.func2.4        e2e_test.go:90

Not a flake: reproduced on master and again on an explicit re-run.

Bisected across this cycle's eight qwentts.cpp bumps by their own check:
10832, 10850, 10902, 10964, 11006, 11039 and 11127 all pass in ~5 minutes;
11241 (abab6b3) fails at 1h58m. That PR was merged with this check already
red, which is how the hang reached master.

35ebe537..abab6b3 is three upstream commits, and the only functional one is
26dd8adb, "predictor: unroll the frame into one cgraph and sample in standard
ops", which is consistent with a generation loop that never reaches its stop
condition.

Hold the pin at the last known-good commit. The bump entry is commented out
rather than left in place, because it tracks upstream master and would put
the hang straight back on the next nightly run. Both spots carry a pointer to
the other so the hold is discoverable, and restoring it is uncommenting four
lines once upstream is fixed.


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-01 23:40:35 +02:00
localai-org-maint-bot
9fe1165f61 fix(turboquant): retain CPU variants in GPU builds (#11276)
Select the CPU_ALL_VARIANTS target for x86 GPU images so partial offload uses runtime-selected host kernels. Keep GPU arm64 builds on the portable fallback until their toolchains consistently provide gcc-14.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 16:06:36 +02:00
mudler's LocalAI [bot]
ad2be8a856 chore: ⬆️ Update ggml-org/llama.cpp to 876a4321163249c43ca4e986818fab5ab081f282 (#11177)
* ⬆️ Update ggml-org/llama.cpp

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

* fix(llama-cpp): drop merged MiniMax-M3 patch

The bumped llama.cpp revision includes the MiniMax-M3 parser and template detection, so the carried patch now rejects during backend preparation. Remove the obsolete patch while retaining the independent score-task patch.

Assisted-by: Codex:gpt-5 [systematic-debugging]

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 16:06:13 +02:00
localai-org-maint-bot
3c02d2aa4d gallery: add Inkling Small GGUF variants (#11273)
Add Q4_K_M and IQ2_M sharded llama.cpp entries with the BF16 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-01 14:14:59 +02:00
localai-org-maint-bot
c0a9c42771 gallery: add Fara1.5 9B GGUF variants (#11271)
Add the new 9B Fara computer-use model alongside its existing 27B sibling, with Q4_K_M and Q8_0 llama.cpp variants plus the required vision projector.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 11:49:26 +02:00
localai-org-maint-bot
cedcbf97a9 fix(llama-cpp): retain CPU variants in GPU builds (#11255)
Build the runtime CPU variant set alongside x86 GPU backends so partial offload uses the host's SIMD kernels instead of the scalar fallback. Keep arm64 GPU images on the portable binary until their builders consistently provide gcc-14.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 09:26:23 +02:00
mudler's LocalAI [bot]
7e4a60c701 chore: ⬆️ Update TheTom/llama-cpp-turboquant to 8a891f4b566efdbd3cea92fafee3227a0a267683 (#11258)
⬆️ Update TheTom/llama-cpp-turboquant

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-01 09:25:51 +02:00
Zelys
76927ccde3 fix(utils): reject tar hardlinks that escape the extraction root (#11266)
* fix(utils): reject tar hardlinks that escape the extraction root

ExtractArchive pre-scans archive members and rejects symlinks, but tar
hardlink entries carry a regular file mode and so pass that check.
Header.Linkname was never validated, so an archive could create a link
to a path outside the destination directory.

Validate Linkname with the same path check already applied to member
names. Hardlinks that resolve inside the extraction root still extract,
so ordinary archives are unaffected.

pkg/oci/image.go already resolves tar.TypeLink targets before using
them; this brings the archive extraction path in line with it.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com>

* test(utils): cover hardlink overwrite and in-root hardlinks

The existing hardlink test names a link target two levels above the
extraction root, so its final assertion checked a path the link never
resolved to and could not fail. Point the target one level up instead,
at the path that assertion already names.

Add two cases. The first uses a .tar.gz, where ExtractArchive binds a
Tar config with OverwriteExisting set, and follows the link entry with a
regular entry of the same name. Before the fix that pair linked to a
file outside the root and then truncated it through the link, which the
plain .tar case does not reach. The second extracts a hardlink whose
target is an earlier member of the same archive, covering the claim that
ordinary archives are unaffected.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com>

---------

Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com>
2026-08-01 09:25:35 +02:00
localai-org-maint-bot
fca7ab2df4 fix(gallery): correct Nanbeige 4.2 artifacts (#11269)
Use the case-sensitive Hugging Face filenames and refresh the linked SHA256 values for both gallery variants.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 09:13:59 +02:00
mudler's LocalAI [bot]
04764bbe89 chore(model gallery): 🤖 add 1 new models via gallery agent (#11268)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 09:13:25 +02:00
localai-org-maint-bot
2f3dd404b5 feat(import): route MLX TTS models to mlx-audio (#11267)
Detect text-to-speech MLX repositories during model import and emit a TTS-ready mlx-audio configuration. Expose mlx-audio in the backend preference dropdown for repositories without complete metadata.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 09:12:54 +02:00
mudler's LocalAI [bot]
a7440f032d chore: ⬆️ Update PrismML-Eng/llama.cpp to 4dd165625bb6c020285eec8b342af25cf60233dd (#11259)
⬆️ Update PrismML-Eng/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-01 09:11:48 +02:00
mudler's LocalAI [bot]
4a6cd227a3 chore: ⬆️ Update 0xShug0/audio.cpp to f78227c52736a4792a50aa3f82ead7e7385c891b (#11261)
⬆️ 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-01 01:23:41 +02:00
mudler's LocalAI [bot]
740d8684b5 chore: ⬆️ Update ggml-org/whisper.cpp to 2ca53bb45e38748d07b310eeb36245a7157ac882 (#11263)
⬆️ 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-01 01:23:29 +02:00
mudler's LocalAI [bot]
cb432c4c99 chore: ⬆️ Update CrispStrobe/CrispASR to b5211ac635489049ee8ce86a82d69faa18e8d8da (#11264)
⬆️ 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-01 01:23:18 +02:00
mudler's LocalAI [bot]
a4cd387100 chore: ⬆️ Update localai-org/rf-detr.cpp to 98d0f381b832ef08a608b65c7dd78db066ed8b9a (#11260)
⬆️ Update localai-org/rf-detr.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-01 00:49:42 +02:00
Dimitris Karakasilis
c089caf320 feat(sycl): make the intel llama.cpp backend self-contained on any host (#10991)
* feat(sycl): make the intel llama.cpp backend self-contained on any host

The SYCL backend shipped an incomplete oneAPI runtime AND relied on a
host-provided GPU driver, so it only ran inside the build container. On a
bare host it died with "libze_loader.so.1 / libdnnl.so.3: cannot open
shared object file", and even with the host's Intel driver installed it
SIGSEGV'd during SYCL init when the host driver was built against a newer
glibc than the backend's bundled loader (rolling-release distros).

package_intel_libs now bundles the complete, coherent oneAPI runtime
(the missing MKL ILP64 / sycl_blas / tbb_thread + oneDNN + the dlopen'd
UR adapters, plus a sweep of the backend binaries' own direct deps) and
the Intel GPU userspace driver (libze_intel_gpu + libigdrcl + IGC + gmm)
with its OpenCL ICD manifest, mirroring how package_vulkan_libs bundles
Mesa. run.sh points the Level Zero and OpenCL loaders at the bundled
driver, and install-base-deps.sh installs it in the SYCL build image.
Bundling the driver is safe across kernels because it talks to the host
i915/xe via the stable DRM UAPI (unlike NVIDIA's kernel-locked
userspace).

Validated on Arch (glibc 2.43, i915): the backend loads and runs on an
Iris Xe with no host Intel packages installed.

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

Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>

* fix(sycl): install a driver that exists, and let the user choose their own

The driver install added earlier in this branch asked apt for
intel-level-zero-gpu, which is not a package in Ubuntu 24.04. apt fails
outright on an unknown name, so neither driver was installed, nothing was there
to copy, and the images carried no driver at all.

It now comes from Intel's own repository, which has 25.18 for this Ubuntu
release, against 23.43 from late 2023 in the Ubuntu archive. The archive driver
does not know any card released since, so a machine with a recent Intel GPU
would end up carrying a driver that cannot drive it. Anything that goes wrong
during that install fails the build on purpose: an unreachable repository is a
passing problem that a retry fixes, while quietly carrying a different driver,
or none, is a difference nobody would notice until a user reports an idle GPU.

run.sh used to overwrite whatever driver the user had chosen. Level Zero uses
only the driver it is given, so on a machine with a card too new for the
carried driver, the GPU would go unused with no way back. Both that setting and
the OpenCL one are now left alone when already set, and the docs say how to
point a backend at the machine's own driver.

The OpenCL setting also used to be applied whenever the backend held a driver
list, even when the driver it named had not been copied, which leaves OpenCL
with nothing instead of falling back to the machine's own driver. It now
requires the copied driver to be present, and the packaging leaves out the list
entry of any driver it did not copy. The oneAPI images list a processor-only
OpenCL library, which was being carried with nothing behind it.

Two more corrections in the packaging. The scan for libraries a program is
linked against only looked at files named llama-cpp-*, so turboquant and bonsai,
which are also built for Intel GPUs, were left with the incomplete set of
libraries this branch set out to fix; it now looks at every program in the
directory. And a build that should carry a driver but ends up without one now
says so, which is what a stale prebuilt base image looks like: such a backend
still runs on a machine that has its own driver, so nothing fails and the only
other symptom is a user reporting an idle GPU.

Backends now also ask the driver to report how much graphics memory is free,
without which llama.cpp reads zero on an integrated GPU, since such a chip
shares the system memory instead of having its own. turboquant and bonsai get
the same run.sh handling as llama.cpp.

The driver is only carried by the builds that start through run.sh, because
run.sh is what points Level Zero and OpenCL at it. The Python backends for
Intel GPUs start differently and would never load it, so they keep using the
machine's own driver rather than carrying several hundred megabytes they cannot
use.

Checked in a container on Ubuntu 24.04: the install brings driver 25.18 with
the files where the packaging expects them, an unreachable repository fails the
build, and the copied set resolves on its own once the machine's Intel packages
are moved away.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>

* fix(ci): rebuild every Linux backend when the GPU packaging script changes

scripts/build/package-gpu-libs.sh decides which GPU libraries end up inside an
image. The filter that builds the backend matrix listed it as an input of the
Python images only, so changing it rebuilt no Go and no C++ backend, even
though those run it from their own package.sh. A packaging fix aimed at the
Intel llama.cpp backend could merge and reach no image, which is the same
failure this rule was written to prevent.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>

* fix(sycl): carry only the driver Level Zero uses, not the OpenCL one

llama.cpp reaches an Intel GPU through Level Zero, which hands the driver
programs that are already compiled and so needs only the back end of the
graphics compiler. The OpenCL driver can be handed source code instead, so it
needs the compiler's front end as well, and that arrives with its own copy of
clang. Carrying it cost about 139 MB in every backend built for Intel GPUs, and
took the carried set from 123 MB to 261 MB.

Nothing here takes that path. No LocalAI code selects an OpenCL device, each
backend image holds one backend, and the documentation never described OpenCL
as a way to run models: the only mentions are a stale clblas row in the
BUILD_TYPE table, for a llama.cpp backend that no longer exists and that no
build matrix entry uses, and the sycl-ls troubleshooting hint. Before this
branch the packaging carried the OpenCL loader and adapter but no driver, so
the path could not work in a released image either. There is nobody to keep
working.

The driver list that OpenCL reads is no longer carried, and run.sh no longer
sets OCL_ICD_VENDORS, so OpenCL inside a container keeps using whatever the
image provides rather than being pointed at a directory with no driver in it.

Checked in a container against the real 25.18 driver: the carried set is 123 MB
with nothing unresolved, and Level Zero still reports the GPU with the
machine's own Intel packages moved out of the way. Neither the Level Zero
driver nor the compiler back end names the front end or clang among the
libraries it opens by name, so the leaner set is complete for this path.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>

---------

Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-07-31 23:39:53 +02:00
localai-org-maint-bot
9584377a50 feat(chat): edit saved conversation messages (#11189)
* feat(chat): edit saved conversation messages

Add inline edit, save, and cancel controls for stored user and assistant messages without triggering inference. Preserve structured message attachments and cancel edits when streaming starts.

Assisted-by: Codex:gpt-5

* test(chat): preserve seeded conversation on reload

The saved-message edit test reloads the page to verify persistence, but its init script was replacing localStorage with the original fixture on every navigation. Seed only an empty store so reloads exercise the data written by the application.

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

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-31 23:38:03 +02:00
mudler's LocalAI [bot]
51c9cc1934 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 3f53a059024039358e9fef75b5dc0c99dbcb40f9 (#11262)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 23:37:41 +02:00
localai-org-maint-bot
22e401b43d docs: fix local Hugo working directory (#11183)
Direct repository-root users to the supported make docs target and document the equivalent direct Hugo invocation from docs/.

Fixes #10062

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-31 23:37:27 +02:00
mudler's LocalAI [bot]
11403f4797 chore(model-gallery): ⬆️ update checksum (#11265)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 23:36:46 +02:00
Ettore Di Giacinto
aa5a9c483a fix(website): connect the runtime, the engines and APEX into one thread
The page reads as a list of features with nothing joining them, so two
things did not land.

The engines section never said these are the backends LocalAI loads. The
runtime section describes a core that pulls each engine in on demand, and
the engines section describes engines written from scratch, and nothing on
the page connected the two sentences. Readers were taking parakeet.cpp and
the rest for unrelated side projects by the same people. The lede now says
whose backends they are before it says anything else.

APEX was used as a known term on first appearance, in a section that opened
onto a benchmark table. Nothing said what it is or why it follows the
engines. It now opens by placing itself in the stack: the engine decides how
fast a model runs, the weights decide whether it runs at all, and APEX is
the second of those. Then the numbers.

Also drops "Most backends wrap somebody else's engine. These do not", which
is the machine-written antithesis shape, and fixes a list that broke its own
parallel halfway through.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m]
2026-07-31 21:28:21 +00:00
Ettore Di Giacinto
4b3978dcba chore(website): derive the counters from data, refresh them weekly
The star, fork, contributor and release counts were typed into the templates
by hand, so they only moved when somebody remembered. They had already
drifted: stars read 48,042 against 48,067, forks 4,314 against 4,320, and
contributors 224 against 225.

They move to website/data/stats.yaml, which .github/ci/refresh-site-counters.sh
rewrites from the GitHub API, run weekly by a new workflow. The contributors
and releases endpoints never report a total, so the script asks for one item
per page and reads the count out of the Link header. It refuses to write a
zero or a non-number, which is what a rate-limited or failed call looks like,
and the workflow commits only when a number actually moved. The Discord count
has no API behind it, so the script reads the existing value back and carries
it through.

The engine count was wrong in a second way. The hero said 18, the section
heading said "Eighteen engines", the timeline said "Nineteen engines of our
own", and the /engines/ page derived 19 from the data file. All of them now
derive from that same file, so they cannot disagree again.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m]
2026-07-31 21:23:54 +00:00
localai-org-maint-bot
3f4e446adc gallery: add Qwopus3.6 27B Fusion variants (#11257)
Add Q4_K_M and Q8_0 llama.cpp entries for the newly released Qwopus3.6-27B Fusion reasoning and coding merge, with MTP enabled.

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-07-31 23:05:02 +02:00
Ettore Di Giacinto
e6b235baf2 fix(website): wrap the timeline, run integrations as a reel, fix blog cards
The timeline set six 15rem columns in a flex row with overflow-x:auto, which
needs 90rem and so scrolled sideways on any normal laptop. It is a wrapping
grid now, and the rule that carries the dots moves from the container onto
each item so a wrapped row still gets a line above it. Column gap is zero and
the items carry their own right padding, so the rule stays continuous.

Integrations move from a card grid to a reel. Any single integration is a weak
signal and the whole moving line is the strong one, so the count is doing the
argument. It pauses on hover and on keyboard focus, since the names are links.
The list grows from 8 to 26: Open WebUI, Dify, LibreChat, RAGFlow, Continue,
big-AGI, Nextcloud, Frigate, promptfoo, Mods, TypingMind, baibot, k8sgpt-
operator and others. Each was admitted only after opening that project's own
repository or docs and reading the line that names LocalAI. The ones that
failed that test are listed in the data file so nobody re-adds them.

The blog cards were hand-written, which is how one of them came to advertise
"Porting vLLM to C++", a post that does not exist, and how all three linked to
the blog index instead of an article. They range over the posts now.

The section intro used the "a changelog tells you what moved, these posts show
you what it does" shape, which is the standard machine-written antithesis. It
states what the posts contain instead, including the perplexity regression
that APEX costs, because publishing the price is the actual claim.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m]
2026-07-31 14:55:16 +00:00
Ettore Di Giacinto
0bedc75921 fix(website): rewrite the ecosystem band, drop two false coverage links
The band led with three sentences of hedging and printed a commit count
next to each employer, so a one-commit entry beside a large name read as
weakness rather than as the modest, true claim it was. It now opens on the
contributor count, sets the employers as a sentence instead of a pill wall,
and keeps the caveat to one line. The counts stay in ecosystem.yaml, since
they are the provenance for the list and anyone re-checking it needs them.

Two "coverage" cards were not about this project. The modelslab.com piece
reviews Frikallo/parakeet.cpp, an unrelated project of the same name, and
the snailtext.app benchmark measures Parakeet through ONNX Runtime without
mentioning LocalAI at all. Both are removed, along with the contributor
card that duplicated the band's opening line.

Press was four posts from one vendor, which read as the whole of the
coverage rather than one enthusiastic outlet. SUSE collapses to a single
series entry, and Pulumi, Semaphore and Spectro Cloud join it. Each was
opened and checked against the project before being added. K8sGPT and
LlamaIndex join the integrations; both document LocalAI as a backend.

The quotes move above the lists so the section opens on its strongest
line, which is somebody else's. The hero gains a GitHub call to action,
the APEX collection link was returning 404 and is corrected, and the
footer no longer describes the site as a design mock.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m]
2026-07-31 14:24:26 +00:00
Ettore Di Giacinto
dad4d5956a ci: move lint back to hosted runners, arc image has no make/gcc
Follow-up to 42541dd4f, which routed lint to arc-runner-set. Both of its
jobs failed there in one second (run 30637392862): the runner image has
git, curl, unzip, tar, ldd and python3, but not make, and build-scripts
additionally needs gcc because the packaging-script tests compile a
throwaway binary and inspect it with ldd.

golangci-lint needs make twice over: `make protogen-go` (which also wants
curl + unzip to fetch protoc) and `make lint` itself. So both jobs go back
to ubuntu-latest.

gh-pages.yml stays on arc-runner-set and is unaffected: it uses no make and
no C toolchain, and setup-go / actions-hugo fetch their own toolchains.

The preflight steps stay. They cost about a second on the hosted pool, and
they are what turned this into a one-second named failure instead of an
opaque one midway through a build. When the runner image gains make + gcc,
re-routing is one runs-on line per job. Any such re-route must stay
push-only: lint also runs on pull_request, and fork PRs execute untrusted
code that must not reach a persistent self-hosted runner.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
2026-07-31 14:11:45 +00:00
Ettore Di Giacinto
42541dd4f6 ci: route site deploy and lint to the self-hosted runner
The GitHub-hosted runner pool is shared per ACCOUNT, not per repo, so a
burst in one repo starves every other. On 2026-07-31 it reached zero
scheduled jobs for 35 consecutive minutes with 39 jobs queued, while
arc-runner-set completed 12 jobs without interruption across the same
window. Actions was healthy globally (other public repos were scheduling
normally), so this is an account-level throttle we cannot fix from inside
the workflows, only route around.

Site publishing and lint are small, run on nearly every commit, and gain
nothing from waiting behind a saturated hosted queue, so both move to
arc-runner-set, the label already proven in generate_intel_image.yaml.

lint.yml is routed for PUSH ONLY, and this is the important part: that
workflow also triggers on pull_request, and a fork PR executes untrusted
contributor code. Running that on a persistent self-hosted runner would be
a real compromise vector, so anything that is not a push to mudler/LocalAI
stays on the ephemeral hosted pool. gh-pages.yml needs no such clause: it
triggers only on push-to-master and workflow_dispatch, so it never runs
pull-request code. Both carry a repository guard so forks, which have no
such runner label, fall back to hosted instead of queueing forever.

Neither workflow uses sudo or apt, and both fetch their own toolchains via
setup-go / actions-hugo. A self-hosted image can still be leaner than the
hosted one, so each lint job opens with a preflight that names the missing
tool (curl/unzip/make for protoc and lint; gcc/ldd/python3 for the
packaging-script tests) rather than failing opaquely mid-build. Reverting
is one runs-on expression per job.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
2026-07-31 14:08:49 +00:00
Ankit Aglawe
fb54d0faab gallery: add Parable Claude-Fable-5 agent-trace models (3B/4B/8B) (#10930) 2026-07-31 13:10:48 +02:00
localai-org-maint-bot
314a824039 gallery: consolidate POCKET-35B variants (#11249)
Keep the canonical POCKET-35B family, add its missing Q3_K_M build, and remove the duplicate artifact entries introduced by overlapping gallery additions.

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

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-31 13:07:46 +02:00
mudler's LocalAI [bot]
b60b01d783 chore: ⬆️ Update 0xShug0/audio.cpp to f32876cfb45732dd4f43264e9104d229e95b0bc3 (#11233)
⬆️ 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-07-31 10:01:44 +02:00
mudler's LocalAI [bot]
5d461ec7d2 chore: ⬆️ Update CrispStrobe/CrispASR to 677e95d0e60010f10636c3a0b1ba215b38a4a943 (#11234)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 10:01:28 +02:00
mudler's LocalAI [bot]
25a8a73b35 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 9992f6b515ee63c7d6f7beee6b8414b0a6d1dd43 (#11235)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 10:01:14 +02:00
mudler's LocalAI [bot]
e356315f9c feat(swagger): update swagger (#11236)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 10:00:59 +02:00
mudler's LocalAI [bot]
4076b32d42 chore: ⬆️ Update leejet/stable-diffusion.cpp to e31a86ce9110b11a98bd5990c329093244c2d1e3 (#11237)
⬆️ Update leejet/stable-diffusion.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 10:00:41 +02:00
mudler's LocalAI [bot]
d2be530d14 chore: ⬆️ Update ggml-org/whisper.cpp to 4523d0ce373ee4b2176b3251fff29fd4864fcf38 (#11240)
⬆️ 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-07-31 10:00:28 +02:00
mudler's LocalAI [bot]
735420c216 chore: ⬆️ Update ServeurpersoCom/qwentts.cpp to abab6b3bf317cfa1b788efce1d25f4f9239395ad (#11241)
⬆️ Update ServeurpersoCom/qwentts.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 10:00:16 +02:00
localai-org-maint-bot
5e98f898db gallery: add Antares 1B GGUF variants (#11246)
feat(gallery): add Antares 1B GGUF variants

Add Q4_K_M and Q8_0 builds of the Granite 4.0-based security agent, linked as selectable gallery variants.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-31 09:20:52 +02:00
localai-org-maint-bot
f01589d98b fix(oci): identify signature verification requests (#11244)
Signed backend verification performs separate registry requests for manifests and referrers. Reuse LocalAI's version-aware User-Agent there so the full install flow is attributable to LocalAI.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-31 09:20:26 +02:00
mudler's LocalAI [bot]
daab94134c feat(website): add an ecosystem band, and an ADOPTERS file to back it (#11248)
Adds the "who turns up around this project" section, split into three lists
because the evidence behind each one is a different strength and collapsing
them into a single logo wall would overclaim.

  Contributors   21 companies whose engineers have commits here. Evidence is
                 the commit history plus the employer on that person's public
                 GitHub profile, so it is a claim about the person. Commit
                 counts are shown next to each name, including the ones that
                 are a single patch, because hiding that would be the whole
                 problem.
  Integrations   six projects that reference LocalAI in their own repository
                 or documentation, which anyone can verify without asking us.
  Press          four SUSE Communities articles about running LocalAI.

Names are set in type rather than fetched as logos. A logo reads as
endorsement, and a one-line typo fix from somebody who happens to work at a
large company does not support that, quite apart from what their trademark
policy says about it.

ADOPTERS.md is the mechanism for the stronger claim. An organisation that
wants to be listed as a user opens a pull request adding itself, which is both
the evidence and the permission, and is publicly auditable afterwards. The
file says plainly what the website does and does not claim, so the next person
to ask "can we add some big names" has the answer in the repository.


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

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-31 09:18:51 +02:00
mudler's LocalAI [bot]
94d5affcea feat(website): split the site, move docs to /docs, add a landing page (#11243)
* feat(website): split the site, move docs to /docs, add a landing page

The Hugo docs site has always been localai.io itself, which left nowhere to
explain what LocalAI is or show what the team builds. This adds a separate
marketing site at the root and moves the documentation under /docs/.

Docs:
  The existing site keeps its content tree and its Relearn theme, and now
  builds with baseURL <root>/docs/. Its _index.md, which held a hand written
  landing page, becomes a real documentation home.

  Every previously published URL keeps working. GitHub Pages has no server
  side rewrites, so .github/ci/gen-redirects.sh walks the built docs output
  and leaves a meta refresh plus a canonical link at each old root path. It
  covers bare .html files too, which is what keeps /gallery.html alive, and
  it never overwrites a path the marketing site already owns.

Website:
  A second Hugo site under website/ with its own layouts and no external
  theme, so the marketing side does not have to fight Relearn's home rooted
  menu and asset pipeline. CI builds both and merges them into one Pages
  artifact.

  The design is derived from the project logo rather than invented: the navy
  of the triangle, the cyan of the llama, the purple of the speed bars. Those
  offset bars became the motion signature. The background renders a real
  depth-anything.cpp depth map as contour lines and switches to a
  locate-anything.cpp style detection overlay over the engines section.

  Also included: an /engines/ index driven entirely by data/engines.yaml, a
  /blog/ section with five posts written from the release notes and the
  engine benchmark suites, install.sh and a Kubernetes manifest since the
  site advertises both, and a rule in .agents/ that release preparation now
  includes a blog post and demo clips.

Every figure on the site is derived from the repository or the GitHub API,
not from memory. Correcting them against their sources found one error in
README.md: voxtral-tts.c is text to speech, not speech to text.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write] [Agent]

* feat(website): add a star history chart, rewrite the history post in first person

The history post read like a changelog written by a committee. It is now in
Ettore's voice, first person, with the admissions left in.

The numbers paragraph in particular read like a directory listing. It now says
what the figures mean rather than which file they came from.

Adds an interactive star history chart, built from the GitHub stargazers API
rather than embedded from a third party, so the page makes no external request
and cannot break when someone else's service is down. The four releases the
post is organised around are marked on the curve, and the labels stack into
rows because three of them land within two months of each other.

The API stops paginating at 40,000 items, so the curve is measured up to
December 2025 and the segment from there to today's total is drawn dashed,
labelled as an estimate in the caption and in the tooltip. It is a straight
line between two known points, and the chart says so rather than implying it
is data.

Also drops "marketing site" from the README heading and everywhere else it
appeared, and calls it the main site instead.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-31 09:00:56 +02:00
362 changed files with 20864 additions and 3478 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

@@ -125,7 +125,7 @@ The per-backend prefix match only sees files under a backend's own directory, so
| `backend/backend.proto` | nothing if the edit is additive-only, otherwise everything (see below) |
| `backend/Dockerfile.<x>` | the Linux entries whose `dockerfile:` names it |
| `backend/python/common/` | Python, Linux + Darwin |
| `scripts/build/package-gpu-libs.sh` | Python, Linux only |
| `scripts/build/package-gpu-libs.sh` | every Linux entry (Python, Go and C++ all run it) |
| `scripts/build/<lang>-darwin.sh` | the Darwin entries that build target routes to |
| `.github/workflows/backend_build[_darwin].yml` | everything on that OS |
| anything else under `scripts/build/` (except `*_test.sh`) | everything — conservative default for unclassified packaging inputs |
@@ -359,6 +359,26 @@ GitHub Actions caches are limited to 10 GB per repo. Steady-state worst case: ~8
One residual self-hosted reference remains in `test-extra.yml` (`tests-vibevoice-cpp-grpc-transcription` uses `bigger-runner` for the 30s JFK-decode timeout headroom). That's a separate concern.
### Small always-on jobs routed to `arc-runner-set`
The hosted pool is shared across the whole *account*, not per repo, so a burst in one repo starves the others. On 2026-07-31 it went to **zero scheduled jobs for 35 consecutive minutes** with 39 jobs queued, while `arc-runner-set` completed 12 jobs without interruption over the same window. Actions was healthy globally at the time (other public repos were scheduling normally), so this is an account-level throttle, not an outage.
`gh-pages.yml` (`build` + `deploy`) is therefore routed to `arc-runner-set` when `github.repository == 'mudler/LocalAI'`. It needs no fork-safety clause because it only triggers on push-to-master and `workflow_dispatch`, so it never executes pull-request code. The repository guard keeps forks (which have no such runner label) from queueing forever. It fetches its own toolchains via `setup-go` / `actions-hugo` and uses no `sudo`/`apt`.
#### What the `arc-runner-set` image actually contains
Measured 2026-07-31 on run `30637392862` by a preflight step, not assumed:
| present | **absent** |
|---|---|
| `git`, `curl`, `unzip`, `tar`, `ldd`, `python3` | **`make`**, **`gcc`** |
That is why `lint.yml` is **not** on the self-hosted pool. Both of its jobs were routed there and both failed in one second: `golangci-lint` needs `make` (for `make protogen-go`, itself needing `curl`+`unzip` to fetch protoc, and for `make lint`), and `build-scripts` additionally needs a C toolchain because the packaging-script tests compile a throwaway binary and inspect it with `ldd`. Both jobs are back on `ubuntu-latest`.
The preflight steps were deliberately left in place. They cost about a second on the hosted pool and mean that whenever the runner image gains `make` + `gcc`, re-routing is one `runs-on:` line per job and any remaining gap reports itself by name rather than as an opaque mid-build failure.
Note for any future re-route: `lint.yml` also triggers on `pull_request`, and a fork PR runs untrusted contributor code. That must never reach a persistent self-hosted runner, so any re-route has to stay push-only, e.g. `${{ (github.event_name == 'push' && github.repository == 'mudler/LocalAI') && 'arc-runner-set' || 'ubuntu-latest' }}`.
## Touching the cache pipeline
When changing `image_build.yml`, `backend_build.yml`, any of the `backend/Dockerfile.*` files, `Dockerfile.base-grpc-builder`, `.docker/install-base-deps.sh`, `.docker/<backend>-compile.sh`, or `scripts/changed-backends.js`:

View File

@@ -0,0 +1,26 @@
# Preparing a Release
A release is not finished when the tag is pushed. The GitHub release, the blog post and the demo clips ship together, because the changelog says what moved and the post and the clips are what make anyone care.
## What a release must include
1. **Labels on the merged PRs.** GitHub generates the raw notes from PR labels, so label first, generate second. Wrong labels mean a miscategorised changelog that has to be edited by hand.
2. **`RELEASE_NOTES_vX.Y.Z.md`** at the repository root, in the house style: what changed, why it matters, PR numbers so people can read the diffs.
3. **A blog post under `website/content/blog/`.** One post per release, front matter with `title`, `date`, `author`, `category: "Release"`, `tags`, `summary` and `extracss: ["blog.css"]`. Cover the two or three changes that alter what a user does day to day, not the whole changelog, and link the PR numbers. See `website/content/blog/what-landed-in-localai-4-8.md` for the shape.
4. **Demo clips for the notable features.** Anything visible (a new backend, a UI change, a new endpoint, a measured speedup) gets a short screen recording. Put the file in `website/static/media/`, reference it from the blog post, and reuse it on the marketing pages where it fits.
A release without a post and without clips is incomplete, in the same way a user-facing code change without a docs update is incomplete.
## Clip conventions
- MP4, H.264, no audio track unless the feature is about audio. Keep them short (10 to 30 seconds) and loopable.
- Record the real thing. A clip from the engine's own benchmark suite or a real session, never a mockup.
- Where the change is a speedup, record both sides on the same machine on the same input, so the comparison is honest.
- Name the file after the feature, not the release (`vllm-race.mp4`, not `v4-8-demo.mp4`), so it stays reusable once the release is old.
- The marketing site plays clips with `muted loop playsinline preload="none"` and a `data-lazy` attribute, which the site's IntersectionObserver uses to play and pause them on scroll. Follow that pattern for anything you add.
## Order of work
Label the PRs, generate and edit the release notes, cut the draft release, record the clips while the branch is still fresh in your head, then write the post against the notes and the clips. Publishing the release and merging the post should happen on the same day.
The `creating-localai-releases` skill drives steps 1 to 3 and captures the React UI screenshots that go into the notes.

View File

@@ -113,6 +113,54 @@ if [ "${BUILD_TYPE:-}" = "vulkan" ] && [ "${SKIP_DRIVERS:-false}" = "false" ]; t
rm -rf /var/lib/apt/lists/*
fi
# --- 2b. Intel graphics driver (BUILD_TYPE=sycl*) ---
# The Intel oneAPI base image brings the compilers and the oneAPI libraries, but
# not the driver that talks to the graphics card. The packaging step copies that
# driver into the backend, so that the backend works on a machine which has no
# Intel graphics packages of its own, for the same reason the Vulkan section
# above installs the Mesa drivers. Install it here so there is something to copy.
#
# Only the sycl builds are covered, because those are the ones whose packaging
# copies the driver. See package_intel_libs in scripts/build/package-gpu-libs.sh.
#
# The driver comes from Intel's own package repository, not from the Ubuntu
# archive. The archive has 23.43 from late 2023, which does not know any card
# released since, so a machine with a recent Intel GPU would end up carrying a
# driver that cannot drive it. Intel's repository has 25.18 for the same Ubuntu
# release.
#
# Anything that goes wrong here fails the build, on purpose. An unreachable
# repository is a passing problem that a retry fixes, whereas carrying a
# different driver than intended, or none, is a difference nobody would notice
# until a user reports an idle GPU.
if case "${BUILD_TYPE:-}" in sycl*) true;; *) false;; esac \
&& [ "${SKIP_DRIVERS:-false}" = "false" ]; then
# Ubuntu release name, which is what the repository is indexed by.
ubuntu_codename=$(. /etc/os-release && echo "${VERSION_CODENAME:-}")
if [ -z "$ubuntu_codename" ]; then
echo "ERROR: cannot tell which Ubuntu release this image is, so cannot pick the Intel driver repository" >&2
exit 1
fi
# The key is armored text, which apt reads directly from a .asc file, so
# there is no need for gnupg here. "unified" is the component Intel ships
# its current driver in.
mkdir -p /usr/share/keyrings
curl -fsSL https://repositories.intel.com/gpu/intel-graphics.key \
-o /usr/share/keyrings/intel-graphics.asc
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.asc] https://repositories.intel.com/gpu/ubuntu ${ubuntu_codename} unified" \
> /etc/apt/sources.list.d/intel-graphics.list
apt-get update
# The first package holds the driver OpenCL talks to, the second the driver
# Level Zero talks to. Between them they pull in the compiler and the memory
# manager that both need.
apt-get install -y --no-install-recommends \
intel-opencl-icd \
libze-intel-gpu1
apt-get clean
rm -rf /var/lib/apt/lists/*
fi
# --- 3. CUDA toolkit (BUILD_TYPE=cublas|l4t) ---
if { [ "${BUILD_TYPE:-}" = "cublas" ] || [ "${BUILD_TYPE:-}" = "l4t" ]; } && [ "${SKIP_DRIVERS:-false}" = "false" ]; then
apt-get update

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
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.
if [ "$arch" = "arm64" ] && [ -n "$build_type" ]; then
echo llama-cpp-fallback
else
echo llama-cpp-cpu-all
fi

View File

@@ -18,10 +18,12 @@ if [[ -n "${CUDA_DOCKER_ARCH:-}" ]]; then
fi
cd /LocalAI/backend/cpp/llama-cpp
if [ -z "${BUILD_TYPE:-}" ]; then
# Pure CPU image (BUILD_TYPE empty): one build with ggml CPU_ALL_VARIANTS replaces the
# per-microarch binaries (x86: avx/avx2/avx512/fallback; arm64: armv8.x/armv9.x). ggml
# dlopens the best libggml-cpu-*.so at runtime by probing host CPU features.
BUILD_TARGET=$(/LocalAI/.docker/llama-cpp-build-target.sh "${TARGETARCH}" "${BUILD_TYPE:-}")
if [ "$BUILD_TARGET" = "llama-cpp-cpu-all" ]; then
# One build with ggml CPU_ALL_VARIANTS replaces the per-microarch binaries (x86:
# avx/avx2/avx512/fallback; arm64: armv8.x/armv9.x). BUILD_TYPE remains in the
# environment, so GPU builds retain their accelerator backend while ggml dlopens the
# best CPU library when work is offloaded to the host.
#
# arm64: the CPU_ALL_VARIANTS table includes armv9.2 SME variants whose -march=...+sme is
# rejected by the Ubuntu 24.04 default gcc-13. gcc-14 accepts it, so build the arm64
@@ -35,14 +37,8 @@ if [ -z "${BUILD_TYPE:-}" ]; then
apt-get update -qq && apt-get install -y -qq gcc-14 g++-14
export CC=gcc-14 CXX=g++-14
fi
make llama-cpp-cpu-all
else
# GPU build (cublas/hipblas/sycl/vulkan/...): the accelerator does the compute, so a
# single fallback CPU build is enough - no per-microarch CPU variants needed. (This also
# keeps the heavy GPU backend compile from also building the whole CPU variant matrix,
# and avoids the gcc-14 apt step on GPU base images such as nvidia l4t.)
make llama-cpp-fallback
fi
make "$BUILD_TARGET"
make llama-cpp-grpc
make llama-cpp-rpc-server

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
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.
if [ "$arch" = "arm64" ] && [ -n "$build_type" ]; then
echo turboquant-fallback
else
echo turboquant-cpu-all
fi

View File

@@ -19,20 +19,18 @@ fi
cd /LocalAI/backend/cpp/turboquant
if [ -z "${BUILD_TYPE:-}" ]; then
# Pure CPU image: one ggml CPU_ALL_VARIANTS build replaces the per-microarch binaries.
BUILD_TARGET=$(/LocalAI/.docker/turboquant-build-target.sh "${TARGETARCH}" "${BUILD_TYPE:-}")
if [ "$BUILD_TARGET" = "turboquant-cpu-all" ]; then
# BUILD_TYPE remains in the environment, so GPU builds retain their accelerator while
# ggml selects the best CPU library when model work is offloaded to the host.
# arm64: the armv9.2 SME variants need gcc-14 (gcc-13 rejects +sme).
if [ "${TARGETARCH}" = "arm64" ]; then
sh /LocalAI/.docker/apt-mirror.sh || true
apt-get update -qq && apt-get install -y -qq gcc-14 g++-14
export CC=gcc-14 CXX=g++-14
fi
make turboquant-cpu-all
else
# GPU build (cublas/hipblas/sycl/vulkan/...): single fallback CPU build, the accelerator
# does the compute. Keeps the GPU compile from also building the CPU variant matrix and
# avoids the gcc-14 apt step on GPU base images such as nvidia l4t.
make turboquant-fallback
fi
make "$BUILD_TARGET"
make turboquant-grpc
make turboquant-rpc-server

76
.github/ci/gen-redirects.sh vendored Executable file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# Generate client-side redirects for the documentation URLs that used to live at
# the site root.
#
# Until this site existed, the Hugo docs site WAS localai.io, so pages
# were published at /features/..., /getting-started/..., /faq/ and so on. The
# docs now build under /docs/, and GitHub Pages serves static files only: there
# is no server-side rewrite, no .htaccess, no _redirects. The only way to keep
# every published, bookmarked and search-indexed URL alive is to leave a real
# HTML file at the old address that sends the browser to the new one.
#
# Anything the main site already publishes wins: it owns /, /engines/,
# /blog/ and friends, so an existing file is never replaced.
#
# Usage: gen-redirects.sh <public-dir> [base-url]
# public-dir merged output directory (main site with docs/ inside it)
# base-url absolute or root-relative prefix the deployment is served from,
# trailing slash optional (default "/")
set -euo pipefail
PUBLIC_DIR=${1:?usage: gen-redirects.sh <public-dir> [base-url]}
BASE_URL=${2:-/}
# Normalise to exactly one trailing slash so concatenation below is predictable.
BASE_URL="${BASE_URL%/}/"
DOCS_DIR="${PUBLIC_DIR}/docs"
if [ ! -d "$DOCS_DIR" ]; then
echo "gen-redirects: no docs output at ${DOCS_DIR}" >&2
exit 1
fi
created=0
skipped=0
# Every .html file is a reachable old URL, not just directory indexes: the
# generated model gallery ships as a bare gallery.html and used to sit at the
# root too.
while IFS= read -r src; do
rel=${src#"$DOCS_DIR"/}
dst="${PUBLIC_DIR}/${rel}"
if [ -e "$dst" ]; then
skipped=$((skipped + 1))
continue
fi
# Link to the directory, not to its index.html, so the redirect target is the
# canonical URL the docs site itself advertises.
target="${BASE_URL}docs/${rel%index.html}"
mkdir -p "$(dirname "$dst")"
printf '%s' '<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Moved</title>
<link rel="canonical" href="'"$target"'">
<meta name="robots" content="noindex">
<meta http-equiv="refresh" content="0; url='"$target"'">
</head>
<body>
<p>This page moved to <a href="'"$target"'">'"$target"'</a>.</p>
</body>
</html>
' > "$dst"
created=$((created + 1))
done <<EOF
$(find "$DOCS_DIR" -type f -name '*.html' | sort)
EOF
echo "gen-redirects: ${created} redirect(s) written, ${skipped} path(s) left to the main site"

64
.github/ci/refresh-site-counters.sh vendored Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Refreshes the counters shown on the landing page from the GitHub API.
#
# The numbers used to be typed into the templates by hand, which meant they
# only moved when somebody remembered, and a stale star count on the front
# page is worse than no star count. Everything the API can answer for lives
# in website/data/stats.yaml and is rewritten wholesale by this script.
#
# Anything the API cannot answer for (the Discord member count) is read back
# out of the existing file and carried through untouched.
set -euo pipefail
REPO="${REPO:-mudler/LocalAI}"
OUT="${OUT:-website/data/stats.yaml}"
# The contributors and releases endpoints are paginated and never report a
# total. Asking for one item per page makes the last page number equal to the
# item count, which the Link header hands over.
count_via_link_header() {
local path="$1" link last
link=$(gh api -i "${path}?per_page=1" 2>/dev/null | tr -d '\r' | grep -i '^link:' || true)
if [ -z "$link" ]; then
# No Link header means a single page, so count that page directly.
gh api "${path}?per_page=100" --jq 'length'
return
fi
last=$(sed -n 's/.*[?&]page=\([0-9]*\)>; rel="last".*/\1/p' <<<"$link")
[ -n "$last" ] || { gh api "${path}?per_page=100" --jq 'length'; return; }
printf '%s\n' "$last"
}
read -r stars forks < <(gh api "repos/${REPO}" --jq '"\(.stargazers_count) \(.forks_count)"')
contributors=$(count_via_link_header "repos/${REPO}/contributors")
releases=$(count_via_link_header "repos/${REPO}/releases")
# Not derivable from the GitHub API, so keep whatever is already on disk.
discord=$(sed -n 's/^discord: *\([0-9]*\).*/\1/p' "$OUT" 2>/dev/null | head -1)
discord="${discord:-0}"
for n in stars forks contributors releases; do
v="${!n}"
[[ "$v" =~ ^[0-9]+$ ]] && [ "$v" -gt 0 ] || {
echo "refusing to write: ${n} came back as '${v}'" >&2
exit 1
}
done
cat > "$OUT" <<YAML
# Counters shown on the landing page.
#
# The four GitHub fields are rewritten by .github/ci/refresh-site-counters.sh,
# which runs weekly from .github/workflows/refresh-site-counters.yml. Editing
# them by hand works but will be overwritten on the next run.
stars: ${stars}
forks: ${forks}
contributors: ${contributors}
releases: ${releases}
# The GitHub API cannot answer for this one, so it is maintained by hand and
# the refresh script carries it through untouched.
discord: ${discord}
YAML
echo "stars=${stars} forks=${forks} contributors=${contributors} releases=${releases} discord=${discord}"

View File

@@ -110,10 +110,14 @@ jobs:
variable: "LOCATEANYTHING_VERSION"
branch: "master"
file: "backend/go/locate-anything-cpp/Makefile"
- repository: "ServeurpersoCom/qwentts.cpp"
variable: "QWEN3TTS_CPP_VERSION"
branch: "master"
file: "backend/go/qwen3-tts-cpp/Makefile"
# qwentts.cpp is held, not tracked: upstream master hangs in synthesis
# (see the comment on QWEN3TTS_CPP_VERSION in the backend Makefile).
# Leaving it here would re-bump the pin back onto the hang every night.
# Restore this entry once the upstream fix lands.
# - repository: "ServeurpersoCom/qwentts.cpp"
# variable: "QWEN3TTS_CPP_VERSION"
# branch: "master"
# file: "backend/go/qwen3-tts-cpp/Makefile"
- repository: "ServeurpersoCom/omnivoice.cpp"
variable: "OMNIVOICE_VERSION"
branch: "master"

View File

@@ -1,4 +1,4 @@
name: Deploy docs to GitHub Pages
name: Deploy site to GitHub Pages
on:
push:
@@ -6,9 +6,11 @@ on:
- master
paths:
- 'docs/**'
- 'website/**'
- 'gallery/**'
- 'images/**'
- '.github/ci/modelslist.go'
- '.github/ci/gen-redirects.sh'
- '.github/workflows/gh-pages.yml'
workflow_dispatch:
@@ -23,7 +25,20 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
# Self-hosted. This workflow is push-to-master + workflow_dispatch only, so
# it never executes pull-request code and a fork cannot reach the runner
# with untrusted changes. The repository guard keeps forks (whose own master
# pushes would otherwise queue forever against a label they do not have) on
# the hosted pool.
#
# Why: the GitHub-hosted pool is shared account-wide and has repeatedly
# starved (2026-07-31: 35 consecutive minutes at zero scheduled jobs, while
# arc-runner-set kept completing work throughout). Publishing the site is
# small, frequent, and must not sit behind a saturated hosted queue.
#
# Needs only git, tar and curl on the runner: setup-go and actions-hugo
# fetch their own toolchains, and no step uses sudo, apt, make or unzip.
runs-on: ${{ github.repository == 'mudler/LocalAI' && 'arc-runner-set' || 'ubuntu-latest' }}
env:
HUGO_VERSION: "0.146.3"
steps:
@@ -36,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
@@ -49,25 +73,46 @@ jobs:
id: pages
uses: actions/configure-pages@v6
# The gallery page is generated from the model index and shipped as a
# static asset of the docs site, so it has to exist before Hugo runs.
- name: Generate gallery
run: go run ./.github/ci/modelslist.go ./gallery/index.yaml > docs/static/gallery.html
- name: Build site
# Two Hugo sites, one Pages artifact: the main site owns the root,
# the docs site is nested under /docs/.
- name: Build the main site
working-directory: website
run: hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
- name: Build documentation site
working-directory: docs
run: |
mkdir -p layouts/_default
hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/docs/"
- name: Merge documentation into the main site
run: |
mkdir -p website/public/docs
cp -R docs/public/. website/public/docs/
# Keeps the pre-split URLs alive; see the script header.
- name: Generate legacy URL redirects
run: .github/ci/gen-redirects.sh website/public "${{ steps.pages.outputs.base_url }}/"
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: docs/public
path: website/public
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
# Same routing as build: a hosted slot for a ~10s deploy is exactly the kind
# of job that should not block on a starved pool. deploy-pages authenticates
# with the job's OIDC token (id-token: write above), which self-hosted
# runners issue the same way hosted ones do.
runs-on: ${{ github.repository == 'mudler/LocalAI' && 'arc-runner-set' || 'ubuntu-latest' }}
needs: build
steps:
- name: Deploy to GitHub Pages

View File

@@ -21,8 +21,41 @@ concurrency:
jobs:
golangci-lint:
# Self-hosted for PUSH only, and only in the canonical repo.
#
# This workflow also runs on pull_request, which for a fork PR means
# executing untrusted contributor code. That must never land on a
# self-hosted runner, so anything that is not a push to mudler/LocalAI stays
# on the ephemeral hosted pool. Pushes to master are trusted code that has
# already been reviewed and merged.
#
# Why at all: the hosted pool is shared account-wide and starved for 35
# straight minutes on 2026-07-31 while arc-runner-set kept completing jobs.
# Lint is small and runs on every commit, so it is a good candidate to move
# off the contended pool.
# REVERTED to hosted: the arc-runner-set image has git, curl, unzip, tar,
# ldd and python3, but NOT make (nor gcc). Measured on run 30637392862,
# where the preflight below named both. Re-route here once the runner image
# ships a C toolchain and make; the preflight stays so the next attempt
# fails by name in one second instead of opaquely mid-build.
runs-on: ubuntu-latest
steps:
- name: Preflight - required host tools
# The hosted images ship these; a self-hosted container image may not.
# Check up front so a missing tool reports itself by name instead of
# surfacing as an opaque failure inside `make protogen-go` (which needs
# curl + unzip for protoc) or `make lint`.
run: |
missing=""
for t in git curl unzip make tar; do
command -v "$t" >/dev/null 2>&1 || missing="$missing $t"
done
echo "runner: ${RUNNER_NAME:-unknown} os: $(uname -sm)"
if [ -n "$missing" ]; then
echo "::error::missing required tools on this runner:$missing"
exit 1
fi
echo "all required tools present"
- uses: actions/checkout@v7
with:
# Full history so golangci-lint's new-from-merge-base can reach
@@ -55,8 +88,30 @@ jobs:
# container build (a missing transitive dep, a partial cuDNN family). Their
# shell tests need nothing but bash + gcc + ldd, so run them on every PR
# rather than waiting on a multi-GB cross-arch backend image build.
#
# Push-only self-hosted routing, same fork-safety reasoning as
# golangci-lint above.
# REVERTED to hosted: the arc-runner-set image has git, curl, unzip, tar,
# ldd and python3, but NOT make (nor gcc). Measured on run 30637392862,
# where the preflight below named both. Re-route here once the runner image
# ships a C toolchain and make; the preflight stays so the next attempt
# fails by name in one second instead of opaquely mid-build.
runs-on: ubuntu-latest
steps:
- name: Preflight - required host tools
# This job additionally needs a C toolchain: the packaging-script tests
# compile a throwaway binary and inspect it with ldd.
run: |
missing=""
for t in git make gcc ldd python3; do
command -v "$t" >/dev/null 2>&1 || missing="$missing $t"
done
echo "runner: ${RUNNER_NAME:-unknown} os: $(uname -sm)"
if [ -n "$missing" ]; then
echo "::error::missing required tools on this runner:$missing"
exit 1
fi
echo "all required tools present"
- uses: actions/checkout@v7
- name: run packaging script tests
run: make test-build-scripts

View File

@@ -0,0 +1,44 @@
name: Refresh site counters
# The landing page shows a star count, a contributor count and a release
# count. They were typed in by hand, so they drifted the moment somebody
# forgot. This pulls the real numbers once a week and commits them only when
# they have actually moved, which in turn triggers the usual Pages deploy.
on:
schedule:
# Mondays, 06:17 UTC. Off the hour on purpose, since the scheduler queues
# everything that asks for :00 and drops what it cannot run.
- cron: '17 6 * * 1'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: refresh-site-counters
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Read the counts off the GitHub API
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/ci/refresh-site-counters.sh
- name: Commit only if something moved
run: |
if git diff --quiet -- website/data/stats.yaml; then
echo "counters unchanged, nothing to commit"
exit 0
fi
git diff --unified=0 -- website/data/stats.yaml
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add website/data/stats.yaml
git commit -m "chore(website): refresh the counters"
git push

10
.gitignore vendored
View File

@@ -62,6 +62,11 @@ prepare
/ggml-metal.metal
docs/static/gallery.html
# Hugo build output and lock files (docs/ and website/)
docs/public/
website/public/
.hugo_build.lock
# Protobuf generated files
*.pb.go
*pb2.py
@@ -119,3 +124,8 @@ formal-verification/out/
# package directory itself and untrack the source.
/apexentries
/.github/ci/apexentries/apexentries
# Runtime state written by `local-ai run` when it is started from the repo
# root, which is what a contributor testing a build does. Nothing under here is
# source: it is the instance's own models, outputs, traces and identity.
/data/

48
ADOPTERS.md Normal file
View File

@@ -0,0 +1,48 @@
# Adopters
Organisations running LocalAI, listed by the people who run it.
If your organisation uses LocalAI and you are happy to say so publicly, open a
pull request adding a row to the table below. That pull request is how we know
we have permission to list you, which is why we do not add anybody ourselves.
You do not need to be a large company, and you do not need to disclose anything
sensitive. A sentence on what you use it for is more useful to other readers
than a logo.
## How to add yourself
1. Add a row to the table, in alphabetical order.
2. Use your organisation's usual name and a link to your site.
3. Say briefly what you use LocalAI for, and whether it is in production.
4. Open the pull request from an account that makes it plausible you speak for
the organisation, or say in the description who you are. We may ask.
To be removed, open a pull request deleting your row, or email
[info@localai.io](mailto:info@localai.io). We will not ask why.
## Who is using LocalAI
<!-- Keep alphabetical. Columns: Organisation | What for | Status -->
| Organisation | What they use it for | Status |
|---|---|---|
| _Your organisation here_ | | |
## What this list is not
This is not a list of everyone who has ever starred the repository, and it is
not a list of the employers of people who have contributed a patch. Both of
those are easy to scrape and neither means what a logo wall implies.
The website shows two separate things, both of which are checkable without
anybody's permission:
- **Engineers from these companies have contributed code.** Evidence is the
commit history plus the employer on that person's public GitHub profile. It
is a claim about a person, not about their employer.
- **These projects integrate LocalAI.** Evidence is a reference to LocalAI in
that project's own repository or documentation.
Those two lists live in [`website/data/ecosystem.yaml`](website/data/ecosystem.yaml).
This file is the third, stronger thing: organisations that chose to say so.

View File

@@ -32,6 +32,7 @@ LocalAI follows the Linux kernel project's [guidelines for AI coding assistants]
| [.agents/adding-gallery-models.md](.agents/adding-gallery-models.md) | Adding GGUF models from HuggingFace to the model gallery |
| [.agents/localai-assistant-mcp.md](.agents/localai-assistant-mcp.md) | LocalAI Assistant chat modality — adding admin tools to the in-process MCP server, editing skill prompts, keeping REST + MCP + skills in sync |
| [.agents/backend-signing.md](.agents/backend-signing.md) | Backend OCI image signing (keyless cosign + sigstore-go) — producer-side CI setup, consumer-side gallery `verification:` block, strict mode (`LOCALAI_REQUIRE_BACKEND_INTEGRITY`), revocation via `not_before` |
| [.agents/preparing-a-release.md](.agents/preparing-a-release.md) | Cutting a release: PR labels, `RELEASE_NOTES_vX.Y.Z.md`, the blog post under `website/content/blog/`, and the demo clips under `website/static/media/` |
## Quick Reference
@@ -42,6 +43,7 @@ LocalAI follows the Linux kernel project's [guidelines for AI coding assistants]
- **Docs (docs-with-code rule)**: When you change user-facing behavior (API endpoints, CLI flags, config keys, or features), update the corresponding page under `docs/content/` in the SAME change, not as a follow-up. A user-facing change without a matching docs update is incomplete. See also the documentation conventions in [.agents/coding-style.md](.agents/coding-style.md).
- **New API endpoints**: LocalAI advertises its capability surface in several independent places — swagger `@Tags`, `/api/instructions` registry, auth `RouteFeatureRegistry`, React UI `capabilities.js`, docs. Read [.agents/api-endpoints-and-auth.md](.agents/api-endpoints-and-auth.md) and follow its checklist — missing any surface means clients, admins, and the UI won't know the endpoint exists.
- **Admin endpoints → MCP tool**: every admin endpoint that an admin would manage conversationally (install/list/edit/toggle/upgrade) MUST also be exposed as an MCP tool in `pkg/mcp/localaitools/`. The LocalAI Assistant chat modality and the standalone `local-ai mcp-server` consume that package; drift between REST and MCP is a real risk. Read [.agents/localai-assistant-mcp.md](.agents/localai-assistant-mcp.md) — the `TestToolHTTPRouteMappingComplete` test fails until you wire the new tool and update the route map.
- **Releases ship with a post and clips**: a release is not done at the tag. It needs labelled PRs, `RELEASE_NOTES_vX.Y.Z.md`, a blog post under `website/content/blog/`, and a short demo clip in `website/static/media/` for each notable feature. See [.agents/preparing-a-release.md](.agents/preparing-a-release.md).
- **Build**: Inspect `Makefile` and `.github/workflows/` — ask the user before running long builds
- **Backend OS coverage**: a new backend must target every OS it can build for, not just Linux. `.github/backend-matrix.yml` has two matrices — `include:` (Linux) and `includeDarwin:` (macOS / Apple Silicon). Most C/C++/GGML and many Python backends build on Darwin too — wire the `includeDarwin` entry + `backend/index.yaml` `metal:` entries, or say in the PR why an OS is unsupported. See the darwin checklist in [.agents/adding-backends.md](.agents/adding-backends.md).
- **Gallery variant ranking**: a gallery entry can declare `variants` (alternative builds of the same weights), and LocalAI ranks the ones a host can run by engine preference first, size second. A new backend that should be preferred on some hardware must be listed in `engineNamePreferenceRules` in `pkg/system/capabilities.go`; the sibling `backendBuildTagPreferenceRules` speaks build tags rather than engine names, and using the wrong table matches nothing without erroring. See [.agents/adding-backends.md](.agents/adding-backends.md).

View File

@@ -1548,7 +1548,12 @@ swagger:
gen-assets:
$(GOCMD) run core/dependencies_manager/manager.go webui_static.yaml core/http/static/assets
## Documentation
## Documentation and website
# The published site is two Hugo sites: website/ owns the root, docs/ is nested
# under /docs/. Serve them separately while editing; use `make site` to get the
# merged tree (including the legacy URL redirects) that GitHub Pages deploys.
SITE_BASE_URL?=http://localhost:8000
docs/layouts/_default:
mkdir -p docs/layouts/_default
@@ -1560,12 +1565,30 @@ docs/public: docs/layouts/_default docs/static/gallery.html
docs-clean:
rm -rf docs/public
rm -rf website/public
rm -rf docs/static/gallery.html
.PHONY: docs
docs: docs/static/gallery.html
cd docs && hugo serve
.PHONY: website
website:
cd website && hugo serve
.PHONY: site
site: docs/static/gallery.html
rm -rf website/public docs/public
cd website && hugo --minify --baseURL "$(SITE_BASE_URL)/"
cd docs && hugo --minify --baseURL "$(SITE_BASE_URL)/docs/"
mkdir -p website/public/docs
cp -R docs/public/. website/public/docs/
./.github/ci/gen-redirects.sh website/public "$(SITE_BASE_URL)/"
.PHONY: site-serve
site-serve: site
cd website/public && python3 -m http.server 8000
########################################################
## Platform-specific builds
########################################################

View File

@@ -161,7 +161,7 @@ local-ai run https://gist.githubusercontent.com/.../phi-2.yaml
local-ai run oci://localai/phi-2:latest
```
To test a running LocalAI server from the terminal, open an interactive chat session from another shell. Inside the prompt, `/models` lists installed models and `/model <name>` switches between them.
To work with a running LocalAI server from the terminal, start the built-in agent from another shell. It answers questions, reads your files and runs commands on your machine, asking you to approve anything that changes state. Inside a session, `/models` lists installed models and `/model <name>` switches between them. See the [Terminal agent](https://localai.io/docs/features/terminal-agent/) docs.
```bash
# Terminal 1
@@ -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
@@ -238,7 +238,7 @@ Most backends wrap a best-in-class upstream engine. A handful of them are native
| [magpie-tts.cpp](https://github.com/mudler/magpie-tts.cpp) | C++/GGML port of NVIDIA's Magpie TTS Multilingual 357M: 22.05 kHz mono text-to-speech in 5 voices and 9+ languages, with the NanoCodec neural codec and tokenizer/G2P embedded in a single GGUF |
| [ced.cpp](https://github.com/localai-org/ced.cpp) | C++/GGML port of the CED audio-tagging models: sound-event classification (527-class AudioSet) over REST and the realtime API for live recognition |
| [voice-detect.cpp](https://github.com/localai-org/voice-detect.cpp) | Speaker recognition and voice analysis (ECAPA-TDNN, WeSpeaker, ERes2Net, CAM++, wav2vec2 age/gender/emotion), replacing the Python speaker-recognition backend |
| [voxtral-tts.c](https://github.com/mudler/voxtral-tts.c) | Voxtral Realtime 4B speech-to-text in pure C |
| [voxtral-tts.c](https://github.com/mudler/voxtral-tts.c) | Mistral Voxtral-4B-TTS text-to-speech in pure C: 20 preset voices across 9 languages, 24 kHz WAV output, no dependencies beyond libc |
| [vibevoice.cpp](https://github.com/mudler/vibevoice.cpp) | Native port of Microsoft VibeVoice for TTS (voice cloning) and long-form ASR with speaker diarization |
| [rf-detr.cpp](https://github.com/localai-org/rf-detr.cpp) | Native RF-DETR object detection and instance segmentation |
| [locate-anything.cpp](https://github.com/mudler/locate-anything.cpp) | Open-vocabulary object detection and visual grounding (LocateAnything-3B) |
@@ -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?=e800d435d130dc776baf6f3e6129bb62b1495c89
AUDIO_CPP_VERSION?=5a8312ef7b8aa7cf14e9a24ac568cabd8725d68a
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))

View File

@@ -1,7 +1,7 @@
# Pinned to the HEAD of the `prism` branch on https://github.com/PrismML-Eng/llama.cpp.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
BONSAI_VERSION?=7529fdaaf99ffdc5ca71ace9c7409a56b27ad92f
BONSAI_VERSION?=9ca265a57f85f2117942490f421f64a226dd9847
LLAMA_REPO?=https://github.com/PrismML-Eng/llama.cpp
CMAKE_ARGS?=

View File

@@ -40,6 +40,27 @@ else
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
# and libze_loader is only there in those builds. Level Zero looks for a
# driver on its own, so point it at the copy that came with this backend: it
# was built against the same C library, while the machine's own driver may
# not have been, and loading that one can crash on start.
#
# Anything the user set is left alone, so a machine with a graphics card
# newer than the driver carried here can still be told to use its own.
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
# set there would leave OpenCL worse off than the machine's own setup.
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
fi
# Ask the driver how much graphics memory is free. Without this, the
# backend reads zero on an integrated graphics chip, because such a chip
# shares the system memory instead of having its own.
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
export ZES_ENABLE_SYSMAN=1
fi
fi
fi
# If there is a lib/ld.so, use it

View File

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

View File

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

View File

@@ -1,225 +0,0 @@
# MiniMax-M3 chat-template parser, vendored from upstream llama.cpp PR #24523.
#
# Upstream has since merged the *model* half of #24523 (LLM_ARCH_MINIMAX_M3,
# src/models/minimax-m3.cpp, the gguf-py constants and conversion/minimax.py), so
# only the chat half is carried here: M3's namespace token "]<]minimax[>[" collides
# with the autoparser's markup delimiters, so common/chat.cpp needs a dedicated
# template detection + PEG parser that upstream does not have yet.
#
# Rebased against LLAMA_VERSION 0d47ea7427463093e69128bf2c2f9cd06b3ee5b3, which also
# renamed common_chat_params::thinking_end_tag to thinking_end_tags (a vector).
# LLAMA_VERSION is auto-bumped nightly; if a bump rejects this patch, re-vendor from
# #24523 — or, once the chat half merges upstream, delete this file.
# See https://github.com/mudler/LocalAI/issues/10820 and PR #10837.
diff --git a/common/chat.cpp b/common/chat.cpp
index 7a6e7238c..2dd015a2e 100644
--- a/common/chat.cpp
+++ b/common/chat.cpp
@@ -2121,6 +2121,191 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha
return data;
}
+static common_chat_params common_chat_params_init_minimax_m3(const common_chat_template & tmpl,
+ const autoparser::generation_params & inputs) {
+ common_chat_params data;
+
+ data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
+ data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
+ data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
+ data.supports_thinking = true;
+ data.thinking_start_tag = "<mm:think>";
+ data.thinking_end_tags = {"</mm:think>"};
+
+ // M3 prefixes every tool tag with the namespace token "]<]minimax[>[";
+ // params use the parameter name as the tag (<file_path>...</file_path>).
+ const std::string NS = "]<]minimax[>[";
+ const std::string THINK_START = "<mm:think>";
+ const std::string THINK_END = "</mm:think>";
+ const std::string FC_START = NS + "<tool_call>";
+ const std::string FC_END = NS + "</tool_call>";
+ const std::string INVOKE_END = NS + "</invoke>";
+
+ data.preserved_tokens = {
+ NS,
+ "<tool_call>",
+ "</tool_call>",
+ THINK_START,
+ THINK_END,
+ };
+
+ auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
+ auto has_response_format = !inputs.json_schema.is_null() && inputs.json_schema.is_object();
+ auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
+ auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);
+
+ const std::string GEN_PROMPT = data.generation_prompt;
+
+ if (inputs.has_continuation()) {
+ const auto & msg = inputs.continue_msg;
+
+ data.generation_prompt = GEN_PROMPT + THINK_START + msg.reasoning_content;
+ if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
+ data.generation_prompt += THINK_END + msg.render_content();
+ }
+
+ data.prompt += data.generation_prompt;
+ }
+
+ auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
+ auto generation_prompt = p.literal(GEN_PROMPT);
+ auto end = p.end();
+
+ auto reasoning = p.eps();
+ // M3 can emit a bare </mm:think> (no opener) after tool results; keep the opener optional.
+ if (extract_reasoning && inputs.enable_thinking) {
+ reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.reasoning(p.until(THINK_END)) + THINK_END);
+ } else if (extract_reasoning) {
+ reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.until(THINK_END) + p.literal(THINK_END));
+ }
+
+ if (has_response_format) {
+ auto response_format = p.rule("response-format",
+ p.literal("```json") + p.space() +
+ p.content(p.schema(p.json(), "response-format-schema", inputs.json_schema)) +
+ p.space() + p.literal("```"));
+ return generation_prompt + reasoning + response_format + end;
+ }
+
+ if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
+ return generation_prompt + reasoning + p.content(p.rest()) + end;
+ }
+
+ auto tool_choice = p.choice();
+ foreach_function(inputs.tools, [&](const json & tool) {
+ const auto & function = tool.at("function");
+ std::string name = function.at("name");
+ auto params = function.contains("parameters") ? function.at("parameters") : json::object();
+ const auto & props = params.contains("properties") ? params.at("properties") : json::object();
+
+ std::set<std::string> required;
+ if (params.contains("required")) {
+ params.at("required").get_to(required);
+ }
+
+ auto schema_info = common_schema_info();
+ schema_info.resolve_refs(params);
+
+ std::vector<common_peg_parser> required_parsers;
+ std::vector<common_peg_parser> optional_parsers;
+ for (const auto & [param_name, param_schema] : props.items()) {
+ bool is_required = required.find(param_name) != required.end();
+ bool is_string = schema_info.resolves_to_string(param_schema);
+
+ const std::string p_close = NS + "</" + param_name + ">";
+
+ auto arg = p.tool_arg(
+ p.tool_arg_open(
+ p.literal(NS + "<") +
+ p.tool_arg_name(p.literal(param_name)) +
+ p.literal(">")) +
+ (is_string
+ ? p.ac(p.tool_arg_string_value(p.until(p_close)) +
+ p.tool_arg_close(p.literal(p_close)), p_close)
+ : p.tool_arg_json_value(p.schema(p.json(),
+ "tool-" + name + "-arg-" + param_name + "-schema",
+ param_schema, false)) +
+ p.tool_arg_close(p.literal(p_close))));
+
+ auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg);
+ if (is_required) {
+ required_parsers.push_back(named_arg);
+ } else {
+ optional_parsers.push_back(named_arg);
+ }
+ }
+
+ common_peg_parser args_seq = p.eps();
+ for (size_t i = 0; i < required_parsers.size(); i++) {
+ if (i > 0) {
+ args_seq = args_seq + p.space();
+ }
+ args_seq = args_seq + required_parsers[i];
+ }
+
+ if (!optional_parsers.empty()) {
+ common_peg_parser any_opt = p.choice();
+ for (const auto & opt : optional_parsers) {
+ any_opt |= opt;
+ }
+ args_seq = args_seq + p.repeat(p.space() + any_opt, 0, -1);
+ }
+
+ common_peg_parser invoke_body = args_seq;
+ auto func_parser = p.tool(
+ p.tool_open(p.literal(NS + "<invoke name=\"") +
+ p.tool_name(p.literal(name)) + p.literal("\">")) +
+ p.space() + invoke_body + p.space() +
+ p.tool_close(p.literal(INVOKE_END)));
+
+ tool_choice |= p.rule("tool-" + name, func_parser);
+ });
+
+ auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
+
+ common_peg_parser tool_calls = p.eps();
+ if (inputs.parallel_tool_calls) {
+ tool_calls = p.trigger_rule("tool-call",
+ p.literal(FC_START) + p.space() + tool_choice +
+ p.zero_or_more(p.space() + tool_choice) + p.space() + p.literal(FC_END));
+ } else {
+ tool_calls = p.trigger_rule("tool-call",
+ p.literal(FC_START) + p.space() + tool_choice + p.space() + p.literal(FC_END));
+ }
+
+ if (!require_tools) {
+ tool_calls = p.optional(tool_calls);
+ }
+
+ auto content_before_tools = p.content(p.until(FC_START));
+ return generation_prompt + reasoning + content_before_tools + tool_calls + end;
+ });
+
+ data.parser = parser.save();
+
+ if (include_grammar) {
+ data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
+ data.grammar = build_grammar([&](const common_grammar_builder & builder) {
+ foreach_function(inputs.tools, [&](const json & tool) {
+ const auto & function = tool.at("function");
+ auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
+ builder.resolve_refs(schema);
+ });
+ if (has_response_format) {
+ auto schema = inputs.json_schema;
+ builder.resolve_refs(schema);
+ }
+ parser.build_grammar(builder, data.grammar_lazy);
+ });
+
+ data.grammar_triggers = {
+ { COMMON_GRAMMAR_TRIGGER_TYPE_WORD, FC_START },
+ };
+ }
+
+ return data;
+}
+
// Cohere2 MoE (a.k.a. "North Code") parser.
//
// The assistant turn is fully marker-wrapped:
@@ -2707,6 +2892,15 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
return common_chat_params_init_gigachat_v3(tmpl, params);
}
+ // MiniMax-M3: the namespace token "]<]minimax[>[" collides with the autoparser's
+ // markup delimiters, so detect the template and use a dedicated parser.
+ if (src.find("]<]minimax[>[") != std::string::npos &&
+ src.find("<tool_call>") != std::string::npos &&
+ src.find("<invoke name=") != std::string::npos) {
+ LOG_DBG("Using specialized template: MiniMax-M3\n");
+ return common_chat_params_init_minimax_m3(tmpl, params);
+ }
+
// DeepSeek V3.2/V4 format detection: template defines dsml_token and uses it for tool calls.
// The template source contains the token as a variable assignment, not as a literal in markup.
// V3.2 names the tool call block "function_calls", V4 names it "tool_calls".

View File

@@ -12,10 +12,11 @@ grep -e "flags" /proc/cpuinfo | head -1
BINARY=llama-cpp-fallback
# CPU images (x86, arm64, darwin) 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 images (cublas/sycl/vulkan/hipblas) ship only
# llama-cpp-fallback (the accelerator does the compute), so fall back to it when absent.
# 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, 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
@@ -42,6 +43,27 @@ else
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
# and libze_loader is only there in those builds. Level Zero looks for a
# driver on its own, so point it at the copy that came with this backend: it
# was built against the same C library, while the machine's own driver may
# not have been, and loading that one can crash on start.
#
# Anything the user set is left alone, so a machine with a graphics card
# newer than the driver carried here can still be told to use its own.
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
# set there would leave OpenCL worse off than the machine's own setup.
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
fi
# Ask the driver how much graphics memory is free. Without this,
# llama.cpp reads zero on an integrated graphics chip, because such a
# chip shares the system memory instead of having its own.
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
export ZES_ENABLE_SYSMAN=1
fi
fi
fi
# If there is a lib/ld.so, use it
@@ -55,4 +77,4 @@ echo "Using binary: $BINARY"
exec "$CURDIR"/$BINARY "$@"
# We should never reach this point, however just in case we do, run fallback
exec "$CURDIR"/llama-cpp-fallback "$@"
exec "$CURDIR"/llama-cpp-fallback "$@"

View File

@@ -1,7 +1,7 @@
# Pinned to the HEAD of feature/turboquant-kv-cache on https://github.com/TheTom/llama-cpp-turboquant.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
TURBOQUANT_VERSION?=c26cbdffcf6fc9b7430cd6b117757e9a3f70b7ea
TURBOQUANT_VERSION?=8a891f4b566efdbd3cea92fafee3227a0a267683
LLAMA_REPO?=https://github.com/TheTom/llama-cpp-turboquant
CMAKE_ARGS?=

View File

@@ -12,9 +12,12 @@ grep -e "flags" /proc/cpuinfo | head -1
BINARY=turboquant-fallback
# x86/arm64 ship a single turboquant-cpu-all built with ggml CPU_ALL_VARIANTS: ggml's
# 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. ROCm ships only turboquant-fallback, so fall back to it when cpu-all is absent.
# probing. GPU arm64 images still ship turboquant-fallback 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"/turboquant-cpu-all ]; then
BINARY=turboquant-cpu-all
fi
@@ -40,6 +43,27 @@ else
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
# and libze_loader is only there in those builds. Level Zero looks for a
# driver on its own, so point it at the copy that came with this backend: it
# was built against the same C library, while the machine's own driver may
# not have been, and loading that one can crash on start.
#
# Anything the user set is left alone, so a machine with a graphics card
# newer than the driver carried here can still be told to use its own.
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
# set there would leave OpenCL worse off than the machine's own setup.
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
fi
# Ask the driver how much graphics memory is free. Without this, the
# backend reads zero on an integrated graphics chip, because such a chip
# shares the system memory instead of having its own.
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
export ZES_ENABLE_SYSMAN=1
fi
fi
fi
# If there is a lib/ld.so, use it

View File

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

View File

@@ -7,6 +7,16 @@ GO_TAGS?=
JOBS?=$(shell nproc --ignore=1)
# qwentts.cpp version
#
# Held at 35ebe537 rather than tracking latest: abab6b3 hangs in synthesis.
# TTS() never returns from the native call, so tests-qwen3-tts-cpp goes from
# ~5 minutes to the 20 minute Go test timeout. Reproduced on master on
# 2026-08-01 and again on re-run, and the bump PR (#11241) was merged with
# this same check already red.
#
# The regression is in 35ebe537..abab6b3, three upstream commits whose only
# functional change is 26dd8adb, "predictor: unroll the frame into one cgraph
# and sample in standard ops". Restore the bump once that is fixed upstream.
QWEN3TTS_REPO?=https://github.com/ServeurpersoCom/qwentts.cpp
QWEN3TTS_CPP_VERSION?=35ebe5376b82a0a59d008586d55bbe623d449011
SO_TARGET?=libgoqwen3ttscpp.so

View File

@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1)
# build; leaving this on `master` always picks up the latest C-API surface
# (incl. the per-detection accessor functions used by gorfdetrcpp.go).
RFDETR_REPO?=https://github.com/localai-org/rf-detr.cpp.git
RFDETR_VERSION?=65c0ffcc9a9bc9dae38252f63d0417c9845a6cf7
RFDETR_VERSION?=98d0f381b832ef08a608b65c7dd78db066ed8b9a
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF

View File

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

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?=a630b35c6fc02c8879f751ec3f39a61327f01dc7
WHISPER_CPP_VERSION?=2ca53bb45e38748d07b310eeb36245a7157ac882
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

@@ -1,6 +1,7 @@
package main
import (
"errors"
"os"
"path/filepath"
@@ -107,6 +108,13 @@ For documentation and support:
// Run the thing!
err = ctx.Run(&cli.CLI.Context)
if err != nil {
// A command that has already told the user what went wrong returns
// only a status. Logging it as well would print a bare "exit status 1"
// underneath the explanation they just read.
var reported cli.ExitCodeError
if errors.As(err, &reported) {
os.Exit(reported.Code)
}
xlog.Fatal("Error running the application", "error", err)
}
}

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

View File

@@ -444,6 +444,13 @@ func New(opts ...config.AppOption) (*Application, error) {
// when gallery data refreshes instead of using a fixed TTL.
vram.SetGalleryGenerationFunc(gallery.GalleryGeneration)
// Fill those caches ahead of the first visitor. An estimate for an entry
// nobody has asked about yet costs a remote probe of its weight files, and
// the model gallery asks for one per row, so without this the first page
// spends seconds filling in its own sizes while somebody watches it.
// Non-blocking, and bounded: see DefaultEstimateWarmConfig.
gallery.WarmEstimateCache(options.Context, options.Galleries, options.SystemState, gallery.EstimateWarmConfigFromEnv())
if options.ConfigFile != "" {
if err := application.ModelConfigLoader().LoadMultipleModelConfigsSingleFile(options.ConfigFile, configLoaderOpts...); err != nil {
xlog.Error("error loading config file", "error", err)

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

@@ -1,30 +0,0 @@
package chat
import (
"context"
"io"
"strings"
)
type Options struct {
Model string
BaseURL string
APIKey string
In io.Reader
Out io.Writer
}
func Run(ctx context.Context, opts Options) error {
if opts.In == nil {
opts.In = strings.NewReader("")
}
if opts.Out == nil {
opts.Out = io.Discard
}
session, err := newChatSession(ctx, newLocalAIChatClient(opts.BaseURL, opts.APIKey), opts.Model)
if err != nil {
return err
}
return runTerminalChat(ctx, session, opts.In, opts.Out)
}

View File

@@ -1,172 +0,0 @@
package chat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Run chat", func() {
It("streams a single chat response", func() {
var capturedModel string
var capturedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
w.Header().Set("Content-Type", "application/json")
writeResponse(w, `{"object":"list","data":[{"id":"test-model","object":"model"}]}`)
return
}
Expect(r.URL.Path).To(Equal("/v1/chat/completions"))
capturedAuth = r.Header.Get("Authorization")
var body struct {
Model string `json:"model"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
capturedModel = body.Model
Expect(body.Messages).To(HaveLen(1))
Expect(body.Messages[0].Role).To(Equal("user"))
Expect(body.Messages[0].Content).To(Equal("hello"))
w.Header().Set("Content-Type", "text/event-stream")
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n")
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\n")
writeResponse(w, "data: [DONE]\n\n")
}))
defer server.Close()
var out bytes.Buffer
err := Run(GinkgoT().Context(), Options{
Model: "test-model",
BaseURL: server.URL + "/v1",
APIKey: "secret",
In: strings.NewReader("hello\n/exit\n"),
Out: &out,
})
Expect(err).ToNot(HaveOccurred())
Expect(capturedModel).To(Equal("test-model"))
Expect(capturedAuth).To(Equal("Bearer secret"))
Expect(out.String()).To(ContainSubstring("assistant: hi!"))
Expect(out.String()).To(ContainSubstring("bye"))
})
It("auto-selects the only available model", func() {
server := chatTestServer([]string{"solo"}, nil)
defer server.Close()
var out bytes.Buffer
err := Run(GinkgoT().Context(), Options{
BaseURL: server.URL + "/v1",
In: strings.NewReader("/exit\n"),
Out: &out,
})
Expect(err).ToNot(HaveOccurred())
Expect(out.String()).To(ContainSubstring("LocalAI chat (solo)"))
})
It("returns an actionable error when no models are installed", func() {
server := chatTestServer(nil, nil)
defer server.Close()
err := Run(GinkgoT().Context(), Options{
BaseURL: server.URL + "/v1",
In: strings.NewReader(""),
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no chat models are installed"))
Expect(err.Error()).To(ContainSubstring("local-ai models install <model>"))
})
It("returns an actionable error when multiple models are available without a selection", func() {
server := chatTestServer([]string{"alpha", "beta"}, nil)
defer server.Close()
err := Run(GinkgoT().Context(), Options{
BaseURL: server.URL + "/v1",
In: strings.NewReader(""),
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("multiple models are available"))
Expect(err.Error()).To(ContainSubstring("--model"))
Expect(err.Error()).To(ContainSubstring("alpha"))
Expect(err.Error()).To(ContainSubstring("beta"))
})
It("lists and switches models inside the chat", func() {
requestedModels := []string{}
server := chatTestServer([]string{"alpha", "beta"}, func(model string) {
requestedModels = append(requestedModels, model)
})
defer server.Close()
var out bytes.Buffer
err := Run(GinkgoT().Context(), Options{
Model: "alpha",
BaseURL: server.URL + "/v1",
In: strings.NewReader("/models\n/model beta\nhello\n/exit\n"),
Out: &out,
})
Expect(err).ToNot(HaveOccurred())
Expect(out.String()).To(ContainSubstring("* alpha"))
Expect(out.String()).To(ContainSubstring(" beta"))
Expect(out.String()).To(ContainSubstring("switched to beta; conversation cleared"))
Expect(requestedModels).To(Equal([]string{"beta"}))
})
})
func chatTestServer(models []string, onChat func(model string)) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models":
w.Header().Set("Content-Type", "application/json")
writeResponse(w, `{"object":"list","data":[`)
for i, model := range models {
if i > 0 {
writeResponse(w, ",")
}
writeResponsef(w, `{"id":%q,"object":"model"}`, model)
}
writeResponse(w, `]}`)
case "/v1/chat/completions":
var body struct {
Model string `json:"model"`
}
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
if onChat != nil {
onChat(body.Model)
}
w.Header().Set("Content-Type", "text/event-stream")
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n\n")
writeResponse(w, "data: [DONE]\n\n")
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func writeResponse(w io.Writer, text string) {
_, err := fmt.Fprint(w, text)
Expect(err).ToNot(HaveOccurred())
}
func writeResponsef(w io.Writer, format string, args ...any) {
_, err := fmt.Fprintf(w, format, args...)
Expect(err).ToNot(HaveOccurred())
}

View File

@@ -1,114 +0,0 @@
package chat
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strings"
openai "github.com/sashabaranov/go-openai"
)
type chatClient interface {
ListModels(ctx context.Context) ([]string, error)
StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error)
}
type localAIChatClient struct {
client *openai.Client
}
func newLocalAIChatClient(baseURL string, apiKey string) *localAIChatClient {
cfg := openai.DefaultConfig(apiKey)
cfg.BaseURL = baseURL
return &localAIChatClient{client: openai.NewClientWithConfig(cfg)}
}
func (c *localAIChatClient) ListModels(ctx context.Context) ([]string, error) {
resp, err := c.client.ListModels(ctx)
if err != nil {
return nil, err
}
models := make([]string, 0, len(resp.Models))
for _, model := range resp.Models {
if model.ID != "" {
models = append(models, model.ID)
}
}
sort.Strings(models)
return models, nil
}
func (c *localAIChatClient) StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: model,
Messages: openAIChatMessages(messages),
})
if err != nil {
return "", friendlyChatError(err, model)
}
defer func() {
_ = stream.Close()
}()
var answer strings.Builder
for {
resp, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return answer.String(), friendlyChatError(err, model)
}
if len(resp.Choices) == 0 {
continue
}
token := resp.Choices[0].Delta.Content
if token == "" {
continue
}
answer.WriteString(token)
if _, err := fmt.Fprint(out, token); err != nil {
return answer.String(), err
}
}
return answer.String(), nil
}
func openAIChatMessages(messages []chatMessage) []openai.ChatCompletionMessage {
converted := make([]openai.ChatCompletionMessage, len(messages))
for i, message := range messages {
converted[i] = openai.ChatCompletionMessage{
Role: message.Role,
Content: message.Content,
}
}
return converted
}
func friendlyChatError(err error, model string) error {
var apiErr *openai.APIError
if errors.As(err, &apiErr) {
switch apiErr.HTTPStatusCode {
case 404:
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
case 403:
return fmt.Errorf("model %q is disabled. Enable it from LocalAI settings or choose another model with `/model <name>`", model)
}
if apiErr.Message != "" {
return errors.New(apiErr.Message)
}
}
msg := err.Error()
if strings.Contains(msg, "model") && strings.Contains(msg, "not found") {
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
}
return err
}

View File

@@ -1,17 +0,0 @@
package chat
import "strings"
func formatChatModelList(models []string, current string) string {
var b strings.Builder
for _, model := range models {
prefix := " "
if model == current {
prefix = "* "
}
b.WriteString(prefix)
b.WriteString(model)
b.WriteByte('\n')
}
return b.String()
}

153
core/cli/chat/paths.go Normal file
View File

@@ -0,0 +1,153 @@
package chat
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// stateDirMode matches the mode nib uses for the same directory. The directory
// holds an API key, so it stays owner-only.
const stateDirMode = 0o700
// configFileMode keeps the config owner-only: nib stores the user's API key in
// it alongside the keys written here.
const configFileMode = 0o600
// StateDir resolves where the chat agent keeps its config, plugins, and
// skills. This is user-scoped rather than server-scoped: chat is a client that
// may target a remote LocalAI, so it does not belong under LOCALAI_CONFIG_DIR.
func StateDir(override string) (string, error) {
if override != "" {
return override, nil
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "localai", "chat"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolving home directory for the agent state dir: %w", err)
}
return filepath.Join(home, ".config", "localai", "chat"), nil
}
// ConfigPath is the agent's config file inside dir.
func ConfigPath(dir string) string { return filepath.Join(dir, "config.yaml") }
// EnsureStateDir creates dir and, on first run only, seeds a config file
// pointing at baseURL. It deliberately does not seed a model: a baked-in model
// name goes stale as soon as the user installs a different one.
//
// The config file is machine-managed from here on: nib rewrites it whenever it
// self-configures, so hand-written comments in it do not survive.
func EnsureStateDir(dir, baseURL string) error {
if err := os.MkdirAll(dir, stateDirMode); err != nil {
return fmt.Errorf("creating agent state dir %s: %w", dir, err)
}
path := ConfigPath(dir)
if _, err := os.Stat(path); err == nil {
return nil // already configured; never overwrite the user's file
} else if !os.IsNotExist(err) {
return fmt.Errorf("checking agent config %s: %w", path, err)
}
seed := map[string]string{"base_url": baseURL}
data, err := yaml.Marshal(seed)
if err != nil {
return fmt.Errorf("encoding seed agent config: %w", err)
}
if err := writeConfigFile(path, data); err != nil {
return fmt.Errorf("writing seed agent config: %w", err)
}
return nil
}
// PersistModel records the chosen model in the agent config, preserving every
// other key the user may have set, including the api_key nib writes there.
//
// The file is machine-managed: this overlays the model onto the parsed keys and
// re-marshals, which drops comments. That is deliberate rather than an
// oversight, because nib's own save path does the same thing and would erase
// them on its next write regardless.
func PersistModel(dir, model string) error {
// PersistModel is callable before EnsureStateDir, so it cannot assume the
// directory exists.
if err := os.MkdirAll(dir, stateDirMode); err != nil {
return fmt.Errorf("creating agent state dir %s: %w", dir, err)
}
path := ConfigPath(dir)
values := map[string]any{}
// #nosec G304 -- path is the fixed config.yaml name under the user-selected
// chat state directory; selecting that directory is the documented override.
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading agent config %s: %w", path, err)
}
if err == nil {
if err := yaml.Unmarshal(data, &values); err != nil {
return fmt.Errorf("parsing agent config %s: %w", path, err)
}
}
values["model"] = model
out, err := yaml.Marshal(values)
if err != nil {
return fmt.Errorf("encoding agent config: %w", err)
}
if err := writeConfigFile(path, out); err != nil {
return fmt.Errorf("writing agent config: %w", err)
}
return nil
}
// writeConfigFile replaces path with data atomically: it writes a temporary
// file next to the target and renames it over the target. Writing the target in
// place would truncate it first, so an interrupted or out-of-disk write would
// leave a half-written config and destroy the api_key nib keeps in the same
// file. The temporary file must share the directory because rename is only
// atomic within one filesystem.
func writeConfigFile(path string, data []byte) error {
dir := filepath.Dir(path)
// A randomized name rather than a fixed config.yaml.tmp, so two concurrent
// writers cannot corrupt each other's temporary file.
tmp, err := os.CreateTemp(dir, "config.yaml.*.tmp")
if err != nil {
return fmt.Errorf("creating temp file in %s: %w", dir, err)
}
tmpPath := tmp.Name()
renamed := false
defer func() {
if !renamed {
// Leave no litter behind on any failure path.
_ = os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("writing %s: %w", tmpPath, err)
}
// Flush before the rename: renaming a file whose contents are still only in
// the page cache can still lose them across a crash.
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("syncing %s: %w", tmpPath, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing %s: %w", tmpPath, err)
}
// CreateTemp already asks for 0600, but the umask can only ever clear bits,
// so set the mode explicitly rather than inheriting whatever survived.
if err := os.Chmod(tmpPath, configFileMode); err != nil {
return fmt.Errorf("setting mode on %s: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("replacing %s: %w", path, err)
}
renamed = true
return nil
}

186
core/cli/chat/paths_test.go Normal file
View File

@@ -0,0 +1,186 @@
package chat
import (
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
// richConfig stands in for a config nib has already taken ownership of: a
// comment, a secret, and a nested block. A flat scalar alone would not catch a
// writer that mangles structure or drops a key it does not know about.
const richConfig = `# hand written note
base_url: http://x.invalid/v1
api_key: secret-token
mcp_servers:
files:
command: mcp-files
args:
- --root
- /tmp
`
var _ = Describe("Agent state directory", func() {
Describe("StateDir", func() {
It("prefers an explicit override", func() {
Expect(StateDir("/custom/dir")).To(Equal("/custom/dir"))
})
It("uses XDG_CONFIG_HOME when set", func() {
tmp := GinkgoT().TempDir()
GinkgoT().Setenv("XDG_CONFIG_HOME", tmp)
Expect(StateDir("")).To(Equal(filepath.Join(tmp, "localai", "chat")))
})
It("falls back to ~/.config/localai/chat", func() {
tmp := GinkgoT().TempDir()
GinkgoT().Setenv("XDG_CONFIG_HOME", "")
GinkgoT().Setenv("HOME", tmp)
Expect(StateDir("")).To(Equal(filepath.Join(tmp, ".config", "localai", "chat")))
})
It("fails when neither XDG_CONFIG_HOME nor a home directory is resolvable", func() {
GinkgoT().Setenv("XDG_CONFIG_HOME", "")
GinkgoT().Setenv("HOME", "")
dir, err := StateDir("")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("agent state dir"))
// No silent fallback to a relative path: writing an API key into the
// working directory would be worse than refusing.
Expect(dir).To(BeEmpty())
})
})
Describe("EnsureStateDir", func() {
It("creates the directory and seeds base_url on first run", func() {
dir := filepath.Join(GinkgoT().TempDir(), "chat")
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("base_url: http://127.0.0.1:8080/v1"))
// A model must NOT be seeded: it goes stale as soon as the user
// installs a different one.
Expect(string(data)).ToNot(ContainSubstring("model:"))
})
It("keeps the seeded config and its directory owner-only", func() {
dir := filepath.Join(GinkgoT().TempDir(), "chat")
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
// nib writes the user's api_key into this same file, so the modes are
// load-bearing, not cosmetic.
config, err := os.Stat(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(config.Mode().Perm()).To(Equal(os.FileMode(0o600)))
state, err := os.Stat(dir)
Expect(err).ToNot(HaveOccurred())
Expect(state.Mode().Perm()).To(Equal(os.FileMode(0o700)))
})
It("leaves an existing config byte-for-byte untouched", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
// Byte-exact against a fixture carrying a comment and a nested block:
// an implementation that "preserves" by re-marshaling through a map
// fails here rather than passing on a flat scalar.
Expect(string(data)).To(Equal(richConfig))
})
})
Describe("PersistModel", func() {
It("adds a model to an existing config, preserving other keys", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte("base_url: http://x.invalid/v1\n"), 0o600)).To(Succeed())
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("base_url: http://x.invalid/v1"))
Expect(string(data)).To(ContainSubstring("model: chosen-model"))
})
It("replaces an existing model rather than duplicating the key", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte("model: old\nbase_url: http://x.invalid/v1\n"), 0o600)).To(Succeed())
Expect(PersistModel(dir, "new")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("model: new"))
Expect(string(data)).ToNot(ContainSubstring("model: old"))
})
It("preserves secrets and nested blocks it does not understand", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(yaml.Unmarshal(data, &got)).To(Succeed())
Expect(got).To(HaveKeyWithValue("model", "chosen-model"))
Expect(got).To(HaveKeyWithValue("base_url", "http://x.invalid/v1"))
// Losing this key logs the user out of their own server.
Expect(got).To(HaveKeyWithValue("api_key", "secret-token"))
Expect(got).To(HaveKeyWithValue("mcp_servers",
HaveKeyWithValue("files", And(
HaveKeyWithValue("command", "mcp-files"),
HaveKeyWithValue("args", ConsistOf("--root", "/tmp")),
)),
))
// Documented, accepted behavior rather than an aspiration: the overlay
// re-marshals, so comments do not survive. nib's own save path erases
// them too, so preserving them here would buy nothing.
Expect(string(data)).ToNot(ContainSubstring("# hand written note"))
})
It("keeps the rewritten config owner-only and leaves no temp file behind", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
info, err := os.Stat(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600)))
// The atomic write stages through a sibling temp file; it must not
// survive a successful write.
entries, err := os.ReadDir(dir)
Expect(err).ToNot(HaveOccurred())
names := []string{}
for _, entry := range entries {
names = append(names, entry.Name())
}
Expect(names).To(ConsistOf("config.yaml"))
})
It("creates the state directory when it does not exist yet", func() {
// Task 4 may persist a picked model before anything else has run.
dir := filepath.Join(GinkgoT().TempDir(), "chat")
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("model: chosen-model"))
})
})
})

86
core/cli/chat/probe.go Normal file
View File

@@ -0,0 +1,86 @@
package chat
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
openai "github.com/sashabaranov/go-openai"
)
var (
// ErrUnreachable means nothing answered at the endpoint. Callers use this
// to decide whether offering to start a server makes sense.
ErrUnreachable = errors.New("no LocalAI server reachable")
// ErrUnauthorized means the server answered but rejected the credentials.
ErrUnauthorized = errors.New("LocalAI server rejected the API key")
)
// Probe lists the models the endpoint advertises. It classifies the two
// failures that need different advice: nothing listening, and bad credentials.
//
// The returned list is what the server advertises, verbatim and in server
// order. LocalAI happily lists non-model entries it finds in the models
// directory (stray archives, dotfiles), and guessing which advertised IDs are
// real belongs to whoever presents them, not here.
func Probe(ctx context.Context, baseURL, apiKey string) ([]string, error) {
cfg := openai.DefaultConfig(apiKey)
cfg.BaseURL = baseURL
resp, err := openai.NewClientWithConfig(cfg).ListModels(ctx)
if err != nil {
if status, answered := responseStatus(err); answered {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return nil, fmt.Errorf("%w: %w", ErrUnauthorized, err)
}
// The server answered, so it is up; surface its error as-is.
return nil, fmt.Errorf("listing models at %s: %w", baseURL, err)
}
// A caller who cancelled the probe learned nothing about the endpoint,
// so claiming it is unreachable would send them to fix a server that
// may be fine. A deadline is left alone: an endpoint that cannot answer
// within the probe's budget is unreachable for our purposes.
var urlErr *url.Error
if errors.As(err, &urlErr) && !errors.Is(err, context.Canceled) {
// Only a failure to complete the round trip means nothing is
// listening. A reply we could not parse is a different problem,
// so it falls through to the generic error below.
return nil, fmt.Errorf("%w at %s: %w", ErrUnreachable, baseURL, err)
}
return nil, fmt.Errorf("listing models at %s: %w", baseURL, err)
}
models := make([]string, 0, len(resp.Models))
for _, m := range resp.Models {
if m.ID != "" {
models = append(models, m.ID)
}
}
return models, nil
}
// responseStatus reports the HTTP status a failed call came back with, and
// whether there was one at all.
//
// go-openai splits this across two types depending on the error body, and both
// occur against a real LocalAI: it returns *openai.APIError when the body
// parses as an OpenAI error envelope, which is what LocalAI's normal error
// handler sends, and *openai.RequestError when it does not, which is what
// LocalAI sends when started with opaque errors, since that handler replies
// with a bare status and no body.
func responseStatus(err error) (int, bool) {
// *RequestError is checked first because it is the outer type when
// go-openai nests one error inside the other; the inner value in that case
// carries no status.
var reqErr *openai.RequestError
if errors.As(err, &reqErr) {
return reqErr.HTTPStatusCode, true
}
var apiErr *openai.APIError
if errors.As(err, &apiErr) {
return apiErr.HTTPStatusCode, true
}
return 0, false
}

169
core/cli/chat/probe_test.go Normal file
View File

@@ -0,0 +1,169 @@
package chat
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Probe", func() {
It("returns the advertised models", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"data": []map[string]string{
{"id": "model-a", "object": "model"},
{"id": "model-b", "object": "model"},
},
})).To(Succeed())
}))
defer srv.Close()
models, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).ToNot(HaveOccurred())
Expect(models).To(Equal([]string{"model-a", "model-b"}))
})
It("reports an unreachable server distinguishably", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := srv.URL
srv.Close() // nothing is listening now
_, err := Probe(context.Background(), url+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnreachable)).To(BeTrue(), "want ErrUnreachable, got %v", err)
})
It("reports an auth failure distinguishably", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
_, err := Probe(context.Background(), srv.URL+"/v1", "bad-key")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnauthorized)).To(BeTrue(), "want ErrUnauthorized, got %v", err)
})
// LocalAI's normal error handler replies with an OpenAI error envelope, and
// its opaque-errors handler replies with a bare status and no body. Those
// reach the client as two different go-openai types, so both have to be
// classified the same way.
It("reports an auth failure carrying an error envelope distinguishably", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
Expect(json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"message": "invalid api key", "code": http.StatusUnauthorized},
})).To(Succeed())
}))
defer srv.Close()
_, err := Probe(context.Background(), srv.URL+"/v1", "bad-key")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnauthorized)).To(BeTrue(), "want ErrUnauthorized, got %v", err)
})
It("does not call a server that answered with an error unreachable", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
_, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "a server that replied is not unreachable, got %v", err)
Expect(errors.Is(err, ErrUnauthorized)).To(BeFalse(), "500 is not an auth failure, got %v", err)
})
// Pointing chat at some other service that happens to be listening is a
// different problem from nothing listening, and needs different advice.
It("does not call a reply it could not parse unreachable", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, err := w.Write([]byte("<html><body>not LocalAI</body></html>"))
Expect(err).ToNot(HaveOccurred())
}))
defer srv.Close()
_, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "something answered, got %v", err)
})
It("returns every advertised id, including ones that are not models", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"data": []map[string]string{
{"id": "zeta", "object": "model"},
{"id": ".gitignore", "object": "model"},
{"id": "alpha", "object": "model"},
{"id": "voice.tar.bz2", "object": "model"},
},
})).To(Succeed())
}))
defer srv.Close()
// Verbatim and in server order: deciding which of these are real, and
// what order to show them in, belongs to the caller.
models, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).ToNot(HaveOccurred())
Expect(models).To(Equal([]string{"zeta", ".gitignore", "alpha", "voice.tar.bz2"}))
})
It("stops early when the context is already cancelled", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []any{}})).To(Succeed())
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := Probe(ctx, srv.URL+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "want the cancellation preserved, got %v", err)
// A cancelled probe learned nothing about the endpoint, so it must not
// send the caller off to start a server that may already be running.
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "cancelling is not a verdict on the server, got %v", err)
})
It("reports a server that never answers as unreachable", func() {
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
}))
defer srv.Close()
defer close(release)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := Probe(ctx, srv.URL+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnreachable)).To(BeTrue(), "want ErrUnreachable, got %v", err)
Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue(), "want the deadline preserved, got %v", err)
})
It("returns an empty list when the server has no models", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []any{}})).To(Succeed())
}))
defer srv.Close()
models, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).ToNot(HaveOccurred())
Expect(models).To(BeEmpty())
})
})

95
core/cli/chat/resolve.go Normal file
View File

@@ -0,0 +1,95 @@
package chat
import (
"errors"
"fmt"
"slices"
"sort"
"strings"
"github.com/mudler/xlog"
)
// ModelChooser asks the user to pick one of models. It is nil when the session
// is not interactive.
type ModelChooser func(models []string) (string, error)
// ModelRequest is everything model resolution needs.
type ModelRequest struct {
Flag string // --model
Configured string // model recorded in the agent config
Available []string // models the server advertises
StateDir string // where an interactive choice is persisted
Choose ModelChooser // nil means non-interactive
// Notify reports a problem that is worth telling the user about but not
// worth failing over. Nil discards it. It exists because the one such
// problem here, a choice that could not be saved, changes what the user
// should expect next: they will be asked again. A log line does not reach
// them, since the agent runs at log level error by default.
Notify func(message string)
}
// ResolveModel picks the model for this invocation. A flag or a configured
// value wins outright and is not persisted; only an interactive choice is
// written back, so the prompt appears at most once.
//
// Available is used exactly as the server gave it. LocalAI advertises stray
// files it finds in the models directory alongside real models, but real model
// IDs contain dots too (lfm2.5-8b-a1b), so any client-side "looks like a
// filename" heuristic would eventually hide a model the user has. Deciding
// which advertised IDs are real belongs to the endpoint, not to a guess here.
func ResolveModel(req ModelRequest) (string, error) {
if req.Flag != "" {
return req.Flag, nil
}
if req.Configured != "" {
return req.Configured, nil
}
// The server's /v1/models ordering is not stable between calls, so sort
// before showing or listing: the same number must mean the same model on
// the next run. Sort a copy; the caller's slice is not ours to reorder.
available := append([]string(nil), req.Available...)
sort.Strings(available)
switch len(available) {
case 0:
return "", errors.New("the LocalAI server has no models installed. Install one with 'local-ai models install <name>', then run 'local-ai chat' again")
case 1:
return available[0], nil
}
if req.Choose == nil {
return "", fmt.Errorf(
"several models are available; pick one with --model. Available: %s",
strings.Join(available, ", "),
)
}
chosen, err := req.Choose(available)
if err != nil {
return "", err
}
// Choose is an interface, so its answer is checked rather than trusted.
// What comes back is persisted and every later run starts against it, so a
// chooser that returns an empty string or a name of its own would record a
// model the server never offered and there would be nothing left to catch
// it.
if !slices.Contains(available, chosen) {
return "", fmt.Errorf(
"the model chooser answered %q, which is not one of the available models: %s",
chosen, strings.Join(available, ", "),
)
}
if req.StateDir != "" {
if err := PersistModel(req.StateDir, chosen); err != nil {
// A failure to remember the choice must not block the session: the
// user picked a model, so honour it and say what will happen.
xlog.Warn("could not save the model choice", "error", err, "model", chosen)
if req.Notify != nil {
req.Notify(fmt.Sprintf("Your choice of %s could not be saved, so this question comes back next time: %v", chosen, err))
}
}
}
return chosen, nil
}

View File

@@ -0,0 +1,156 @@
package chat
import (
"errors"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ResolveModel", func() {
It("prefers the flag over everything", func() {
got, err := ResolveModel(ModelRequest{
Flag: "from-flag",
Configured: "from-config",
Available: []string{"a", "b"},
})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("from-flag"))
})
It("uses the configured model when no flag is given", func() {
got, err := ResolveModel(ModelRequest{
Configured: "from-config",
Available: []string{"a", "b"},
})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("from-config"))
})
It("auto-selects when the server offers exactly one model", func() {
got, err := ResolveModel(ModelRequest{Available: []string{"only-one"}})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("only-one"))
})
It("errors and lists the options when several models exist and there is no chooser", func() {
_, err := ResolveModel(ModelRequest{Available: []string{"a", "b"}})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("a"))
Expect(err.Error()).To(ContainSubstring("b"))
Expect(err.Error()).To(ContainSubstring("--model"))
})
It("sorts before offering, so the same number means the same model next run", func() {
var offered []string
available := []string{"zeta", "alpha", "mid"}
_, err := ResolveModel(ModelRequest{
Available: available,
StateDir: GinkgoT().TempDir(),
Choose: func(models []string) (string, error) {
offered = models
return models[0], nil
},
})
Expect(err).ToNot(HaveOccurred())
// The server's /v1/models ordering is unstable between calls.
Expect(offered).To(Equal([]string{"alpha", "mid", "zeta"}))
// Sorting must happen on a copy: the caller still owns this slice, and
// reordering it under them would move whatever they index into it.
Expect(available).To(Equal([]string{"zeta", "alpha", "mid"}))
})
It("lists models in sorted order in the several-models error", func() {
_, err := ResolveModel(ModelRequest{Available: []string{"zeta", "alpha"}})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("alpha, zeta"))
})
It("asks the chooser when several models exist, and persists the answer", func() {
dir := GinkgoT().TempDir()
got, err := ResolveModel(ModelRequest{
Available: []string{"a", "b"},
StateDir: dir,
Choose: func(models []string) (string, error) { return models[1], nil },
})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("b"))
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("model: b"))
})
// The answer is persisted and every later run starts against it, and
// ModelChooser is exported, so the invariant has to hold for choosers this
// package did not write.
DescribeTable("refuses an answer the chooser was not offered",
func(answer string) {
dir := GinkgoT().TempDir()
got, err := ResolveModel(ModelRequest{
Available: []string{"alpha", "zeta"},
StateDir: dir,
Choose: func([]string) (string, error) { return answer, nil },
})
Expect(err).To(HaveOccurred())
Expect(got).To(BeEmpty())
Expect(err.Error()).To(ContainSubstring("alpha, zeta"))
_, statErr := os.Stat(ConfigPath(dir))
Expect(os.IsNotExist(statErr)).To(BeTrue(), "nothing may be recorded for an answer that was refused")
},
Entry("nothing at all", ""),
Entry("a model the server never offered", "gamma"),
Entry("an offered model with stray whitespace", " alpha"),
Entry("an offered model in the wrong case", "Alpha"),
)
It("notifies, and still honours the choice, when it cannot be persisted", func() {
dir := GinkgoT().TempDir()
// A directory where the config file belongs: the write fails for any
// user, including root.
Expect(os.MkdirAll(ConfigPath(dir), 0o700)).To(Succeed())
var notices []string
got, err := ResolveModel(ModelRequest{
Available: []string{"a", "b"},
StateDir: dir,
Choose: func(models []string) (string, error) { return models[0], nil },
Notify: func(message string) { notices = append(notices, message) },
})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("a"))
Expect(notices).To(HaveLen(1))
Expect(notices[0]).To(ContainSubstring("a"))
Expect(notices[0]).To(ContainSubstring("could not be saved"))
})
It("says nothing when the choice was saved", func() {
var notices []string
_, err := ResolveModel(ModelRequest{
Available: []string{"a", "b"},
StateDir: GinkgoT().TempDir(),
Choose: func(models []string) (string, error) { return models[0], nil },
Notify: func(message string) { notices = append(notices, message) },
})
Expect(err).ToNot(HaveOccurred())
Expect(notices).To(BeEmpty())
})
It("propagates a chooser cancellation", func() {
cancelled := errors.New("cancelled")
_, err := ResolveModel(ModelRequest{
Available: []string{"a", "b"},
StateDir: GinkgoT().TempDir(),
Choose: func([]string) (string, error) { return "", cancelled },
})
Expect(errors.Is(err, cancelled)).To(BeTrue())
})
It("errors with an install hint when the server has no models", func() {
_, err := ResolveModel(ModelRequest{Available: nil})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("local-ai models install"))
})
})

475
core/cli/chat/run.go Normal file
View File

@@ -0,0 +1,475 @@
package chat
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/mudler/nib/app"
nibcmd "github.com/mudler/nib/cmd"
nibconfig "github.com/mudler/nib/config"
nibtypes "github.com/mudler/nib/types"
"golang.org/x/term"
)
// Options is everything the chat command passes down from its flags.
type Options struct {
Args []string // forwarded to the agent verbatim
Endpoint string // the server root, e.g. http://127.0.0.1:8080
BaseURL string // the API base, e.g. http://127.0.0.1:8080/v1
APIKey string
Model string
StateDir string
TraceDir string
Yolo bool
// ProbeTimeout bounds each check of the server. Zero means
// defaultProbeTimeout.
ProbeTimeout time.Duration
In io.Reader
Out io.Writer
ErrOut io.Writer
}
// ExitStatus reports the status the process should exit with for an agent run
// that failed, and whether err is such a failure.
//
// nib writes what went wrong to the error stream itself and hands back nothing
// but a code, so an error that satisfies this has already been explained to the
// user and must not be reported a second time. The refusal to open a
// full-screen session on a stdin that cannot be read arrives this way, and it
// is the one a user is most likely to meet: 'echo q | local-ai chat' names
// --cli, and burying that under a second message would hide the fix.
func ExitStatus(err error) (int, bool) {
var exit app.ExitError
if errors.As(err, &exit) {
return exit.Code, true
}
return 0, false
}
// shutdownSignals end the session. SIGHUP is one of them because this is a
// terminal program: once the terminal is gone there is nobody left to talk to,
// and a server started for the session has to go with it.
var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP}
// shutdownContext derives a context that is cancelled when the process is
// asked to stop.
//
// Without it a signal kills this process where it stands, skipping every
// deferred call, and a 'local-ai run' started for the session is reparented to
// init with nothing left that knows to shut it down. An interactive Ctrl+C is
// safe on its own, because the child shares this process' foreground process
// group and the terminal signals all of it, but a SIGTERM from a supervisor or
// a script reaches only this process.
//
// Since nib v0.5.1 cancelling this context does end the session: RunTUI passes
// it to bubbletea, which unwinds the program and reports the context's own
// error. The server is still stopped on cancellation rather than on the way
// out (see runSession), because registering here removes SIGHUP's default
// terminate disposition, and a guarantee about a server this process owns is
// not worth resting on how promptly a third party unwinds its interface.
//
// A handler rather than SysProcAttr.Pdeathsig on the child: Pdeathsig is
// Linux-only, and in Go it is delivered when the OS thread that forked exits
// rather than when the process does, so it can fire on a perfectly healthy
// parent. Setpgid is not an alternative either, since taking the child out of
// the foreground process group is what would break the Ctrl+C that works
// today. SIGKILL stays uncovered, as it must: nothing in the process can
// observe it.
func shutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, shutdownSignals...)
}
// Run starts the agent: resolve where state lives, make sure a server is
// reachable, pick a model, then hand off to nib.
func Run(ctx context.Context, opts Options) error {
ctx, stop := shutdownContext(ctx)
defer stop()
p, err := prepare(ctx, opts, isTerminal(opts.In))
if err != nil {
return err
}
// A server this process started belongs to this session, and Stop is
// nil-safe and idempotent, so one defer covers both cases and costs nothing
// when runSession has already stopped it.
defer p.server.Stop()
return runSession(ctx, p.server, func(ctx context.Context) error {
return runAgent(ctx, p.dir, p.model, opts)
})
}
// runSession hands the terminal to agent, and stops a server started for this
// session as soon as the context is cancelled rather than when agent returns.
//
// The difference matters because the deferred Stop in Run is only reached once
// agent returns, and how long that takes is nib's business rather than ours.
// nib v0.5.1 does unwind the TUI on a cancelled context, so it does return; a
// SIGHUP no longer leaves the interface on screen with the server behind it,
// which it did before, when bubbletea's own SIGINT and SIGTERM handler was the
// only thing that ever quit the program and registering for SIGHUP had removed
// the default disposition that used to end the process. Watching the context
// keeps the guarantee independent of what the agent does with it.
func runSession(ctx context.Context, server *StartedServer, agent func(context.Context) error) error {
returned := make(chan struct{})
defer close(returned)
go func() {
select {
case <-ctx.Done():
server.Stop()
case <-returned:
}
}()
return agent(ctx)
}
// preparation is what the agent needs once the environment is ready: where its
// state lives, which model to talk to, and the server this process started on
// the user's behalf, if any.
type preparation struct {
dir string
model string
server *StartedServer
}
// prepare does everything that has to happen before the agent takes over the
// terminal. It is split out of Run because all of it is testable and none of
// what follows is: once app.Run has the terminal there is no seam left.
//
// interactive says whether there is a user to prompt. It is a parameter rather
// than a second read of opts.In so the prompts can be driven over a pipe.
func prepare(ctx context.Context, opts Options, interactive bool) (_ *preparation, err error) {
dir, dirErr := StateDir(opts.StateDir)
if dirErr != nil {
return nil, dirErr
}
if err := EnsureStateDir(dir, opts.BaseURL); err != nil {
return nil, err
}
if isLocalOnlyArgs(opts.Args) {
return &preparation{dir: dir}, nil
}
// One prompter for every question this run asks; see its doc comment for
// why the reader cannot be rebuilt per question.
var prompts *prompter
if interactive {
prompts = newPrompter(opts.In, opts.ErrOut)
}
var started *StartedServer
defer func() {
// Nothing after the spawn may leave a server behind: the caller only
// learns about it through a successful return.
if err != nil {
started.Stop()
}
}()
models, err := probeModels(ctx, opts)
if err != nil {
if errors.Is(err, ErrUnauthorized) {
return nil, fmt.Errorf("the LocalAI server at %s rejected the API key. Pass --api-key or set LOCALAI_API_KEY", opts.Endpoint)
}
if !errors.Is(err, ErrUnreachable) {
return nil, err
}
var confirm Confirmer
if interactive {
confirm = prompts.yesNo
}
var startErr error
started, startErr = OfferToStart(ctx, StartOptions{
Endpoint: opts.Endpoint,
Confirm: confirm,
Stderr: opts.ErrOut,
})
if startErr != nil {
err = startErr
if errors.Is(startErr, ErrDeclined) {
err = fmt.Errorf("no LocalAI server at %s. Start one with 'local-ai run', or point elsewhere with --endpoint", opts.Endpoint)
}
return nil, err
}
say(opts.ErrOut, "Started a temporary LocalAI server; it stops when you exit. Use 'local-ai run' for a persistent one.\n")
if models, err = probeModels(ctx, opts); err != nil {
return nil, err
}
}
var chooser ModelChooser
if interactive {
chooser = prompts.choose
}
model, err := ResolveModel(ModelRequest{
Flag: opts.Model,
Configured: configuredModel(dir),
Available: models,
StateDir: dir,
Choose: chooser,
Notify: func(message string) { say(opts.ErrOut, "%s\n", message) },
})
if err != nil {
return nil, err
}
return &preparation{dir: dir, model: model, server: started}, nil
}
func runAgent(ctx context.Context, dir, model string, opts Options) error {
return app.Run(ctx, agentOptions(dir, model, opts))
}
// agentOptions builds the request handed to nib. It is split out of runAgent
// because app.Run takes the terminal and cannot be called from a test, while
// what is asked of it is exactly the part worth pinning.
//
// The stream fields are the interesting ones, and they are not symmetric.
//
// nib reads a non-nil stream as "the embedder wants this used", and refuses
// every mode but --cli when such a stream is not a terminal, because the
// full-screen interface renders on /dev/tty and would otherwise ignore it in
// silence. Nil means "not injected": nib falls back to the process stream and
// behaves as standalone nib does.
//
// Stdin is passed through as it comes. A piped or redirected stdin really is
// ignored by the interface, so the refusal is the honest answer there, and it
// is the one users meet: 'echo q | local-ai chat' says to re-run with --cli
// rather than opening a full-screen session that will never read the question.
//
// Stdout is different, and the process stream is deliberately sent as nil. The
// interface does write to stdout even when it is a pipe: that is the whole of
// nib's shell-capture idiom, out=$(local-ai chat --height 50%), which is what
// the Ctrl+Space widget emitted by --init is built on. Injecting os.Stdout
// there would refuse the widget for a stream nib was going to use anyway.
//
// The test is identity with os.Stdout rather than whether it happens to be a
// terminal, which means a shell redirect goes the same way as the widget:
// 'local-ai chat > out.txt' no longer refuses either, and renders on /dev/tty
// with the capture line landing in the file. That is not a second decision, it
// is the same one. Both are the process stdout as the shell handed it over,
// differing only in being a pipe rather than a regular file, which nib's gate
// does not look at and should not. Refusing one would refuse the other.
//
// What stays injected, and so stays subject to the refusal, is a writer some
// in-process caller chose for itself rather than inherited: a bytes.Buffer, or
// an *os.File it opened. The specs rely on that.
//
// Stderr is never gated by nib, so it is passed through unchanged.
//
// The config values go through Overrides rather than Defaults, and that is not
// a detail. Defaults are seeds: they sit BENEATH the config file, so the file
// silently undoes them. Everything here is a decision this invocation already
// made on the user's behalf, and a flag that the file can undo is not a flag.
// It was not a rare case either, since EnsureStateDir writes base_url on the
// first run and an interactive choice writes model, so from the second run on
// the file carried a value for both and --endpoint and --model did nothing.
//
// The one asymmetry to plan around is that nib cannot tell "set to the zero
// value" from "not set", so an override only ever raises a field. --yolo can
// turn approval off, but nothing on the command line can turn it back on over
// an approval_mode: auto in the file; that needs a config edit. Same shape for
// the strings, which is what makes an unset --api-key or --trace-dir leave the
// file's value standing, as it should.
//
// nib's own --trace-dir and --yolo, and their NIB_TRACE_DIR and NIB_YOLO twins,
// are resolved after the config load and so still outrank these. That is
// deliberate upstream: they are instructions to nib rather than ambient
// environment.
func agentOptions(dir, model string, opts Options) app.Options {
// Model is the model this run resolved, which already prefers --model and
// falls back to the file's own model, so the override restates the file's
// value rather than fighting it whenever no flag was given.
//
// BaseURL is the endpoint this run probed, offered to start a server for,
// and seeded the config with. Handing nib a different one is precisely the
// split that made --endpoint a no-op, so the agent talks to the server
// LocalAI checked. Pointing somewhere else for good is LOCALAI_CHAT_ENDPOINT
// or --endpoint, not a hand-edited base_url the probe never reads.
//
// APIKey and TraceDir are the flags as given, empty when they were not, and
// an empty override leaves the file alone. TraceDir is runtime-only in nib
// (yaml:"-"), so no file value exists for it to beat today; it belongs here
// with the other flags rather than one rung down for a reason that could
// quietly stop being true.
overrides := nibtypes.Config{
Model: model,
APIKey: opts.APIKey,
BaseURL: opts.BaseURL,
TraceDir: opts.TraceDir,
}
if opts.Yolo {
overrides.ApprovalMode = "auto"
}
return app.Options{
Args: opts.Args,
ProgramName: "local-ai chat",
BaseDir: dir,
Overrides: overrides,
SkipSetup: true,
SkipBareEnv: true,
Stdin: opts.In,
Stdout: ownStdout(opts.Out),
Stderr: opts.ErrOut,
}
}
// ownStdout reports the writer as nib's own rather than as an injected one when
// it is the process stdout, by answering nil for it. See agentOptions for why
// that distinction is the difference between a working Ctrl+Space widget and a
// refused one.
func ownStdout(w io.Writer) io.Writer {
if f, ok := w.(*os.File); ok && f == os.Stdout {
return nil
}
return w
}
// defaultProbeTimeout bounds a check of the server. Listing models is cheap,
// so this is long enough that a loaded server is never given up on and short
// enough that a hung one does not leave the user staring at nothing.
const defaultProbeTimeout = 30 * time.Second
// probeModels lists what the endpoint offers, under a budget.
func probeModels(ctx context.Context, opts Options) ([]string, error) {
timeout := opts.ProbeTimeout
if timeout <= 0 {
timeout = defaultProbeTimeout
}
// A real deadline rather than a cancel plus a timer. Probe reads
// context.Canceled as "the caller gave up", which is a statement about the
// caller and not about the endpoint, and only a deadline as "nothing
// answered in time". Expiring the budget as a cancellation would stop
// ErrUnreachable firing for precisely the hung servers that the offer to
// start one exists for.
probeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return Probe(probeCtx, opts.BaseURL, opts.APIKey)
}
// isLocalOnlyArgs reports whether the forwarded arguments do their work
// without ever reaching a model, in which case demanding a running server (and
// offering to start one) would be an obstacle rather than a service.
//
// Two groups qualify. The management subcommands edit nib's own state: plugin,
// skill, and the mcp verbs that add or remove configured servers, which is
// asked of nib rather than restated, because bare 'mcp' and its transport
// flags do serve the agent and do need a model. The other group is the flags
// that only print something, above all --init: its shell snippet goes into an
// rc file, typically long before any server exists.
func isLocalOnlyArgs(args []string) bool {
if len(args) == 0 {
return false
}
// A scan rather than a look at args[0]: the mode flags this command
// translates are prepended, so --init is not necessarily first. Positional
// text cannot be mistaken for a flag here, since nib ignores what is left
// after flag parsing.
for _, a := range args {
switch {
case a == "--init", a == "-init", strings.HasPrefix(a, "--init="), strings.HasPrefix(a, "-init="):
return true
case a == "--version", a == "-version":
return true
}
}
switch args[0] {
case "plugin", "skill":
return true
case "mcp":
return len(args) >= 2 && nibcmd.IsMCPManageSubcommand(args[1])
}
return false
}
// configuredModel reads the model already recorded in the agent config, if any.
func configuredModel(dir string) string {
cfg := nibconfig.LoadWith(nibconfig.LoadOptions{BaseDir: dir, SkipBareEnv: true})
return cfg.Model
}
func isTerminal(in io.Reader) bool {
f, ok := in.(*os.File)
return ok && term.IsTerminal(int(f.Fd()))
}
// say writes a line of interactive chatter: a question, or a notice about
// something that did not stop the session. A write that fails is not worth
// failing over, and when the terminal really is gone the read that follows the
// question says so.
func say(w io.Writer, format string, args ...any) {
_, _ = fmt.Fprintf(w, format, args...)
}
// prompter asks this run's questions on the user's terminal.
//
// It owns the buffered reader rather than wrapping opts.In per question,
// because bufio reads ahead: a throwaway reader for the "start a server?"
// question swallows the model choice that was typed behind it, and the next
// question then sees EOF. A real run asks both, one after the other.
type prompter struct {
in *bufio.Reader
out io.Writer
}
func newPrompter(in io.Reader, out io.Writer) *prompter {
return &prompter{in: bufio.NewReader(in), out: out}
}
// yesNo satisfies Confirmer. Anything that is not an explicit yes is a no, so
// a closed stream declines rather than proceeding on the user's behalf.
func (p *prompter) yesNo(question string) (bool, error) {
say(p.out, "%s [y/N]: ", question)
line, err := p.in.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return false, fmt.Errorf("reading the answer: %w", err)
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true, nil
}
return false, nil
}
// choose satisfies ModelChooser. It answers with a list index rather than with
// what the user typed, so the result can only ever be one of the models it was
// offered: a model name is not something to accept unvalidated here, since
// ResolveModel persists whatever comes back and every later run then starts
// against it.
func (p *prompter) choose(models []string) (string, error) {
if len(models) == 0 {
return "", errors.New("there is nothing to choose from")
}
say(p.out, "Several models are available:\n")
for i, m := range models {
say(p.out, " %d) %s\n", i+1, m)
}
say(p.out, "Pick one [1-%d]: ", len(models))
line, err := p.in.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("reading the choice: %w", err)
}
answer := strings.TrimSpace(line)
n, err := strconv.Atoi(answer)
if err != nil || n < 1 || n > len(models) {
return "", fmt.Errorf("not a valid choice: %q. Pick a number between 1 and %d, or pass --model", answer, len(models))
}
return models[n-1], nil
}

629
core/cli/chat/run_test.go Normal file
View File

@@ -0,0 +1,629 @@
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/mudler/nib/app"
nibconfig "github.com/mudler/nib/config"
nibtypes "github.com/mudler/nib/types"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// modelServer answers /v1/models with the given ids, as LocalAI does.
func modelServer(ids ...string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := make([]map[string]string, 0, len(ids))
for _, id := range ids {
data = append(data, map[string]string{"id": id, "object": "model"})
}
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})).To(Succeed())
}))
}
var _ = Describe("prepare", func() {
var (
dir string
errOut *bytes.Buffer
)
BeforeEach(func() {
dir = GinkgoT().TempDir()
errOut = &bytes.Buffer{}
})
// optionsFor points a run at srv, with no input to read: the default is a
// session nobody can be asked anything in.
optionsFor := func(srv *httptest.Server) Options {
endpoint := "http://127.0.0.1:0"
base := endpoint + "/v1"
if srv != nil {
endpoint, base = srv.URL, srv.URL+"/v1"
}
return Options{
Endpoint: endpoint,
BaseURL: base,
StateDir: dir,
In: strings.NewReader(""),
Out: &bytes.Buffer{},
ErrOut: errOut,
}
}
It("uses the only model the server offers", func() {
srv := modelServer("the-only-model")
defer srv.Close()
p, err := prepare(context.Background(), optionsFor(srv), false)
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(Equal("the-only-model"))
Expect(p.dir).To(Equal(dir))
Expect(p.server).To(BeNil(), "nothing was started, so nothing is owned")
})
It("seeds the agent config with the endpoint on first run", func() {
srv := modelServer("m")
defer srv.Close()
_, err := prepare(context.Background(), optionsFor(srv), false)
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring(srv.URL + "/v1"))
})
It("lets --model win over what the server offers", func() {
srv := modelServer("a", "b")
defer srv.Close()
opts := optionsFor(srv)
opts.Model = "not-listed-yet"
p, err := prepare(context.Background(), opts, false)
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(Equal("not-listed-yet"))
})
It("advises about the API key when the server rejects it", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
_, err := prepare(context.Background(), optionsFor(srv), false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("--api-key"))
Expect(err.Error()).To(ContainSubstring(srv.URL))
})
// Not interactive means nobody can answer the offer, so the advice has to
// stand on its own.
It("advises how to start a server when none is reachable", func() {
srv := modelServer()
url := srv.URL
srv.Close() // nothing is listening now
opts := optionsFor(nil)
opts.Endpoint, opts.BaseURL = url, url+"/v1"
_, err := prepare(context.Background(), opts, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("local-ai run"))
Expect(err.Error()).To(ContainSubstring(url))
})
// A server that accepts the connection and then never replies is the case
// the offer to start one exists for, so the budget has to expire as a
// deadline: Probe reads a cancellation as "the caller gave up" and refuses
// to call the endpoint unreachable on the strength of it.
It("treats a server that never answers as one that is not there", func(ctx SpecContext) {
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
}))
defer srv.Close()
defer close(release)
opts := optionsFor(srv)
opts.ProbeTimeout = 100 * time.Millisecond
_, err := prepare(context.Background(), opts, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("local-ai run"), "want the offer-a-server advice, got %v", err)
}, SpecTimeout(30*time.Second))
It("asks which model to use and remembers the answer", func() {
srv := modelServer("zeta", "alpha")
defer srv.Close()
opts := optionsFor(srv)
opts.In = strings.NewReader("2\n")
p, err := prepare(context.Background(), opts, true)
Expect(err).ToNot(HaveOccurred())
// The list is sorted before it is shown, so 2 is zeta, not the second
// thing the server happened to name.
Expect(p.model).To(Equal("zeta"))
Expect(errOut.String()).To(ContainSubstring("1) alpha"))
Expect(errOut.String()).To(ContainSubstring("2) zeta"))
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("zeta"))
})
// The choice is prompted for once and remembered. When remembering it fails
// the user is about to be asked again on every future run, so they have to
// be told here: a log line is invisible at the default log level.
It("says so on the prompt when the choice cannot be remembered", func() {
srv := modelServer("zeta", "alpha")
defer srv.Close()
// A directory where the config file belongs: writable state dir,
// unwritable config, on any platform and as any user.
Expect(os.MkdirAll(ConfigPath(dir), 0o700)).To(Succeed())
opts := optionsFor(srv)
opts.In = strings.NewReader("1\n")
p, err := prepare(context.Background(), opts, true)
// Failing to remember the choice must not cost the user their session.
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(Equal("alpha"))
Expect(errOut.String()).To(ContainSubstring("could not be saved"), "the user has to learn they will be asked again")
})
It("does not ask again once a model is recorded", func() {
srv := modelServer("zeta", "alpha")
defer srv.Close()
Expect(PersistModel(dir, "alpha")).To(Succeed())
opts := optionsFor(srv)
opts.In = strings.NewReader("") // an answer would have nothing to read
p, err := prepare(context.Background(), opts, true)
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(Equal("alpha"))
Expect(errOut.String()).To(BeEmpty())
})
It("says what to install when the server has no models", func() {
srv := modelServer()
defer srv.Close()
_, err := prepare(context.Background(), optionsFor(srv), false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("models install"))
})
Describe("arguments that only touch local state", func() {
unreachable := func(args ...string) Options {
opts := optionsFor(nil) // port 0: nothing can ever answer here
opts.Args = args
return opts
}
DescribeTable("skips the server entirely",
func(args ...string) {
p, err := prepare(context.Background(), unreachable(args...), false)
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(BeEmpty())
Expect(p.server).To(BeNil())
},
Entry("plugin", "plugin", "list"),
Entry("skill", "skill", "list"),
Entry("mcp add", "mcp", "add", "srv"),
Entry("mcp list", "mcp", "list"),
// The shell snippet is what a user puts in their rc file, long
// before any server exists.
Entry("the shell integration script", "--init", "zsh"),
Entry("the version", "--version"),
)
// Bare 'mcp' and its transport flags serve the agent over MCP, so they
// need a model like any other session. Only the verbs that edit the
// configured servers are local.
DescribeTable("still needs a server",
func(args ...string) {
_, err := prepare(context.Background(), unreachable(args...), false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("local-ai run"))
},
Entry("mcp over stdio", "mcp", "--stdio"),
Entry("bare mcp", "mcp"),
)
})
// A reader per question would read ahead into a buffer it then discards, so
// the second question would see EOF whenever both answers were typed ahead.
// That is the shape of a real run: the offer to start a server is followed
// by the model prompt.
It("keeps reading answers from the same stream across questions", func() {
out := &bytes.Buffer{}
p := newPrompter(strings.NewReader("y\n2\n"), out)
yes, err := p.yesNo("Start one now?")
Expect(err).ToNot(HaveOccurred())
Expect(yes).To(BeTrue())
chosen, err := p.choose([]string{"alpha", "zeta"})
Expect(err).ToNot(HaveOccurred())
Expect(chosen).To(Equal("zeta"))
})
// Whatever the chooser returns is persisted and used for every later run,
// so an answer that is not one of the offered models must never come back
// as one.
Describe("the model prompt", func() {
offered := []string{"alpha", "zeta"}
DescribeTable("refuses an answer that is not one of the numbers shown",
func(answer string) {
chosen, err := newPrompter(strings.NewReader(answer), &bytes.Buffer{}).choose(offered)
Expect(err).To(HaveOccurred())
Expect(chosen).To(BeEmpty())
},
Entry("nothing at all", ""),
Entry("a blank line", "\n"),
Entry("only spaces", " \n"),
Entry("zero", "0\n"),
Entry("past the end", "3\n"),
Entry("negative", "-1\n"),
Entry("a model name", "zeta\n"),
Entry("a number with a suffix", "1x\n"),
)
It("says how to answer when the answer was not a number", func() {
_, err := newPrompter(strings.NewReader("banana\n"), &bytes.Buffer{}).choose(offered)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("between 1 and 2"))
Expect(err.Error()).To(ContainSubstring("--model"))
})
It("returns the model shown against the number", func() {
chosen, err := newPrompter(strings.NewReader("1\n"), &bytes.Buffer{}).choose(offered)
Expect(err).ToNot(HaveOccurred())
Expect(chosen).To(Equal("alpha"))
})
It("refuses to ask when there is nothing to offer", func() {
chosen, err := newPrompter(strings.NewReader("1\n"), &bytes.Buffer{}).choose(nil)
Expect(err).To(HaveOccurred())
Expect(chosen).To(BeEmpty())
})
})
// A server started for this session is stopped by a deferred call, which a
// signal skips: the process dies where it stands and leaves 'local-ai run'
// reparented to init.
Describe("shutdown signals", func() {
It("ends the session when the terminal goes away", func() {
ctx, stop := shutdownContext(context.Background())
defer stop()
self, err := os.FindProcess(os.Getpid())
Expect(err).ToNot(HaveOccurred())
Expect(self.Signal(syscall.SIGHUP)).To(Succeed())
Eventually(ctx.Done()).WithTimeout(5 * time.Second).Should(BeClosed())
Expect(ctx.Err()).To(MatchError(context.Canceled))
})
// SIGINT and SIGTERM cannot be delivered here to prove the same thing:
// Ginkgo registers for both to abort the suite, and a signal goes to
// every registered listener.
It("also listens for an interrupt and a terminate", func() {
Expect(shutdownSignals).To(ContainElements(os.Signal(os.Interrupt), os.Signal(syscall.SIGTERM)))
})
})
// Cancelling the context does unwind nib's TUI since v0.5.1, but how long
// that takes is nib's business, and the deferred Stop in Run is only reached
// once the agent returns. A server this process started is ours to end, so
// the guarantee is made here instead, where it does not depend on the agent
// at all. Before v0.5.1 there was no guarantee to be had on the SIGHUP path:
// bubbletea's own SIGINT and SIGTERM handler was the only thing that ever
// quit the program, and registering for SIGHUP took away the default
// disposition that used to end the process.
Describe("runSession", func() {
It("stops the session's server on cancellation, without waiting for the agent", func() {
server, proc := stoppableServer()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := runSession(ctx, server, func(ctx context.Context) error {
cancel()
Eventually(func() int32 { return proc.interrupts.Load() }).
WithTimeout(5 * time.Second).
Should(BeNumerically(">", 0), "the server has to be stopped while the agent is still running")
return nil
})
Expect(err).ToNot(HaveOccurred())
Expect(proc.lastSignal.Load()).To(Equal(os.Interrupt))
})
It("leaves the server alone for as long as the session lasts", func() {
server, proc := stoppableServer()
Expect(runSession(context.Background(), server, func(context.Context) error {
return nil
})).To(Succeed())
Expect(proc.interrupts.Load()).To(BeZero())
Expect(proc.kills.Load()).To(BeZero())
})
It("returns what the agent returned", func() {
failed := errors.New("the agent gave up")
server, _ := stoppableServer()
Expect(runSession(context.Background(), server, func(context.Context) error {
return failed
})).To(MatchError(failed))
})
// Most sessions run against a server the user already had, and there is
// nothing to stop then.
It("copes with a session that started no server", func() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
Expect(runSession(ctx, nil, func(context.Context) error {
return nil
})).To(Succeed())
})
})
// Which streams reach nib decides two user-visible behaviours at once, and
// they pull in opposite directions, so both are pinned here rather than left
// to whoever next edits the literal.
//
// nib refuses every mode but --cli when a stream it was handed is not a
// terminal. That refusal is wanted for stdin, where it is what tells someone
// piping a question to re-run with --cli. It is not wanted for the process
// stdout, where it would refuse the Ctrl+Space widget that --init emits:
// out=$(local-ai chat --height 50%) puts a pipe on stdout by construction,
// and writing the chosen command into that pipe is the entire point.
Describe("agentOptions", func() {
// optionsWithStreams is a request that differs from the next only in
// what it was told to read and write.
optionsWithStreams := func(in io.Reader, out, errOut io.Writer) Options {
return Options{
BaseURL: "http://127.0.0.1:8080/v1",
In: in,
Out: out,
ErrOut: errOut,
}
}
Describe("stdout", func() {
// The regression this exists to catch: reinstating
// 'Stdout: opts.Out' breaks Ctrl+Space and nothing else notices.
It("hands nib nothing for the process stdout, so the capture widget is not refused", func() {
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
Expect(o.Stdout).To(BeNil(), "injecting os.Stdout is what refuses out=$(local-ai chat)")
})
It("keeps a stdout the caller chose, which the refusal still guards", func() {
out := &bytes.Buffer{}
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, out, os.Stderr))
Expect(o.Stdout).To(BeIdenticalTo(out))
})
// Being an *os.File is not what makes a stream nib's own; being the
// process stdout is. This is a file an in-process caller opened for
// itself, not one a shell redirect handed over as stdout, which
// still arrives as os.Stdout and is still nil-ed. It was never going
// to receive the interface, so it stays injected and stays refused.
It("keeps a file that is not the process stdout", func() {
f, err := os.CreateTemp(GinkgoT().TempDir(), "captured")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(f.Close)
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, f, os.Stderr))
Expect(o.Stdout).To(BeIdenticalTo(f))
})
})
Describe("stdin", func() {
// The opposite regression: nilling stdin the way stdout is nilled
// would silently drop the refusal that names --cli.
It("hands the process stdin over, so a piped session is still refused", func() {
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
Expect(o.Stdin).To(BeIdenticalTo(os.Stdin))
})
It("hands over a stdin the caller chose", func() {
in := strings.NewReader("a question")
o := agentOptions(dir, "a-model", optionsWithStreams(in, os.Stdout, os.Stderr))
Expect(o.Stdin).To(BeIdenticalTo(in))
})
})
// nib gates stdin and stdout and nothing else, so there is no reason to
// hide the error stream from it.
It("hands the error stream over whatever it is", func() {
errOut := &bytes.Buffer{}
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, errOut))
Expect(o.Stderr).To(BeIdenticalTo(errOut))
o = agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
Expect(o.Stderr).To(BeIdenticalTo(os.Stderr))
})
It("names the command a user would type, not the binary nib ships as", func() {
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
Expect(o.ProgramName).To(Equal("local-ai chat"),
"the --init widget invokes this name, so a user has to be able to run it")
})
It("carries the resolved session through to nib", func() {
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.Args = []string{"--cli"}
opts.APIKey = "a-key"
opts.TraceDir = "/traces"
o := agentOptions(dir, "the-model", opts)
Expect(o.Args).To(Equal([]string{"--cli"}))
Expect(o.BaseDir).To(Equal(dir))
Expect(o.Overrides.Model).To(Equal("the-model"))
Expect(o.Overrides.APIKey).To(Equal("a-key"))
Expect(o.Overrides.BaseURL).To(Equal("http://127.0.0.1:8080/v1"))
Expect(o.Overrides.TraceDir).To(Equal("/traces"))
// The model and the server are settled before nib starts, and the
// bare MODEL and API_KEY variables belong to some other tool.
Expect(o.SkipSetup).To(BeTrue())
Expect(o.SkipBareEnv).To(BeTrue())
})
// Defaults sit beneath the config file. Anything routed through them is
// accepted from the command line and then thrown away the moment the
// file carries the same key, which is the normal state rather than an
// edge case. Nothing this command resolves belongs there, so the channel
// stays empty and this says so: it is what fails if the block is moved
// back a rung.
It("seeds nothing, because a seed is not a flag", func() {
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.APIKey = "a-key"
opts.TraceDir = "/traces"
opts.Yolo = true
Expect(agentOptions(dir, "the-model", opts).Defaults).To(Equal(nibtypes.Config{}),
"Defaults lose to the config file, so a value placed there is a flag that does nothing")
})
It("asks for automatic approval only when --yolo was given", func() {
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(BeEmpty())
opts.Yolo = true
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(Equal("auto"))
})
// The specs above pin what is handed over. These pin what nib does with
// it, which is the part that was wrong: every value below reached
// app.Options intact and was then discarded by the config load, so a
// spec that stops at the struct cannot see the bug. Resolving the config
// the way app.Run resolves it can.
Describe("the config nib actually resolves", func() {
// writeConfig puts a config file where nib will read it, with values
// that disagree with every flag under test.
writeConfig := func(body string) {
Expect(os.WriteFile(ConfigPath(dir), []byte(body), 0o600)).To(Succeed())
}
// resolve loads the config exactly as app.Run does, so the precedence
// under test is nib's own rather than a restatement of it here.
resolve := func(o app.Options) nibtypes.Config {
return nibconfig.LoadWith(nibconfig.LoadOptions{
BaseDir: o.BaseDir,
Defaults: o.Defaults,
Overrides: o.Overrides,
SkipBareEnv: o.SkipBareEnv,
})
}
It("sends the requests to the endpoint the flag named, not the one on disk", func() {
writeConfig("base_url: http://127.0.0.1:9999/v1\n")
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.BaseURL = "http://127.0.0.1:8080/v1"
cfg := resolve(agentOptions(dir, "a-model", opts))
Expect(cfg.BaseURL).To(Equal("http://127.0.0.1:8080/v1"),
"--endpoint probed 8080; every turn has to go there too")
})
It("uses the model the flag named, not the one the picker recorded", func() {
writeConfig("model: recorded-model\n")
cfg := resolve(agentOptions(dir, "flag-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)))
Expect(cfg.Model).To(Equal("flag-model"))
})
It("uses the key the flag named, not the one nib saved", func() {
writeConfig("api_key: saved-key\n")
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.APIKey = "flag-key"
cfg := resolve(agentOptions(dir, "a-model", opts))
Expect(cfg.APIKey).To(Equal("flag-key"))
})
It("turns approval off for --yolo even when the file demands it", func() {
writeConfig("approval_mode: prompt\n")
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.Yolo = true
cfg := resolve(agentOptions(dir, "a-model", opts))
Expect(cfg.ApprovalMode).To(Equal("auto"))
})
// The other half of the same rule, and the reason an unset flag is
// not a demand for the empty string: an override only ever raises a
// field, so what the user configured survives a run that said
// nothing about it.
It("leaves what the file configured alone when no flag was given", func() {
writeConfig("api_key: saved-key\napproval_mode: prompt\n")
cfg := resolve(agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)))
Expect(cfg.APIKey).To(Equal("saved-key"))
Expect(cfg.ApprovalMode).To(Equal("prompt"))
})
})
})
// nib reports its own failures on the error stream and returns nothing but
// a status, so anything that reaches here as one has already been explained
// once. The refusal to open a full-screen session on a stdin that cannot be
// read is the one users meet: 'echo q | local-ai chat' names --cli, and a
// second message on top would bury the fix.
Describe("ExitStatus", func() {
It("recognises a status the agent already explained", func() {
code, reported := ExitStatus(app.ExitError{Code: 2})
Expect(reported).To(BeTrue())
Expect(code).To(Equal(2))
})
It("finds one that has been wrapped", func() {
code, reported := ExitStatus(fmt.Errorf("running the agent: %w", app.ExitError{Code: 1}))
Expect(reported).To(BeTrue())
Expect(code).To(Equal(1))
})
It("leaves an ordinary failure to be reported", func() {
_, reported := ExitStatus(errors.New("no LocalAI server at http://127.0.0.1:8080"))
Expect(reported).To(BeFalse())
})
It("says nothing about a run that succeeded", func() {
_, reported := ExitStatus(nil)
Expect(reported).To(BeFalse())
})
})
It("reports a state dir it cannot create", func() {
blocked := filepath.Join(dir, "a-file")
Expect(os.WriteFile(blocked, []byte("not a dir"), 0o600)).To(Succeed())
opts := optionsFor(nil)
opts.StateDir = filepath.Join(blocked, "chat")
_, err := prepare(context.Background(), opts, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("agent state dir"))
})
})

276
core/cli/chat/server.go Normal file
View File

@@ -0,0 +1,276 @@
package chat
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/pkg/httpclient"
)
// ErrDeclined means no server was started, either because the session is not
// interactive or because the user said no.
var ErrDeclined = errors.New("no server started")
// errServerExited means the process we spawned died before it ever reported
// ready, so there is no point in polling out the rest of the budget.
var errServerExited = errors.New("the LocalAI server exited before it became ready")
const (
// defaultReadyTimeout bounds the wait for a freshly spawned server. A cold
// start probes hardware and may pull a backend, so the budget is generous.
defaultReadyTimeout = 2 * time.Minute
// readyPollInterval is how long to wait between readiness polls.
readyPollInterval = 500 * time.Millisecond
// readyProbeTimeout bounds a single readiness request, so one connection
// that hangs cannot swallow the whole budget.
readyProbeTimeout = 5 * time.Second
// shutdownGrace is how long a server we started gets to unload models and
// stop its backends after SIGINT before it is killed outright.
shutdownGrace = 10 * time.Second
// childOutputDrainDelay bounds how long cmd.Wait keeps copying the child's
// output after the child itself has exited.
//
// This is not a theoretical guard for LocalAI. 'local-ai run' spawns backend
// subprocesses, and they inherit the write end of the pipe exec created for
// the child's stderr. A backend that outlives its parent holds that pipe
// open, so an unbounded cmd.Wait would block on the copy goroutine long
// after the server itself is gone: exited would never close, Stop would burn
// its whole grace period even on a clean shutdown, and the waiter goroutine
// would leak.
//
// The value is long enough that a legitimate final burst of logs is never
// truncated even on a loaded machine, where the copy itself takes
// microseconds. It must stay strictly below shutdownGrace: at or above it,
// every wedged-pipe shutdown would exhaust the grace period and then SIGKILL
// a process that had already exited cleanly.
childOutputDrainDelay = 5 * time.Second
)
// Confirmer asks a yes/no question. Nil means the session is not interactive.
type Confirmer func(question string) (bool, error)
// StartOptions configures OfferToStart.
type StartOptions struct {
// Endpoint is the address the user expected a server on, used in the
// question and polled for readiness. This is the endpoint root, not the
// /v1 API base URL: readiness is served at the root.
Endpoint string
// Confirm asks whether to start a server. Nil means never start.
Confirm Confirmer
// Stderr receives the child's output.
Stderr io.Writer
// Executable overrides the binary to run. Empty means os.Executable().
Executable string
// ReadyTimeout bounds the wait for readiness. Zero means defaultReadyTimeout.
ReadyTimeout time.Duration
}
// StartedServer is a server this process started and is responsible for.
type StartedServer struct {
// exited is closed once the child has been reaped. One background waiter
// owns cmd.Wait: it may only be called once, and it is what closes the
// pipes exec created for Stdout/Stderr and joins the goroutines copying
// them, so calling os.Process.Wait directly instead would leak both.
exited chan struct{}
// waitErr is the child's exit status. It is written before exited is
// closed and must only be read after that channel is observed closed.
waitErr error
// proc is the child. It is an interface rather than *os.Process so that
// Stop's contract, in particular that the child is asked to stop exactly
// once however often Stop is called, can be pinned without a live process
// to signal. Nil means nothing was ever started.
proc processControl
stopOnce sync.Once
}
// processControl is the part of *os.Process that Stop needs.
//
// One interface rather than a pair of independent function fields: two fields
// can be wired to each other's operation, or one left nil, and no test can tell,
// because a fake satisfies any combination. There is nothing to swap or forget
// here, since the sole implementation is the real process and the method names
// carry the meaning.
type processControl interface {
Signal(os.Signal) error
Kill() error
}
// *os.Process satisfies processControl unmodified, so production needs no
// adapter and no nil branch: the wiring is a single assignment.
var _ processControl = (*os.Process)(nil)
// newServerCommand builds the child process. Split out from OfferToStart so the
// process' configuration can be asserted on without spawning anything.
func newServerCommand(bin string, stderr io.Writer) *exec.Cmd {
cmd := exec.Command(bin, "run")
// Stdin is left nil, so the child gets /dev/null: it is a background
// server, and sharing the terminal would have it stealing keystrokes from
// the agent.
cmd.Stdout = stderr // the child's logs are diagnostics, not chat output
cmd.Stderr = stderr
// Bound the wait for the child's output pipes; see childOutputDrainDelay.
cmd.WaitDelay = childOutputDrainDelay
return cmd
}
// OfferToStart asks whether to start a LocalAI server and, if allowed, spawns
// one and waits for it to report ready.
//
// A child process rather than an in-process boot: RunCMD.Run installs its own
// signal handling and blocks until shutdown, so re-entering it from a chat
// session would entangle two lifecycles in one process.
func OfferToStart(ctx context.Context, opts StartOptions) (*StartedServer, error) {
if opts.Confirm == nil {
// Not interactive. Spawning a server nobody asked for is the one thing
// this function must never do: in CI, in a pipeline, or under a
// supervisor there is no one to see it or shut it down.
return nil, ErrDeclined
}
ok, err := opts.Confirm(fmt.Sprintf("No LocalAI server at %s. Start one now?", opts.Endpoint))
if err != nil {
return nil, fmt.Errorf("asking whether to start a server: %w", err)
}
if !ok {
return nil, ErrDeclined
}
bin := opts.Executable
if bin == "" {
if bin, err = os.Executable(); err != nil {
return nil, fmt.Errorf("locating the local-ai binary: %w", err)
}
}
cmd := newServerCommand(bin, opts.Stderr)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("starting a LocalAI server with %s: %w", bin, err)
}
s := &StartedServer{exited: make(chan struct{}), proc: cmd.Process}
go func() {
s.waitErr = cmd.Wait()
close(s.exited)
}()
timeout := opts.ReadyTimeout
if timeout <= 0 {
timeout = defaultReadyTimeout
}
if err := waitReady(ctx, opts.Endpoint, timeout, s.exited); err != nil {
if errors.Is(err, errServerExited) {
// Safe to read: errServerExited is only returned once exited has
// been observed closed, which happens after waitErr is written.
err = describeExit(err, s.waitErr)
}
s.Stop()
return nil, fmt.Errorf("%w. Run 'local-ai run' in another terminal to see why it did not come up", err)
}
return s, nil
}
// describeExit adds what is known about how the child died to exitErr, without
// putting os/exec's plumbing in front of the user.
//
// waitErr is exec.ErrWaitDelay when the child exited cleanly but something it
// spawned still held its output pipe open past childOutputDrainDelay. The
// sentinel's own text names the WaitDelay field, which is meaningless to a
// user, so it is translated. Nothing is swallowed: os/exec only substitutes
// ErrWaitDelay when the process itself exited without an error of its own (see
// Cmd.Wait, "Report an error from the copying goroutines only if the program
// otherwise exited normally"), so it can never stand in for an *ExitError.
func describeExit(exitErr, waitErr error) error {
switch {
case waitErr == nil:
return exitErr
case errors.Is(waitErr, exec.ErrWaitDelay):
return fmt.Errorf("%w, and left a subprocess of its own still running", exitErr)
default:
return fmt.Errorf("%w: %w", exitErr, waitErr)
}
}
// Stop terminates the server this process started, giving it a chance to shut
// down cleanly first. It is safe to call on a nil or never-started server, and
// safe to call more than once.
func (s *StartedServer) Stop() {
if s == nil || s.proc == nil {
return
}
s.stopOnce.Do(func() {
// SIGINT rather than SIGKILL: local-ai run installs its own handler and
// needs it to unload models and stop backend subprocesses. Killing it
// outright would strand those children.
_ = s.proc.Signal(os.Interrupt)
select {
case <-s.exited:
case <-time.After(shutdownGrace):
// It ignored the interrupt or wedged on the way down. The user is
// waiting on their shell prompt, so stop being polite.
_ = s.proc.Kill()
}
})
}
// waitReady polls the endpoint's /readyz until the server reports ready, the
// budget expires, the caller gives up, or exited signals that the process we
// are waiting on is gone. A nil exited channel means there is no process to
// watch.
//
// Readiness lives on the endpoint ROOT, not under the /v1 API base URL, and it
// answers 503 for as long as startup is still in progress.
func waitReady(ctx context.Context, endpoint string, timeout time.Duration, exited <-chan struct{}) error {
url := strings.TrimSuffix(endpoint, "/") + "/readyz"
// A real deadline rather than context.WithCancel plus a timer: the latter
// expires as context.Canceled, which every classifier here reads as "the
// caller gave up" rather than "the endpoint never answered".
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client := httpclient.NewWithTimeout(readyProbeTimeout)
ticker := time.NewTicker(readyPollInterval)
defer ticker.Stop()
for {
select {
case <-exited:
return errServerExited
case <-waitCtx.Done():
// Distinguish our budget from the caller's: only ours is advice
// about the server.
if err := ctx.Err(); err != nil {
return err
}
return fmt.Errorf("the LocalAI server did not become ready within %s", timeout)
case <-ticker.C:
}
req, err := http.NewRequestWithContext(waitCtx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("building the readiness request for %s: %w", url, err)
}
resp, err := client.Do(req)
if err != nil {
continue // nothing listening yet
}
// Drain before closing so the next poll can reuse the connection
// instead of opening a socket every 500ms for two minutes.
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
// Anything else means startup is still in progress; keep polling.
}
}

View File

@@ -0,0 +1,375 @@
package chat
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// unusedPort is a loopback address nothing listens on, used wherever a spec
// needs a readiness poll to keep failing. Port 1 is privileged, so no test
// process could have bound it.
const unusedPort = "http://127.0.0.1:1"
var _ = Describe("OfferToStart", func() {
It("never spawns anything when there is no confirmer", func() {
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: "http://127.0.0.1:59999",
Confirm: nil,
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrDeclined)).To(BeTrue(), "want ErrDeclined, got %v", err)
Expect(started).To(BeNil())
})
It("does not spawn when the user declines", func() {
asked := false
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: "http://127.0.0.1:59999",
Confirm: func(string) (bool, error) {
asked = true
return false, nil
},
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(asked).To(BeTrue(), "the user should have been asked")
Expect(errors.Is(err, ErrDeclined)).To(BeTrue())
Expect(started).To(BeNil())
})
It("names the endpoint in the question", func() {
var question string
_, _ = OfferToStart(context.Background(), StartOptions{
Endpoint: "http://example.invalid:9090",
Confirm: func(q string) (bool, error) {
question = q
return false, nil
},
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(question).To(ContainSubstring("http://example.invalid:9090"))
})
It("propagates a confirmer error", func() {
boom := errors.New("boom")
_, err := OfferToStart(context.Background(), StartOptions{
Endpoint: "http://127.0.0.1:59999",
Confirm: func(string) (bool, error) { return false, boom },
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(errors.Is(err, boom)).To(BeTrue())
})
It("reports which binary it failed to launch", func() {
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: "http://127.0.0.1:59999",
Confirm: func(string) (bool, error) { return true, nil },
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(started).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("starting a LocalAI server")))
Expect(err).To(MatchError(ContainSubstring("/nonexistent/binary-that-must-not-run")))
})
It("stops waiting as soon as the process it started exits", func() {
// A harmless no-op binary rather than a real server: this exercises the
// early-exit path without starting LocalAI, binding a port, or running
// 'local-ai run'. Without early-exit detection the call would sit here
// polling until ReadyTimeout.
bin, lookErr := exec.LookPath("true")
if lookErr != nil {
Skip("no 'true' binary on PATH to stand in for a server that dies at once")
}
start := time.Now()
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: unusedPort,
Confirm: func(string) (bool, error) { return true, nil },
Stderr: io.Discard,
Executable: bin,
ReadyTimeout: 30 * time.Second,
})
Expect(started).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
Expect(time.Since(start)).To(BeNumerically("<", 10*time.Second),
"the wait should end with the process, not with the readiness budget")
})
It("gives up on a child whose grandchildren still hold its output pipe", func() {
// The real LocalAI shape: 'local-ai run' exits but a backend
// subprocess it spawned inherited the stderr pipe and keeps it open.
// Without cmd.WaitDelay, cmd.Wait blocks on the copy goroutine, exited
// never closes, and the readiness wait runs out the full budget instead
// of reporting that the server died.
sh, lookErr := exec.LookPath("sh")
if lookErr != nil {
Skip("no 'sh' binary on PATH to stand in for a server with a lingering child")
}
dir := GinkgoT().TempDir()
pidFile := filepath.Join(dir, "grandchild.pid")
script := filepath.Join(dir, "server-with-lingering-child")
// #nosec G306 -- this has to be executable to stand in for a binary.
Expect(os.WriteFile(script,
[]byte("#!"+sh+"\nsleep 30 &\necho $! > "+pidFile+"\nexit 0\n"),
0o700)).To(Succeed())
// Reap the grandchild whatever happens: it outlives its own parent by
// design, so nothing else will clean it up.
DeferCleanup(func() {
raw, err := os.ReadFile(pidFile)
if err != nil {
return
}
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil {
return
}
proc, err := os.FindProcess(pid)
if err != nil {
return
}
_ = proc.Kill()
_, _ = proc.Wait()
})
start := time.Now()
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: unusedPort,
Confirm: func(string) (bool, error) { return true, nil },
Stderr: io.Discard,
Executable: script,
ReadyTimeout: 25 * time.Second,
})
elapsed := time.Since(start)
Expect(started).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")),
"an unbounded cmd.Wait would report a readiness timeout instead")
Expect(elapsed).To(BeNumerically("<", 20*time.Second),
"the wait must be bounded by the output drain, not by the readiness budget")
// This is the case where cmd.Wait returns exec.ErrWaitDelay, whose own
// text names a struct field of os/exec. Users get told what happened
// instead.
Expect(err).NotTo(MatchError(ContainSubstring("WaitDelay")),
"os/exec plumbing must not reach the user")
Expect(err).NotTo(MatchError(ContainSubstring("exec:")))
Expect(err).To(MatchError(ContainSubstring("left a subprocess of its own still running")))
})
It("reports the exit status of a server that failed outright", func() {
// The counterpart to the case above: translating ErrWaitDelay must not
// cost a real exit status, which is the one diagnostic worth having.
bin, lookErr := exec.LookPath("false")
if lookErr != nil {
Skip("no 'false' binary on PATH to stand in for a server that fails")
}
_, err := OfferToStart(context.Background(), StartOptions{
Endpoint: unusedPort,
Confirm: func(string) (bool, error) { return true, nil },
Stderr: io.Discard,
Executable: bin,
ReadyTimeout: 30 * time.Second,
})
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
Expect(err).To(MatchError(ContainSubstring("exit status 1")))
})
})
var _ = Describe("StartedServer.Stop", func() {
It("is a no-op on a server that was never started", func() {
var nilServer *StartedServer
Expect(nilServer.Stop).NotTo(Panic())
Expect((&StartedServer{}).Stop).NotTo(Panic())
})
It("interrupts the child exactly once however often it is called", func() {
s, proc := stoppableServer()
s.Stop()
s.Stop()
s.Stop()
Expect(proc.interrupts.Load()).To(Equal(int32(1)),
"a second Stop must not signal the child again")
Expect(proc.kills.Load()).To(BeZero(), "a child that already exited must not be killed")
})
It("interrupts the child exactly once when called concurrently", func() {
// The realistic double-Stop: a deferred Stop on the way out racing the
// signal handler that also owns shutting the server down.
const callers = 8
s, proc := stoppableServer()
var wg sync.WaitGroup
wg.Add(callers)
for range callers {
go func() {
defer GinkgoRecover()
defer wg.Done()
s.Stop()
}()
}
wg.Wait()
Expect(proc.interrupts.Load()).To(Equal(int32(1)))
Expect(proc.kills.Load()).To(BeZero())
})
It("asks the child to interrupt rather than killing it outright", func() {
// The escalation order is the whole point of the grace period: SIGKILL
// first would strand the backend subprocesses local-ai run owns.
s, proc := stoppableServer()
s.Stop()
Expect(proc.lastSignal.Load()).To(Equal(os.Interrupt))
Expect(proc.kills.Load()).To(BeZero())
})
})
// countingProcess stands in for the *os.Process that Stop drives, recording
// what it was asked to do.
type countingProcess struct {
interrupts atomic.Int32
kills atomic.Int32
lastSignal atomic.Value
}
func (p *countingProcess) Signal(sig os.Signal) error {
p.interrupts.Add(1)
p.lastSignal.Store(sig)
return nil
}
func (p *countingProcess) Kill() error {
p.kills.Add(1)
return nil
}
// stoppableServer builds a StartedServer whose child has already exited, driven
// by a countingProcess rather than a real one. Nothing is spawned.
func stoppableServer() (*StartedServer, *countingProcess) {
proc := &countingProcess{}
exited := make(chan struct{})
close(exited)
return &StartedServer{exited: exited, proc: proc}, proc
}
var _ = Describe("newServerCommand", func() {
It("bounds how long it will wait for the child's output pipes", func() {
cmd := newServerCommand("/nonexistent/binary-that-must-not-run", io.Discard)
// An unbounded wait is the failure mode: backend subprocesses inherit
// the child's stderr pipe and can hold it open long after the server
// itself is gone.
Expect(cmd.WaitDelay).To(BeNumerically(">", 0), "cmd.Wait must not be unbounded")
Expect(cmd.WaitDelay).To(BeNumerically("<", shutdownGrace),
"a drain longer than the shutdown grace would kill a cleanly exited server")
})
It("runs the server subcommand without giving it the terminal", func() {
cmd := newServerCommand("/nonexistent/binary-that-must-not-run", io.Discard)
Expect(cmd.Args).To(Equal([]string{"/nonexistent/binary-that-must-not-run", "run"}))
Expect(cmd.Stdin).To(BeNil(), "the child must not compete with the agent for stdin")
Expect(cmd.Stdout).NotTo(BeNil())
Expect(cmd.Stderr).NotTo(BeNil())
})
})
var _ = Describe("waitReady", func() {
It("polls /readyz on the endpoint root and returns only once it answers 200", func() {
// readyOnPoll is deliberately above 1. A handler that answers 200 to the
// first poll cannot tell a correct implementation apart from one that
// treats 503 as ready, because both return after a single request; the
// poll count is what makes 503-as-ready observable.
const readyOnPoll = 3
var polls atomic.Int32
var paths atomic.Value
paths.Store("")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths.Store(r.URL.Path)
if polls.Add(1) < readyOnPoll {
// What LocalAI answers while startup is still in progress.
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
Expect(waitReady(context.Background(), srv.URL, 20*time.Second, nil)).To(Succeed())
Expect(paths.Load()).To(Equal("/readyz"), "readiness lives on the endpoint root, not under /v1")
Expect(polls.Load()).To(BeNumerically(">=", readyOnPoll),
"503 means startup is still in progress and must never be accepted as ready")
})
It("tolerates a trailing slash on the endpoint", func() {
var path atomic.Value
path.Store("")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path.Store(r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
Expect(waitReady(context.Background(), srv.URL+"/", 20*time.Second, nil)).To(Succeed())
Expect(path.Load()).To(Equal("/readyz"))
})
It("reports a timeout, not a cancellation, when the budget runs out", func() {
err := waitReady(context.Background(), unusedPort, 1200*time.Millisecond, nil)
Expect(err).To(HaveOccurred())
// A budget built from context.WithCancel plus a timer would surface as
// context.Canceled, which downstream code reads as "the caller gave up"
// and would stop classifying a hung server as unreachable.
Expect(errors.Is(err, context.Canceled)).To(BeFalse(), "got %v", err)
Expect(err).To(MatchError(ContainSubstring("did not become ready")))
})
It("returns the caller's cancellation when the caller gives up", func() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
defer GinkgoRecover()
time.Sleep(200 * time.Millisecond)
cancel()
}()
defer cancel()
err := waitReady(ctx, unusedPort, time.Minute, nil)
Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "got %v", err)
})
It("gives up when the process it is waiting on has exited", func() {
exited := make(chan struct{})
close(exited)
err := waitReady(context.Background(), unusedPort, time.Minute, exited)
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
})
})

View File

@@ -1,112 +0,0 @@
package chat
import (
"context"
"errors"
"fmt"
"io"
"slices"
"strings"
)
const (
chatRoleUser = "user"
chatRoleAssistant = "assistant"
)
type chatMessage struct {
Role string
Content string
}
type chatSession struct {
client chatClient
model string
models []string
messages []chatMessage
}
func newChatSession(ctx context.Context, client chatClient, requestedModel string) (*chatSession, error) {
models, err := client.ListModels(ctx)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
model, err := resolveChatModel(requestedModel, models)
if err != nil {
return nil, err
}
return &chatSession{
client: client,
model: model,
models: models,
}, nil
}
func (s *chatSession) CurrentModel() string {
return s.model
}
func (s *chatSession) Models() []string {
models := make([]string, len(s.models))
copy(models, s.models)
return models
}
func (s *chatSession) Clear() {
s.messages = nil
}
func (s *chatSession) SwitchModel(model string) error {
if !slices.Contains(s.models, model) {
return fmt.Errorf("model %q is not available. Use /models to see installed models", model)
}
s.model = model
s.Clear()
return nil
}
func (s *chatSession) Send(ctx context.Context, prompt string, out io.Writer) error {
s.messages = append(s.messages, chatMessage{
Role: chatRoleUser,
Content: prompt,
})
answer, err := s.client.StreamChat(ctx, s.model, s.messages, out)
if err != nil {
return err
}
s.messages = append(s.messages, chatMessage{
Role: chatRoleAssistant,
Content: answer,
})
return nil
}
func resolveChatModel(requested string, models []string) (string, error) {
switch {
case requested == "" && len(models) == 0:
return "", errors.New(`no chat models are installed.
Install a model first, for example:
local-ai models list
local-ai models install <model>
local-ai run
Then start a chat session:
local-ai chat --model <model>`)
case requested == "" && len(models) == 1:
return models[0], nil
case requested == "" && len(models) > 1:
var b strings.Builder
b.WriteString("multiple models are available; choose one with --model:\n")
b.WriteString(formatChatModelList(models, ""))
return "", errors.New(b.String())
case !slices.Contains(models, requested):
return "", fmt.Errorf("model %q is not available. Use `local-ai models list` and `local-ai models install <model>`, or pass an installed model with --model", requested)
default:
return requested, nil
}
}

View File

@@ -1,56 +0,0 @@
package chat
import (
"context"
"io"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Chat session", func() {
It("keeps model switching and message history out of the terminal adapter", func() {
client := &fakeChatClient{
models: []string{"alpha", "beta"},
answer: "pong",
}
session, err := newChatSession(context.Background(), client, "alpha")
Expect(err).ToNot(HaveOccurred())
Expect(session.CurrentModel()).To(Equal("alpha"))
Expect(session.SwitchModel("beta")).To(Succeed())
Expect(session.CurrentModel()).To(Equal("beta"))
Expect(session.Send(context.Background(), "ping", io.Discard)).To(Succeed())
Expect(client.requests).To(HaveLen(1))
Expect(client.requests[0].model).To(Equal("beta"))
Expect(client.requests[0].messages).To(HaveLen(1))
Expect(client.requests[0].messages[0].Content).To(Equal("ping"))
})
})
type fakeChatClient struct {
models []string
answer string
requests []fakeChatRequest
}
type fakeChatRequest struct {
model string
messages []chatMessage
}
func (c *fakeChatClient) ListModels(context.Context) ([]string, error) {
return c.models, nil
}
func (c *fakeChatClient) StreamChat(_ context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
copied := make([]chatMessage, len(messages))
copy(copied, messages)
c.requests = append(c.requests, fakeChatRequest{model: model, messages: copied})
if _, err := io.WriteString(out, c.answer); err != nil {
return "", err
}
return c.answer, nil
}

View File

@@ -1,93 +0,0 @@
package chat
import (
"bufio"
"context"
"fmt"
"io"
"strings"
)
func runTerminalChat(ctx context.Context, session *chatSession, in io.Reader, out io.Writer) error {
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
if err := writeChat(out, "LocalAI chat (%s)\n", session.CurrentModel()); err != nil {
return err
}
if err := writeChat(out, "Type /exit to quit, /clear to reset the conversation, /models to list models.\n"); err != nil {
return err
}
for {
if err := writeChat(out, "\n> "); err != nil {
return err
}
if !scanner.Scan() {
break
}
prompt := strings.TrimSpace(scanner.Text())
switch prompt {
case "":
continue
case "/bye", "/exit", "/quit":
return writeChat(out, "bye\n")
case "/clear":
session.Clear()
if err := writeChat(out, "conversation cleared\n"); err != nil {
return err
}
continue
case "/models":
if err := printChatModels(out, session.Models(), session.CurrentModel()); err != nil {
return err
}
continue
}
if nextModel, ok := strings.CutPrefix(prompt, "/model "); ok {
nextModel = strings.TrimSpace(nextModel)
if nextModel == "" {
if err := writeChat(out, "usage: /model <name>\n"); err != nil {
return err
}
continue
}
if err := session.SwitchModel(nextModel); err != nil {
if writeErr := writeChat(out, "%s\n", err); writeErr != nil {
return writeErr
}
continue
}
if err := writeChat(out, "switched to %s; conversation cleared\n", session.CurrentModel()); err != nil {
return err
}
continue
}
if err := writeChat(out, "assistant: "); err != nil {
return err
}
if err := session.Send(ctx, prompt, out); err != nil {
return err
}
if err := writeChat(out, "\n"); err != nil {
return err
}
}
return scanner.Err()
}
func printChatModels(out io.Writer, models []string, current string) error {
if len(models) == 0 {
return writeChat(out, "no models installed\n")
}
return writeChat(out, "%s", formatChatModelList(models, current))
}
func writeChat(out io.Writer, format string, args ...any) error {
_, err := fmt.Fprintf(out, format, args...)
return err
}

View File

@@ -8,18 +8,72 @@ import (
cliContext "github.com/mudler/LocalAI/core/cli/context"
)
// ChatCMD runs the built-in terminal agent. Everything after the first
// positional argument is forwarded to the agent verbatim, so its own
// subcommands (plugin, skill, mcp) and their flags work unchanged. LocalAI's
// own flags must therefore come first.
type ChatCMD struct {
Model string `short:"m" help:"Model name to use. Defaults to the only model returned by the server when exactly one is available"`
Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"`
APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"`
Model string `short:"m" help:"Model to use. Defaults to the only model the server offers, or asks when there are several"`
Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"`
APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"`
ConfigDir string `env:"LOCALAI_CHAT_CONFIG_DIR" help:"Directory holding the agent's config, plugins, and skills. Defaults to ~/.config/localai/chat" type:"path"`
TraceDir string `env:"LOCALAI_CHAT_TRACE_DIR" help:"Write a session LLM trace (NDJSON) to this directory" type:"path"`
CLI bool `help:"Run in plain CLI mode instead of the full-screen interface"`
TUI bool `help:"Force the full-screen interface"`
Height string `help:"Run as an inline drop-down of this height, e.g. '40%'"`
Tmux bool `help:"Run in a tmux split"`
NoTmux bool `name:"no-tmux" help:"Never use a tmux split, even inside tmux"`
Init string `help:"Print the shell integration script for Ctrl+Space (zsh, bash, or fish)"`
Yolo bool `env:"LOCALAI_CHAT_YOLO" help:"Auto-approve every tool call without prompting"`
Args []string `arg:"" optional:"" passthrough:"" help:"Arguments forwarded to the agent, e.g. 'plugin install <url>', 'skill list', 'mcp add'"`
}
func (c *ChatCMD) Run(ctx *cliContext.Context) error {
return chatcli.Run(context.Background(), chatcli.Options{
Model: c.Model,
BaseURL: chatAPIBaseURL(c.Endpoint),
APIKey: c.APIKey,
In: os.Stdin,
Out: os.Stdout,
err := chatcli.Run(context.Background(), chatcli.Options{
Args: c.agentArgs(),
Endpoint: c.Endpoint,
BaseURL: chatAPIBaseURL(c.Endpoint),
APIKey: c.APIKey,
Model: c.Model,
StateDir: c.ConfigDir,
TraceDir: c.TraceDir,
Yolo: c.Yolo,
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
})
// The agent explains its own failures on stderr and hands back a code, so
// carry the code out and leave the explanation to stand alone.
if code, reported := chatcli.ExitStatus(err); reported {
return ExitCodeError{Code: code}
}
return err
}
// agentArgs rebuilds the argument vector the agent expects: LocalAI's mode
// flags are declared here for discoverability and shell completion, so they
// have to be translated back into the agent's own flag names.
func (c *ChatCMD) agentArgs() []string {
var args []string
if c.CLI {
args = append(args, "--cli")
}
if c.TUI {
args = append(args, "--tui")
}
if c.Height != "" {
args = append(args, "--height", c.Height)
}
if c.Tmux {
args = append(args, "--tmux")
}
if c.NoTmux {
args = append(args, "--no-tmux")
}
if c.Init != "" {
args = append(args, "--init", c.Init)
}
return append(args, c.Args...)
}

View File

@@ -1,6 +1,10 @@
package cli
import (
"errors"
"fmt"
"github.com/alecthomas/kong"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -24,4 +28,70 @@ var _ = Describe("Chat command wiring", func() {
Expect(chatAPIBaseURL("http://127.0.0.1:8080/localai")).To(Equal("http://127.0.0.1:8080/localai/v1"))
})
})
Describe("argument parsing", func() {
parse := func(args ...string) *ChatCMD {
var cli struct {
Chat ChatCMD `cmd:""`
}
parser, err := kong.New(&cli)
Expect(err).ToNot(HaveOccurred())
_, err = parser.Parse(append([]string{"chat"}, args...))
Expect(err).ToNot(HaveOccurred())
return &cli.Chat
}
It("leaves Args empty for a bare invocation", func() {
Expect(parse().Args).To(BeEmpty())
})
It("binds flags that precede the forwarded arguments", func() {
c := parse("--endpoint", "http://host:9090", "--model", "m", "plugin", "list")
Expect(c.Endpoint).To(Equal("http://host:9090"))
Expect(c.Model).To(Equal("m"))
Expect(c.Args).To(Equal([]string{"plugin", "list"}))
})
It("forwards flags that follow the first positional to the agent", func() {
c := parse("plugin", "install", "https://example.invalid/p", "--yes")
Expect(c.Args).To(Equal([]string{"plugin", "install", "https://example.invalid/p", "--yes"}))
})
It("parses its own mode flags", func() {
c := parse("--cli")
Expect(c.CLI).To(BeTrue())
Expect(c.Args).To(BeEmpty())
})
})
// The agent prints its own diagnosis and hands back a status. main exits
// with that status and prints nothing more, so the user reads one message
// rather than an "exit status 1" stacked under it.
Describe("ExitCodeError", func() {
It("carries the status out", func() {
Expect(ExitCodeError{Code: 2}.Code).To(Equal(2))
})
It("is recognisable after wrapping", func() {
var got ExitCodeError
Expect(errors.As(fmt.Errorf("chat: %w", ExitCodeError{Code: 2}), &got)).To(BeTrue())
Expect(got.Code).To(Equal(2))
})
})
Describe("agentArgs", func() {
It("translates mode flags into the agent's own flags", func() {
c := &ChatCMD{CLI: true}
Expect(c.agentArgs()).To(Equal([]string{"--cli"}))
})
It("puts forwarded arguments after the translated flags", func() {
c := &ChatCMD{Height: "40%", Args: []string{"plugin", "list"}}
Expect(c.agentArgs()).To(Equal([]string{"--height", "40%", "plugin", "list"}))
})
It("returns nothing for a bare invocation", func() {
Expect((&ChatCMD{}).agentArgs()).To(BeEmpty())
})
})
})

View File

@@ -9,7 +9,7 @@ var CLI struct {
cliContext.Context `embed:""`
Run RunCMD `cmd:"" help:"Run LocalAI, this the default command if no other command is specified. Run 'local-ai run --help' for more information" default:"withargs"`
Chat ChatCMD `cmd:"" help:"Open an interactive chat session against a running LocalAI server"`
Chat ChatCMD `cmd:"" help:"Run the built-in terminal agent against a LocalAI server"`
Federated FederatedCLI `cmd:"" help:"Run LocalAI in federated mode"`
Models ModelsCMD `cmd:"" help:"Manage LocalAI models and definitions"`
Backends BackendsCMD `cmd:"" help:"Manage LocalAI backends and definitions"`

15
core/cli/exit.go Normal file
View File

@@ -0,0 +1,15 @@
package cli
import "fmt"
// ExitCodeError is a failure a command has already reported to the user. It
// carries nothing but the status the process should exit with, and main prints
// nothing more for it.
//
// It exists for commands that hand their terminal to something that does its
// own error reporting. Returning that subordinate's error instead would put a
// bare "exit status 1" underneath the explanation the user has just read, and
// returning nil would tell a script the run succeeded.
type ExitCodeError struct{ Code int }
func (e ExitCodeError) Error() string { return fmt.Sprintf("exit status %d", e.Code) }

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",

View File

@@ -0,0 +1,211 @@
package gallery
import (
"context"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/vram"
"github.com/mudler/xlog"
)
// EstimateInput builds the VRAM estimator's input from a gallery entry.
//
// It lives here rather than beside the HTTP handler because two callers need
// it: the handler answering one model, and the warmer below answering all of
// them ahead of time.
func EstimateInput(m *GalleryModel) vram.ModelEstimateInput {
var input vram.ModelEstimateInput
input.Size = m.Size
if repoID := extractHFRepo(m.Overrides, m.URLs); repoID != "" {
input.HFRepo = repoID
}
for _, f := range m.AdditionalFiles {
if vram.IsWeightFile(f.URI) {
input.Files = append(input.Files, vram.FileInput{URI: f.URI, Size: 0})
}
}
return input
}
// extractHFRepo finds a HuggingFace repo ID in a model's overrides or URLs.
func extractHFRepo(overrides map[string]any, urls []string) string {
if overrides != nil {
if params, ok := overrides["parameters"].(map[string]any); ok {
if modelRef, ok := params["model"].(string); ok {
if repoID, ok := vram.ExtractHFRepoID(modelRef); ok {
return repoID
}
}
}
}
for _, u := range urls {
if repoID, ok := vram.ExtractHFRepoID(u); ok {
return repoID
}
}
return ""
}
// EstimateWarmConfig bounds the background warm-up.
type EstimateWarmConfig struct {
// Limit is how many gallery entries to warm, in gallery order. Zero
// disables warming entirely. The order matters: it is the order the UI
// lists them in, so the entries a user sees first are warmed first.
Limit int
// Concurrency is how many estimates run at once. Each one can be a remote
// probe, so this is deliberately small: the point is to be finished before
// anybody looks, not to saturate the link or the upstream.
Concurrency int
// Contexts are the context lengths to estimate at. These want to match what
// the UI asks for, or the warmed entry is not the one it reads.
Contexts []uint32
}
// DefaultEstimateWarmConfig is what the server uses unless told otherwise.
//
// The limit is a deliberate compromise. Warming the whole gallery would be
// thousands of remote probes on every boot, which is rude to the upstream and
// slow to finish; warming nothing leaves the first page of the model gallery
// paying two seconds per row. A few hundred covers what anyone browses in a
// sitting, and everything past it still warms itself on first view.
var DefaultEstimateWarmConfig = EstimateWarmConfig{
Limit: 300,
Concurrency: 4,
Contexts: []uint32{8192, 16384, 32768, 65536, 131072, 262144},
}
// WarmEstimateCache fills the gallery's derived caches in the background.
//
// Two things are warmed, and they are the same cost wearing different hats.
// An estimate for an entry the server has never seen costs a network probe of
// its weight files, and describing an entry's variants costs one probe per
// build it offers. The UI asks for an estimate per row and a variant
// description per model opened, so without this the first visitor pays for
// both: ten seconds of a page filling in its own sizes, then another second
// and a half the first time they click anything.
//
// Both land in the same caches underneath, which is why one pass covers them.
//
// It returns immediately; the work happens on its own goroutine and stops when
// ctx is done. Failures are logged at debug and otherwise ignored: a warm-up
// that cannot reach an upstream must never stop the server from starting, and
// the entry it failed on simply stays cold.
func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, cfg EstimateWarmConfig) {
if cfg.Limit <= 0 || cfg.Concurrency <= 0 {
return
}
go func() {
started := time.Now()
models, err := AvailableGalleryModelsCached(galleries, systemState)
if err != nil {
xlog.Debug("VRAM estimate warm-up skipped, gallery unavailable", "error", err)
return
}
if len(models) > cfg.Limit {
models = models[:cfg.Limit]
}
if len(models) == 0 {
return
}
// The host gate the variant picker resolves against. Derived once: it
// describes this machine, not this entry, and HostResolveEnv reads the
// system state to build it.
env := HostResolveEnv(ctx, systemState)
var (
wg sync.WaitGroup
cursor = make(chan *GalleryModel)
warmed int
warmedVariants int
mu sync.Mutex
)
for i := 0; i < cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for m := range cursor {
// Per entry, not for the run: one unreachable weight file
// must not hold a worker for the whole warm-up.
entryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
input := EstimateInput(m)
if len(input.Files) > 0 || input.HFRepo != "" || input.Size != "" {
if _, err := vram.EstimateModelMultiContext(entryCtx, input, cfg.Contexts); err != nil {
xlog.Debug("VRAM estimate warm-up failed for entry", "model", m.GetName(), "error", err)
} else {
mu.Lock()
warmed++
mu.Unlock()
}
}
// Describing variants probes each build the entry offers.
// An entry that declares none costs nothing here, so this is
// gated rather than attempted and discarded.
if m.HasVariants() {
if _, err := DescribeVariants(models, m, env); err != nil {
xlog.Debug("variant warm-up failed for entry", "model", m.GetName(), "error", err)
} else {
mu.Lock()
warmedVariants++
mu.Unlock()
}
}
cancel()
}
}()
}
feed:
for _, m := range models {
select {
case <-ctx.Done():
break feed
case cursor <- m:
}
}
close(cursor)
wg.Wait()
if ctx.Err() != nil {
xlog.Debug("gallery warm-up stopped", "estimates", warmed, "variants", warmedVariants)
return
}
xlog.Info("gallery caches warmed", "estimates", warmed, "variants", warmedVariants, "of", len(models), "took", time.Since(started).Round(time.Second))
}()
}
// EstimateWarmConfigFromEnv reads the warm-up bounds from the environment,
// falling back to the defaults.
//
// LOCALAI_VRAM_WARM_LIMIT entries to warm; 0 disables the warm-up
// LOCALAI_VRAM_WARM_CONCURRENCY estimates in flight at once
//
// Env rather than a flag because it is an operational tuning knob, not part of
// what the server does: an air-gapped host wants it off, and a host behind a
// slow link wants it slower, and neither is a decision the CLI should carry.
func EstimateWarmConfigFromEnv() EstimateWarmConfig {
cfg := DefaultEstimateWarmConfig
if v, ok := os.LookupEnv("LOCALAI_VRAM_WARM_LIMIT"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
cfg.Limit = n
}
}
if v, ok := os.LookupEnv("LOCALAI_VRAM_WARM_CONCURRENCY"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
cfg.Concurrency = n
}
}
return cfg
}

View File

@@ -0,0 +1,115 @@
package gallery_test
import (
"context"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/system"
)
var _ = Describe("VRAM estimate warm-up", func() {
var state *system.SystemState
BeforeEach(func() {
dir, err := os.MkdirTemp("", "warm")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(dir) })
state, err = system.GetSystemState(system.WithModelPath(dir))
Expect(err).ToNot(HaveOccurred())
gallery.ResetGalleryModelCache()
DeferCleanup(gallery.ResetGalleryModelCache)
})
It("does nothing when disabled, and returns without blocking", func() {
cfg := gallery.DefaultEstimateWarmConfig
cfg.Limit = 0
done := make(chan struct{})
go func() {
defer close(done)
gallery.WarmEstimateCache(context.Background(), []config.Gallery{}, state, cfg)
}()
Eventually(done, "1s").Should(BeClosed())
})
It("returns immediately even when there is work to do", func() {
// The caller is a server still starting up: warming must never be on
// the path to listening.
done := make(chan struct{})
go func() {
defer close(done)
gallery.WarmEstimateCache(context.Background(), []config.Gallery{}, state, gallery.DefaultEstimateWarmConfig)
}()
Eventually(done, "1s").Should(BeClosed())
})
It("stops when its context is cancelled", func() {
ctx, cancel := context.WithCancel(context.Background())
gallery.WarmEstimateCache(ctx, []config.Gallery{}, state, gallery.DefaultEstimateWarmConfig)
cancel()
// Nothing to assert beyond not hanging or panicking: an aborted warm-up
// leaves entries cold, which is the state they were already in.
Consistently(func() bool { return true }, "100ms").Should(BeTrue())
})
Describe("configuration from the environment", func() {
AfterEach(func() {
os.Unsetenv("LOCALAI_VRAM_WARM_LIMIT")
os.Unsetenv("LOCALAI_VRAM_WARM_CONCURRENCY")
})
It("falls back to the defaults", func() {
cfg := gallery.EstimateWarmConfigFromEnv()
Expect(cfg.Limit).To(Equal(gallery.DefaultEstimateWarmConfig.Limit))
Expect(cfg.Concurrency).To(Equal(gallery.DefaultEstimateWarmConfig.Concurrency))
})
It("lets an operator turn it off entirely", func() {
os.Setenv("LOCALAI_VRAM_WARM_LIMIT", "0")
Expect(gallery.EstimateWarmConfigFromEnv().Limit).To(BeZero())
})
It("lets an operator slow it down", func() {
os.Setenv("LOCALAI_VRAM_WARM_CONCURRENCY", "1")
Expect(gallery.EstimateWarmConfigFromEnv().Concurrency).To(Equal(1))
})
It("ignores values that are not usable", func() {
os.Setenv("LOCALAI_VRAM_WARM_LIMIT", "not-a-number")
os.Setenv("LOCALAI_VRAM_WARM_CONCURRENCY", "0")
cfg := gallery.EstimateWarmConfigFromEnv()
Expect(cfg.Limit).To(Equal(gallery.DefaultEstimateWarmConfig.Limit))
// Zero workers would be a warm-up that never runs while looking
// enabled, so it keeps the default rather than honouring it.
Expect(cfg.Concurrency).To(Equal(gallery.DefaultEstimateWarmConfig.Concurrency))
})
})
It("warms variant descriptions as well as estimates", func() {
// Both are the same cost wearing different hats - a probe of an entry's
// weight files - and both land in the same caches, so a warm-up that
// covered only one would leave the first click paying for the other.
// Asserted through the shared config rather than by observing network
// calls: the gallery here is empty by design.
Expect(gallery.DefaultEstimateWarmConfig.Limit).To(BeNumerically(">", 0))
})
It("keeps the estimate contexts the UI actually asks for", func() {
// A warmed entry at the wrong context lengths is a cache the gallery
// never reads, so this pins them together.
Expect(gallery.DefaultEstimateWarmConfig.Contexts).To(ContainElements(
uint32(8192), uint32(16384), uint32(32768), uint32(65536), uint32(131072), uint32(262144),
))
})
It("bounds concurrency so a warm-up cannot saturate the link", func() {
Expect(gallery.DefaultEstimateWarmConfig.Concurrency).To(BeNumerically("<=", 8))
Expect(gallery.DefaultEstimateWarmConfig.Concurrency).To(BeNumerically(">", 0))
})
})

View File

@@ -325,10 +325,32 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
var (
availableModelsMu sync.RWMutex
availableModelsCache GalleryElements[*GalleryModel]
refreshing atomic.Bool
galleryGeneration atomic.Uint64
// Whether a load has happened, tracked apart from the slice itself. A
// gallery that legitimately holds nothing caches as an empty (often nil)
// slice, and testing the slice for nil read that as "never loaded": every
// call then took the blocking path and bumped the generation, which is the
// same cache-defeating loop the refresh interval exists to stop.
availableModelsLoaded bool
refreshing atomic.Bool
galleryGeneration atomic.Uint64
lastRefreshUnixNano atomic.Int64
)
// How often the cached model list may be refreshed from upstream.
//
// This is a floor on refresh frequency, not a TTL: the cache is served
// regardless, and this only decides how often a background re-fetch is worth
// starting. It matters far more than it looks, because a refresh bumps
// galleryGeneration, and that invalidates every VRAM estimate cache in
// pkg/vram. Refreshing on every call therefore kept those caches permanently
// cold: the gallery listing is one request but the UI asks for one VRAM
// estimate per row, so a single page view triggered dozens of refreshes and
// every estimate paid full price for a remote probe it had already made.
//
// A package variable rather than a constant so tests can drive refreshes
// without waiting.
var GalleryRefreshInterval = 5 * time.Minute
// GalleryGeneration returns a counter that increments each time the gallery
// model list is refreshed from upstream. VRAM estimation caches use this to
// invalidate entries when the gallery data changes.
@@ -352,7 +374,11 @@ func ResetGalleryModelCache() {
}
availableModelsMu.Lock()
availableModelsCache = nil
availableModelsLoaded = false
availableModelsMu.Unlock()
// Also clear the refresh stamp, or a suite that reset the cache would find
// the next refresh throttled by the previous spec's clock.
lastRefreshUnixNano.Store(0)
}
// AvailableGalleryModelsCached returns gallery models from an in-memory cache.
@@ -363,9 +389,10 @@ func ResetGalleryModelCache() {
func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryModel], error) {
availableModelsMu.RLock()
cached := availableModelsCache
loaded := availableModelsLoaded
availableModelsMu.RUnlock()
if cached != nil {
if loaded {
// Refresh installed status under write lock to avoid races with
// concurrent readers and the background refresh goroutine.
availableModelsMu.Lock()
@@ -387,8 +414,10 @@ func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *syste
availableModelsMu.Lock()
availableModelsCache = models
availableModelsLoaded = true
galleryGeneration.Add(1)
availableModelsMu.Unlock()
lastRefreshUnixNano.Store(time.Now().UnixNano())
return models, nil
}
@@ -397,9 +426,18 @@ func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *syste
// gallery model cache. Only one refresh runs at a time; concurrent calls
// are no-ops.
func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.SystemState) {
if GalleryRefreshInterval > 0 {
last := lastRefreshUnixNano.Load()
if last != 0 && time.Since(time.Unix(0, last)) < GalleryRefreshInterval {
return
}
}
if !refreshing.CompareAndSwap(false, true) {
return
}
// Stamped before the fetch rather than after, so a slow upstream cannot
// let a queue of callers each start their own refresh behind this one.
lastRefreshUnixNano.Store(time.Now().UnixNano())
go func() {
defer refreshing.Store(false)
models, err := AvailableGalleryModels(galleries, systemState)
@@ -408,12 +446,37 @@ func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.Syste
return
}
availableModelsMu.Lock()
changed := !sameModelSet(availableModelsCache, models)
availableModelsCache = models
galleryGeneration.Add(1)
availableModelsLoaded = true
// Only a real change invalidates the VRAM caches. An unchanged gallery
// re-fetched on schedule must not throw away work that is still valid,
// which is the difference between an estimate costing nothing and
// costing a network round trip.
if changed {
galleryGeneration.Add(1)
}
availableModelsMu.Unlock()
}()
}
// sameModelSet reports whether two model lists describe the same gallery, for
// the purpose of deciding whether derived caches are still valid. Names and
// order are enough: a change to an entry's files or size arrives with a new
// gallery index, and comparing every field on every entry would cost more than
// the caches save.
func sameModelSet(a, b GalleryElements[*GalleryModel]) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].GetName() != b[i].GetName() {
return false
}
}
return true
}
// List available backends
func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
return availableBackendsWithFilter(galleries, systemState, func(backend *GalleryBackend) bool {

View File

@@ -0,0 +1,80 @@
package gallery_test
import (
"os"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/system"
)
// The gallery generation counter is what every VRAM estimate cache keys on, so
// how often it moves decides whether those caches are worth having. Refreshing
// on every call kept them permanently cold: one page of the model gallery asks
// for a VRAM estimate per row, and each of those requests re-read the gallery,
// triggering a refresh that invalidated the estimate the previous row had just
// paid a network round trip for.
var _ = Describe("Gallery refresh throttling", func() {
var (
tmp *system.SystemState
galleries []config.Gallery
origInterval time.Duration
)
BeforeEach(func() {
dir, err := os.MkdirTemp("", "gallery-throttle")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(dir) })
tmp, err = system.GetSystemState(system.WithModelPath(dir))
Expect(err).ToNot(HaveOccurred())
// No upstream: the list comes back empty, which is all this needs. What
// is under test is how often a refresh is started, not what it returns.
galleries = []config.Gallery{}
origInterval = gallery.GalleryRefreshInterval
gallery.ResetGalleryModelCache()
})
AfterEach(func() {
gallery.GalleryRefreshInterval = origInterval
gallery.ResetGalleryModelCache()
})
It("does not bump the generation once per call", func() {
gallery.GalleryRefreshInterval = time.Hour
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
Expect(err).ToNot(HaveOccurred())
start := gallery.GalleryGeneration()
// Stands in for one page view: many callers in quick succession.
for i := 0; i < 30; i++ {
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
Expect(err).ToNot(HaveOccurred())
}
// Let any refresh that did start finish, so this cannot pass by racing.
Eventually(func() uint64 { return gallery.GalleryGeneration() }, "2s", "50ms").
Should(Equal(start))
})
It("still refreshes once the interval has passed", func() {
gallery.GalleryRefreshInterval = time.Millisecond
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
Expect(err).ToNot(HaveOccurred())
time.Sleep(5 * time.Millisecond)
_, err = gallery.AvailableGalleryModelsCached(galleries, tmp)
Expect(err).ToNot(HaveOccurred())
// An empty gallery refreshing to an empty gallery is unchanged, so the
// generation must hold: only a real change may invalidate the caches.
Consistently(func() uint64 { return gallery.GalleryGeneration() }, "300ms", "50ms").
Should(Equal(gallery.GalleryGeneration()))
})
})

View File

@@ -2,6 +2,7 @@ package importers
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
@@ -10,6 +11,7 @@ import (
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/functions"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
"go.yaml.in/yaml/v2"
)
@@ -115,7 +117,48 @@ func (i *DS4Importer) Import(details Details) (gallery.ModelConfig, error) {
// filename to "ds4flash.gguf" to match ds4's own convention (its CLI
// defaults to that path), so users can run the model without extra
// config.
uri := downloader.URI(details.URI)
selectedURI := details.URI
uriFilename, _ := downloader.URI(details.URI).FilenameFromUrl()
if !strings.HasSuffix(strings.ToLower(uriFilename), ".gguf") {
if details.HuggingFace == nil {
return gallery.ModelConfig{}, fmt.Errorf("cannot select a compatible GGUF from repository %q without HuggingFace file metadata", details.URI)
}
quantizations, _ := preferencesMap["quantizations"].(string)
var compatible []hfapi.ModelFile
for _, file := range details.HuggingFace.Files {
base := strings.ToLower(filepath.Base(file.Path))
if strings.HasPrefix(base, "deepseek-v4-flash-") && strings.HasSuffix(base, ".gguf") {
compatible = append(compatible, file)
}
}
if len(compatible) == 0 {
return gallery.ModelConfig{}, fmt.Errorf("no compatible GGUF found in repository %q", details.URI)
}
selected := compatible[0].URL
if quantizations != "" {
selected = compatible[len(compatible)-1].URL
matched := false
for _, quantization := range strings.Split(quantizations, ",") {
quantization = strings.ToLower(strings.TrimSpace(quantization))
for _, file := range compatible {
base := strings.ToLower(filepath.Base(file.Path))
if quantTokenMatches(base, quantization) {
selected = file.URL
matched = true
break
}
}
if matched {
break
}
}
}
selectedURI = selected
}
uri := downloader.URI(selectedURI)
cfg.Files = append(cfg.Files, gallery.File{
Filename: "ds4flash.gguf",
URI: string(uri),

View File

@@ -5,6 +5,7 @@ import (
"strings"
. "github.com/mudler/LocalAI/core/gallery/importers"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -65,5 +66,71 @@ var _ = Describe("DS4Importer", func() {
"ConfigFile must specify backend: ds4, got: %s", cfg.ConfigFile)
Expect(strings.Contains(cfg.ConfigFile, "use_tokenizer_template: true")).To(BeTrue())
})
It("preserves an explicit GGUF URI with a query string", func() {
uri := "https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/DeepSeek-V4-Flash-UD-IQ3_XXS.gguf?download=true"
cfg, err := importer.Import(Details{URI: uri})
Expect(err).NotTo(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal(uri))
})
It("selects the highest-priority requested quantization from a repository", func() {
details := Details{
URI: "huggingface://antirez/deepseek-v4-gguf",
Preferences: json.RawMessage(`{"quantizations":"UD-IQ3_XXS,IQ2_XXS"}`),
HuggingFace: &hfapi.ModelDetails{Files: []hfapi.ModelFile{
{Path: "DeepSeek-V4-Flash-IQ2_XXS.gguf", URL: "https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/DeepSeek-V4-Flash-IQ2_XXS.gguf"},
{Path: "DeepSeek-V4-Flash-UD-IQ3_XXS.gguf", URL: "https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/DeepSeek-V4-Flash-UD-IQ3_XXS.gguf"},
}},
}
cfg, err := importer.Import(details)
Expect(err).NotTo(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/DeepSeek-V4-Flash-UD-IQ3_XXS.gguf"))
})
It("falls back to the last compatible GGUF when requested quantizations do not match", func() {
details := Details{
URI: "huggingface://antirez/deepseek-v4-gguf",
Preferences: json.RawMessage(`{"quantizations":"F16"}`),
HuggingFace: &hfapi.ModelDetails{Files: []hfapi.ModelFile{
{Path: "DeepSeek-V4-Flash-BF16.gguf", URL: "https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/DeepSeek-V4-Flash-BF16.gguf"},
{Path: "README.md", URL: "https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/README.md"},
{Path: "DeepSeek-V4-Flash-Q8_0.gguf", URL: "https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/DeepSeek-V4-Flash-Q8_0.gguf"},
}},
}
cfg, err := importer.Import(details)
Expect(err).NotTo(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/DeepSeek-V4-Flash-Q8_0.gguf"))
})
It("rejects a repository URI when no HuggingFace file metadata is available", func() {
_, err := importer.Import(Details{URI: "huggingface://antirez/deepseek-v4-gguf"})
Expect(err).To(MatchError(ContainSubstring("compatible GGUF")))
})
It("rejects repository metadata without a compatible GGUF", func() {
details := Details{
URI: "huggingface://antirez/deepseek-v4-gguf",
HuggingFace: &hfapi.ModelDetails{Files: []hfapi.ModelFile{
{Path: "README.md", URL: "https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/README.md"},
{Path: "unrelated.gguf", URL: "https://huggingface.co/antirez/deepseek-v4-gguf/resolve/main/unrelated.gguf"},
}},
}
cfg, err := importer.Import(details)
Expect(err).To(MatchError(ContainSubstring("compatible GGUF")))
Expect(cfg.Files).To(BeEmpty())
})
})
})

View File

@@ -3,6 +3,7 @@ package importers
import (
"encoding/json"
"path/filepath"
"slices"
"strings"
"github.com/mudler/LocalAI/core/config"
@@ -31,7 +32,7 @@ func (i *MLXImporter) Match(details Details) bool {
}
b, ok := preferencesMap["backend"].(string)
if ok && b == "mlx" || b == "mlx-vlm" {
if ok && slices.Contains([]string{"mlx", "mlx-vlm", "mlx-audio"}, b) {
return true
}
@@ -71,19 +72,32 @@ func (i *MLXImporter) Import(details Details) (gallery.ModelConfig, error) {
// (issue #10269). Send them to the mlx-vlm backend, which applies the
// processor-aware chat template.
backend := "mlx"
if details.HuggingFace != nil && details.HuggingFace.PipelineTag == "image-text-to-text" {
backend = "mlx-vlm"
usecases := []string{config.UsecaseChat}
useTokenizerTemplate := true
if details.HuggingFace != nil {
switch details.HuggingFace.PipelineTag {
case "image-text-to-text":
backend = "mlx-vlm"
case "text-to-speech":
backend = "mlx-audio"
usecases = []string{config.UsecaseTTS}
useTokenizerTemplate = false
}
}
// An explicit backend preference always wins.
b, ok := preferencesMap["backend"].(string)
if ok {
backend = b
if backend == "mlx-audio" {
usecases = []string{config.UsecaseTTS}
useTokenizerTemplate = false
}
}
modelConfig := config.ModelConfig{
Name: name,
Description: description,
KnownUsecaseStrings: []string{config.UsecaseChat},
KnownUsecaseStrings: usecases,
Backend: backend,
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{
@@ -91,7 +105,7 @@ func (i *MLXImporter) Import(details Details) (gallery.ModelConfig, error) {
},
},
TemplateConfig: config.TemplateConfig{
UseTokenizerTemplate: true,
UseTokenizerTemplate: useTokenizerTemplate,
},
}

View File

@@ -48,6 +48,16 @@ var _ = Describe("MLXImporter", func() {
Expect(result).To(BeTrue())
})
It("should match when backend preference is mlx-audio", func() {
preferences := json.RawMessage(`{"backend": "mlx-audio"}`)
details := importers.Details{
URI: "https://example.com/model",
Preferences: preferences,
}
Expect(importer.Match(details)).To(BeTrue())
})
It("should not match when URI does not contain mlx-community/ and no backend preference", func() {
details := importers.Details{
URI: "https://huggingface.co/other-org/test-model",
@@ -123,6 +133,21 @@ var _ = Describe("MLXImporter", func() {
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
})
It("should configure explicit mlx-audio imports for text-to-speech", func() {
preferences := json.RawMessage(`{"backend": "mlx-audio"}`)
details := importers.Details{
URI: "https://huggingface.co/mlx-community/Kokoro-82M-4bit",
Preferences: preferences,
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-audio"))
Expect(modelConfig.ConfigFile).To(ContainSubstring("- tts"))
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("use_tokenizer_template: true"))
})
It("should auto-route vision-language models to the mlx-vlm backend", func() {
// gemma-4 E4B and similar VLMs declare pipeline_tag
// "image-text-to-text" on HuggingFace. The text-only mlx-lm
@@ -143,6 +168,23 @@ var _ = Describe("MLXImporter", func() {
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
})
It("should auto-route text-to-speech models to the mlx-audio backend", func() {
details := importers.Details{
URI: "https://huggingface.co/mlx-community/Kokoro-82M-4bit",
HuggingFace: &hfapi.ModelDetails{
ModelID: "mlx-community/Kokoro-82M-4bit",
PipelineTag: "text-to-speech",
},
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-audio"))
Expect(modelConfig.ConfigFile).To(ContainSubstring("- tts"))
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("use_tokenizer_template: true"))
})
It("should keep text-only models on the plain mlx backend", func() {
details := importers.Details{
URI: "https://huggingface.co/mlx-community/Llama-3.2-1B-Instruct-4bit",

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

@@ -38,6 +38,7 @@ var knownPrefOnlyBackends = []schema.KnownBackend{
{Name: "whisperx", Modality: "asr", AutoDetect: false, Description: "WhisperX transcription (preference-only)"},
{Name: "crispasr", Modality: "asr", AutoDetect: false, Description: "CrispASR multi-architecture transcription (preference-only)"},
// TTS
{Name: "mlx-audio", Modality: "tts", AutoDetect: false, Description: "MLX-Audio text-to-speech models (auto-detected; pref-only fallback)"},
{Name: "kokoros", Modality: "tts", AutoDetect: false, Description: "Kokoros TTS (preference-only)"},
{Name: "qwen-tts", Modality: "tts", AutoDetect: false, Description: "Qwen TTS (preference-only)"},
{Name: "qwen3-tts-cpp", Modality: "tts", AutoDetect: false, Description: "Qwen3 TTS C++ (preference-only)"},

View File

@@ -152,6 +152,7 @@ var _ = Describe("Backend Endpoints", func() {
expectPrefOnly("tinygrad", "text")
expectPrefOnly("trl", "text")
expectPrefOnly("mlx-vlm", "text")
expectPrefOnly("mlx-audio", "tts")
expectPrefOnly("whisperx", "asr")
expectPrefOnly("crispasr", "asr")
expectPrefOnly("kokoros", "tts")

View File

@@ -12,7 +12,7 @@ import (
// @Tags monitoring
// @Success 200 {object} schema.SystemInformationResponse "Response"
// @Router /system [get]
func SystemInformations(ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
func SystemInformations(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
availableBackends := []string{}
loadedModels := ml.ListLoadedModels()
@@ -25,7 +25,14 @@ func SystemInformations(ml *model.ModelLoader, appConfig *config.ApplicationConf
sysmodels := []schema.SysInfoModel{}
for _, m := range loadedModels {
sysmodels = append(sysmodels, schema.SysInfoModel{ID: m.ID})
entry := schema.SysInfoModel{ID: m.ID}
// The loader tracks only the ID. Which engine is serving a model is
// the first thing an operator wants beside its name, and it is one
// config lookup away.
if cfg, ok := cl.GetModelConfig(m.ID); ok {
entry.Backend = cfg.Backend
}
sysmodels = append(sysmodels, entry)
}
return c.JSON(200,
schema.SystemInformationResponse{

View File

@@ -3,6 +3,7 @@ package localai
import (
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/middleware"
@@ -85,6 +86,35 @@ func GetAPITracesEndpoint() echo.HandlerFunc {
}
}
// GetAPITracesSummaryEndpoint returns counted totals over a recent window
// @Summary Summarize recent API traces
// @Description Returns request, failure and latency totals over a recent window, plus a bucketed series for sparklines. Exists so callers wanting three numbers do not have to fetch the whole trace list and count it themselves.
// @Tags monitoring
// @Produce json
// @Param hours query int false "Window in hours (default 24, max 168)"
// @Success 200 {object} middleware.TraceSummary "Counted trace totals"
// @Router /api/traces/summary [get]
func GetAPITracesSummaryEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
hours := 24
if raw := c.QueryParam("hours"); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
hours = v
}
}
// A week is plenty for a dashboard, and the trace buffer is bounded
// anyway; an unbounded window would just scan the whole buffer.
if hours > 168 {
hours = 168
}
return c.JSON(http.StatusOK, middleware.GetTracesSummary(time.Duration(hours)*time.Hour, traceSummaryBuckets))
}
}
// Enough columns for a sparkline to show a shape, few enough that each one
// still holds a meaningful count on a quiet installation.
const traceSummaryBuckets = 12
// GetAPITraceEndpoint returns a single API trace with its full payload
// @Summary Get one API trace
// @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response

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

@@ -0,0 +1,109 @@
// SPDX-License-Identifier: MIT
package middleware
import (
"math"
"slices"
"time"
)
// TraceSummary is the counted view of the trace buffer.
//
// It exists so a caller that wants "how many, how many failed, how slow" does
// not have to fetch every exchange and count them in the browser. The Operate
// overview needs exactly those three numbers, and the trace list is capped in
// the thousands, so shipping it across the wire to produce a single integer is
// waste that grows with the buffer.
type TraceSummary struct {
Total int `json:"total"`
Errors int `json:"errors"`
P95Millis int64 `json:"p95_ms"`
WindowHours int `json:"window_hours"`
Buckets []TraceBucket `json:"buckets"`
}
// TraceBucket is one column of a sparkline: oldest first, so the series reads
// left to right the way a chart is drawn.
type TraceBucket struct {
Start time.Time `json:"start"`
Count int `json:"count"`
Errors int `json:"errors"`
}
// GetTracesSummary counts the buffered exchanges over the given window.
func GetTracesSummary(window time.Duration, buckets int) TraceSummary {
return summarize(GetTraces(), window, buckets)
}
func summarize(traces []APIExchange, window time.Duration, buckets int) TraceSummary {
if buckets < 1 {
buckets = 1
}
now := time.Now()
cutoff := now.Add(-window)
summary := TraceSummary{
WindowHours: int(window.Hours()),
// Never nil: a nil slice serialises as null and breaks .map() on the
// other side, which is a silent runtime error rather than an empty chart.
Buckets: make([]TraceBucket, buckets),
}
bucketWidth := window / time.Duration(buckets)
for i := range summary.Buckets {
summary.Buckets[i].Start = cutoff.Add(time.Duration(i) * bucketWidth)
}
durations := make([]time.Duration, 0, len(traces))
for _, t := range traces {
if t.Timestamp.Before(cutoff) {
continue
}
summary.Total++
failed := isFailure(t)
if failed {
summary.Errors++
}
durations = append(durations, t.Duration)
// Clamp rather than skip: a request timestamped a hair in the future
// (clock skew, or arriving mid-call) still belongs in the newest column.
idx := int(t.Timestamp.Sub(cutoff) / bucketWidth)
if idx >= buckets {
idx = buckets - 1
}
if idx < 0 {
idx = 0
}
summary.Buckets[idx].Count++
if failed {
summary.Buckets[idx].Errors++
}
}
summary.P95Millis = percentileMillis(durations, 0.95)
return summary
}
// A 4xx is the caller getting it wrong, which is not the installation being
// unhealthy. Only 5xx and a transport-level error count against the runtime.
func isFailure(t APIExchange) bool {
return t.Error != "" || t.Response.Status >= 500
}
func percentileMillis(durations []time.Duration, p float64) int64 {
if len(durations) == 0 {
return 0
}
slices.Sort(durations)
// Nearest-rank: the smallest value at or above the pth percentile.
rank := int(math.Ceil(p*float64(len(durations)))) - 1
if rank < 0 {
rank = 0
}
if rank >= len(durations) {
rank = len(durations) - 1
}
return durations[rank].Milliseconds()
}

View File

@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
package middleware
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("API trace summary", func() {
exchange := func(age time.Duration, status int, dur time.Duration) APIExchange {
return APIExchange{
Timestamp: time.Now().Add(-age),
Duration: dur,
Response: APIExchangeResponse{Status: status},
}
}
It("counts only what falls inside the window", func() {
traces := []APIExchange{
exchange(1*time.Hour, 200, 10*time.Millisecond),
exchange(2*time.Hour, 200, 10*time.Millisecond),
// Older than the window: must not be counted at all.
exchange(48*time.Hour, 500, 10*time.Millisecond),
}
s := summarize(traces, 24*time.Hour, 6)
Expect(s.Total).To(Equal(2))
Expect(s.Errors).To(BeZero())
})
It("treats 5xx and a transport error as failures, but not 4xx", func() {
traces := []APIExchange{
exchange(time.Minute, 500, time.Millisecond),
exchange(time.Minute, 503, time.Millisecond),
// A client sending a bad request is not the server failing.
exchange(time.Minute, 404, time.Millisecond),
exchange(time.Minute, 200, time.Millisecond),
}
traces[3].Error = "connection reset"
s := summarize(traces, 24*time.Hour, 6)
Expect(s.Total).To(Equal(4))
Expect(s.Errors).To(Equal(3))
})
It("reports p95 as a real percentile rather than the slowest request", func() {
traces := make([]APIExchange, 0, 100)
for i := 1; i <= 100; i++ {
traces = append(traces, exchange(time.Minute, 200, time.Duration(i)*time.Millisecond))
}
s := summarize(traces, 24*time.Hour, 6)
// 95th of 1..100ms, not the 100ms max.
Expect(s.P95Millis).To(BeNumerically("~", 95, 1))
})
It("buckets oldest-first so a sparkline reads left to right", func() {
traces := []APIExchange{
exchange(30*time.Minute, 200, time.Millisecond),
exchange(30*time.Minute, 200, time.Millisecond),
exchange(5*time.Hour, 200, time.Millisecond),
}
s := summarize(traces, 6*time.Hour, 6)
Expect(s.Buckets).To(HaveLen(6))
Expect(s.Buckets[0].Count).To(Equal(1), "the 5h-old request lands in the first bucket")
Expect(s.Buckets[5].Count).To(Equal(2), "the recent pair lands in the last")
})
It("returns an empty, non-nil summary when nothing has been traced", func() {
s := summarize(nil, 24*time.Hour, 6)
Expect(s.Total).To(BeZero())
Expect(s.Errors).To(BeZero())
Expect(s.P95Millis).To(BeZero())
// A nil slice serialises as null and breaks .map() in the browser.
Expect(s.Buckets).NotTo(BeNil())
Expect(s.Buckets).To(HaveLen(6))
})
})

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

@@ -5,7 +5,9 @@ test.describe('Admin console', () => {
await page.goto('/app/backends')
const rail = page.locator('.console-rail')
await expect(rail).toBeVisible()
for (const group of ['Inference', 'Cluster', 'Observability', 'Access', 'System']) {
// Four groups since the overview landed: Inference folded into Runtime
// (both are "the runtime right now"), Access and System into Administration.
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
}
})

View File

@@ -69,9 +69,9 @@ test.describe('Manage - alias badge', () => {
test('renders a read-only alias -> target badge on aliased rows', async ({ page }) => {
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
// The aliased row shows the target; the plain model row does not.
// The badge moved off the row and into the pane: it is a fact about the
// model, and the rail line is spent on state.
await page.locator('[data-entity="gpt-4"]').click()
await expect(page.getByText('alias -> fast-llm')).toBeVisible({ timeout: 10_000 })
})
})

View File

@@ -1,6 +1,9 @@
import { test, expect } from './coverage-fixtures.js'
// Backends admin page (src/pages/Backends.jsx).
const PANE = '[data-testid="backends-pane"]'
const railItem = (page, name) => page.locator(`[data-entity="${name}"]`)
test.describe('Backends management page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/backends')
@@ -49,11 +52,14 @@ test.describe('Backends management page - Markdown descriptions', () => {
})
})
await page.goto('/app/backends')
await expect(page.locator('th', { hasText: 'Description' })).toBeVisible({ timeout: 10_000 })
// Rendered means the rail has entries. The old gate waited on a column
// header, and there are no columns now.
await expect(railItem(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 })
})
test('table cell shows the description as clean text, not raw Markdown', async ({ page }) => {
const cell = page.locator('tr', { hasText: 'markdown-backend' }).locator('span[title]', { hasText: 'InsightFace' })
test('the pane lede shows the description as clean text, not raw Markdown', async ({ page }) => {
await railItem(page, 'markdown-backend').click()
const cell = page.locator('.detail-pane__lede')
await expect(cell).toHaveText(STRIPPED_DESCRIPTION)
// The syntax itself must be gone, not merely rendered somewhere.
@@ -65,15 +71,77 @@ test.describe('Backends management page - Markdown descriptions', () => {
await expect(cell.locator('h1')).toHaveCount(0)
})
test('title tooltip carries the stripped text, not raw Markdown', async ({ page }) => {
const cell = page.locator('tr', { hasText: 'markdown-backend' }).locator('span[title]', { hasText: 'InsightFace' })
await expect(cell).toHaveAttribute('title', STRIPPED_DESCRIPTION)
test("the lede's tooltip carries the stripped text, not raw Markdown", async ({ page }) => {
await railItem(page, 'markdown-backend').click()
await expect(page.locator('.detail-pane__lede')).toHaveAttribute('title', STRIPPED_DESCRIPTION)
})
test('a backend with no description still shows the placeholder', async ({ page }) => {
const row = page.locator('tr', { hasText: 'plain-backend' })
await expect(row.locator('span[title=""]')).toHaveText('-')
test('a backend with no description renders no lede rather than a blank one', async ({ page }) => {
// The table needed a placeholder because an empty cell in a grid of full
// ones reads as a fault. The pane has no grid to keep aligned, so it omits
// the line - but must never print "undefined".
await railItem(page, 'plain-backend').click()
await expect(page.locator(PANE)).toContainText('plain-backend')
await expect(page.locator('.detail-pane__lede')).toHaveCount(0)
await expect(page.locator(PANE)).not.toContainText('undefined')
})
})
test.describe('Backends gallery - split view', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/backends*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
backends: [
{ name: 'llama-cpp', description: 'GGUF inference', installed: true, version: '1.52.0', license: 'MIT', tags: ['chat'] },
{ name: 'whisper', description: 'Speech to text', installed: true, version: '1.8.2', license: 'MIT', tags: ['transcript'] },
{ name: 'diffusers', description: 'Image generation', installed: false, license: 'Apache-2.0', tags: ['image'] },
],
}),
})
})
await page.goto('/app/backends')
await expect(railItem(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 })
})
test('the gallery renders no table', async ({ page }) => {
await expect(page.locator('[data-testid="backends"]')).toBeVisible()
await expect(page.locator('table thead th')).toHaveCount(0)
})
test('with nothing selected the pane describes the host', async ({ page }) => {
await expect(page.locator(PANE)).toContainText('This host')
await expect(page.locator('[data-testid="backends-back"]')).toHaveCount(0)
})
test('choosing a backend turns the pane into its detail, and back returns', async ({ page }) => {
await railItem(page, 'llama-cpp').click()
await expect(page.locator(PANE)).toContainText('llama-cpp')
await expect(page.locator(PANE)).toContainText('MIT')
await expect(page.locator(PANE)).not.toContainText('This host')
await page.locator('[data-testid="backends-back"]').click()
await expect(page.locator(PANE)).toContainText('This host')
})
test('the selection lives in the URL and survives a reload', async ({ page }) => {
await railItem(page, 'whisper').click()
await expect(page).toHaveURL(/[?&]backend=whisper/)
await page.reload()
await expect(railItem(page, 'whisper')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="backends-back"]')).toBeVisible()
})
test('the rail groups while browsing and flattens on a query', async ({ page }) => {
await expect(page.locator('[data-testid^="backends-rail-group-"]').first()).toBeVisible()
await page.locator('input[placeholder*="Search backends"]').fill('llama')
await expect(page.locator('[data-testid^="backends-rail-group-"]')).toHaveCount(0)
})
test('an installed backend states its version, an absent one says so', async ({ page }) => {
await expect(railItem(page, 'llama-cpp')).toContainText('v1.52.0')
await expect(railItem(page, 'diffusers')).toContainText('not installed')
})
})

View File

@@ -0,0 +1,23 @@
import { test, expect } from './coverage-fixtures.js'
// A notice is a hairline with a coloured left edge, not a filled panel. A tint
// makes every notice shout at the weight of an error, which is how notices stop
// being read — and it is the same treatment the Operate overview uses for the
// rows that want a decision.
test('the backends notice is an edge, not a filled card', async ({ page }) => {
// The upgrade banner is the notice worth pinning, so make one exist.
await page.route('**/api/backends/upgrades', route => route.fulfill({
json: { 'llama-cpp': { backend_name: 'llama-cpp', installed_version: '0.9.4', available_version: '0.9.7' } },
}))
await page.goto('/app/backends')
const notice = page.locator('.bk-notice', { hasText: /update/i }).first()
await expect(notice).toBeVisible()
const s = await notice.evaluate(el => {
const cs = getComputedStyle(el)
return { bg: cs.backgroundColor, left: parseFloat(cs.borderLeftWidth), top: parseFloat(cs.borderTopWidth) }
})
expect(s.bg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
expect(s.left).toBeGreaterThanOrEqual(3)
expect(s.top).toBeLessThanOrEqual(1)
})

View File

@@ -0,0 +1,55 @@
import { test, expect } from './coverage-fixtures.js'
// Chat reads as a transcript rather than a bubble thread (mock 04).
const CHAT = {
chats: [{
id: 'c1', name: 'Transcript', model: 'mock-model',
history: [
{ role: 'user', content: 'Which backends do I have?' },
{ role: 'assistant', content: 'Seven are installed.' },
],
}],
activeChatId: 'c1',
}
test.describe('Chat transcript', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(chat => {
localStorage.setItem('localai_chats_data', JSON.stringify(chat))
}, CHAT)
await page.goto('/app/chat')
})
test('neither role is a filled, rounded bubble', async ({ page }) => {
const user = page.locator('.chat-message-user .chat-message-content').first()
await expect(user).toBeVisible()
const cs = await user.evaluate(el => {
const s = getComputedStyle(el)
return { radius: s.borderTopLeftRadius, shadow: s.boxShadow }
})
// A rounded filled bubble carries the speaker in shape and side; a
// transcript carries it in words, which survives being read aloud.
expect(cs.radius).toBe('0px')
expect(cs.shadow).toBe('none')
})
test('both turns run full width in one column, not left and right', async ({ page }) => {
const user = page.locator('.chat-message-user').first()
const assistant = page.locator('.chat-message-assistant').first()
const [u, a] = [await user.boundingBox(), await assistant.boundingBox()]
expect(Math.abs(u.x - a.x)).toBeLessThan(2)
})
test('every turn says who is speaking', async ({ page }) => {
await expect(page.locator('.chat-message-user .chat-message-model')).toHaveText('You')
await expect(page.locator('.chat-message-assistant .chat-message-model').first())
.toHaveText('mock-model')
})
test('turns are separated by a rule', async ({ page }) => {
const border = await page.locator('.chat-message').first()
.evaluate(el => getComputedStyle(el).borderBottomStyle)
expect(border).toBe('solid')
})
})

View File

@@ -0,0 +1,50 @@
import { test, expect } from './coverage-fixtures.js'
// A standing guard against the two defects an earlier automated edit left
// scattered through the pages: icons stripped of their fa-* class (which render
// nothing at all), and controls left with the user agent's own chrome, which is
// a pale grey button on a dark ground.
const ROUTES = [
'/app', '/app/chat', '/app/models', '/app/studio', '/app/talk',
'/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs',
'/app/fine-tune', '/app/quantize', '/app/face', '/app/voice',
'/app/manage', '/app/backends', '/app/activity', '/app/operate',
'/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p',
'/app/voice-library', '/app/voice-library/new', '/app/account',
]
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)
await page.waitForTimeout(400)
const found = await page.evaluate(() => {
const out = []
for (const el of document.querySelectorAll('button, a')) {
if (el.getBoundingClientRect().width === 0) continue
const cs = getComputedStyle(el)
if (cs.borderTopStyle === 'outset' || cs.backgroundColor === 'rgb(239, 239, 239)') {
out.push(`default-chrome: "${(el.textContent || '').trim().slice(0, 24)}" [${el.className}]`)
}
}
for (const i of document.querySelectorAll('i')) {
if (!/\bfa-/.test((i.className || '').toString())) {
out.push(`dead-icon: [${i.className}]`)
}
}
return [...new Set(out)]
})
for (const f of found) findings.push(`${route}${f}`)
}
expect(findings).toEqual([])
})

View File

@@ -0,0 +1,101 @@
import { test, expect } from './coverage-fixtures.js'
// Small-screen behaviour of the Operate console and the dashboard stat cards.
//
// Both defects here are about a narrow viewport but neither is only a narrow
// viewport problem: the stat cards were being laid out by the wrong rule at
// every width, and the rail's height was never bounded.
test.describe('Operate console on a narrow screen', () => {
test('expanding the rail leaves the page still on screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/manage')
const toggle = page.locator('.console-rail-toggle')
await expect(toggle).toBeVisible()
await toggle.click()
await expect(page.locator('.console-rail-groups')).toBeVisible()
// Thirteen destinations in one column is taller than a phone. If opening
// the menu pushes the page's own heading past the fold, the menu has
// replaced the page instead of annotating it.
// Manage titles itself with .view-bar__title rather than .page-title.
const heading = page.locator('.page-title, .view-bar__title').first()
const box = await heading.boundingBox()
expect(box).not.toBeNull()
expect(box.y).toBeLessThan(800)
})
test('the rail scrolls internally rather than growing without bound', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/manage')
await page.locator('.console-rail-toggle').click()
const groups = page.locator('.console-rail-groups')
await expect(groups).toBeVisible()
const height = await groups.evaluate(el => el.getBoundingClientRect().height)
expect(height).toBeLessThan(800)
})
})
test.describe('Headline figures', () => {
// Host used shadowed StatCards; it now shares the Operate overview's hairline
// figure strip, so the guard is that its labels stay legible, not that it
// keeps a card gap.
for (const width of [768, 1024]) {
test(`Host figure labels are not clipped at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 })
await page.goto('/app/manage')
const labels = page.locator('.stat-strip__label')
await expect(labels.first()).toBeVisible()
const clipped = await labels.evaluateAll(els =>
els.filter(el => el.scrollWidth > el.clientWidth + 1).map(el => el.textContent))
expect(clipped).toEqual([])
})
}
test('a Host figure routes into the thing it counts', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 1000 })
await page.goto('/app/manage')
const cell = page.locator('.stat-strip__cell').first()
await expect(cell).toBeVisible()
// A count is worth more when it is also the way to what it counted.
await expect(cell).toHaveJSProperty('tagName', 'BUTTON')
})
test('the figure strip keeps its height inside the flex column', async ({ page }) => {
// .page--app is a flex column whose split view takes flex:1, so a child
// with no intrinsic minimum gets shrunk to nothing. This strip did exactly
// that and rendered 2px tall with four invisible cells.
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto('/app/manage')
const strip = page.locator('.manage-summary')
await expect(strip).toBeVisible()
const h = await strip.evaluate(el => el.getBoundingClientRect().height)
expect(h).toBeGreaterThan(40)
})
})
test.describe('Headline figure contrast', () => {
test('every figure is legible against the cell it sits on', async ({ page }) => {
// A <button> does not inherit colour, so a value with no tone rule fell
// back to the UA's `buttontext` — pure black on the dark ground, invisible.
await page.setViewportSize({ width: 1440, height: 950 })
await page.goto('/app/manage')
const bad = await page.locator('.stat-strip__value').evaluateAll(els => els
.map(el => ({ text: el.textContent, color: getComputedStyle(el).color }))
.filter(v => v.color === 'rgb(0, 0, 0)'))
expect(bad).toEqual([])
})
test('the strip keeps its top margin against the shared shorthand', async ({ page }) => {
// `.stat-strip` declares `margin: 0 0 ...` later in the file, which was
// silently resetting this element's top margin and leaving it flush
// against the resources panel above it.
await page.setViewportSize({ width: 1440, height: 950 })
await page.goto('/app/manage')
const top = await page.locator('.manage-summary')
.evaluate(el => parseFloat(getComputedStyle(el).marginTop))
expect(top).toBeGreaterThan(12)
})
})

View File

@@ -0,0 +1,72 @@
import { test, expect } from './coverage-fixtures.js'
// The split view is meant to scroll inside itself. It is easy to regress into
// scrolling the document instead, because the shell's height rules are floors
// (min-height: 100dvh) rather than ceilings, so any tall pane silently grows
// the whole column and takes the rail with it.
// A description long enough that the detail pane must overflow, which is the
// only condition under which the bug shows.
const LONG = Array.from({ length: 60 }, (_, i) =>
`Paragraph ${i + 1}. This entry carries a long description so the detail pane has more content than the viewport can hold.`,
).join('\n\n')
const MOCK = {
models: [
{ name: 'long-model', description: LONG, backend: 'llama-cpp', installed: false, tags: ['llm'] },
{ name: 'short-model', description: 'Short.', backend: 'llama-cpp', installed: false, tags: ['llm'] },
],
allBackends: ['llama-cpp'], allTags: ['llm'],
availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
}
test.describe('Discover - the view scrolls, not the page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/models*', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) }))
})
test('a long detail scrolls the pane and leaves the page height alone', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await page.goto('/app/models')
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
const pageHeight = () => page.evaluate(() => document.documentElement.scrollHeight)
const railHeight = () => page.evaluate(
() => document.querySelector('.entity-rail')?.getBoundingClientRect().height,
)
const beforePage = await pageHeight()
const beforeRail = await railHeight()
await page.locator('[data-testid="discover-rail-item"]').first().click()
await expect(page.locator('[data-testid="discover-back"]')).toBeVisible()
// Selecting something must not make the document taller, and must not
// stretch the rail to match the pane.
expect(await pageHeight()).toBe(beforePage)
// Sub-pixel: layout can settle a fraction differently without the rail
// having grown. A pixel of tolerance keeps this about the bug it guards.
expect(Math.abs((await railHeight()) - beforeRail)).toBeLessThan(1)
// The pane is the thing that scrolls.
const paneOverflows = await page.evaluate(() => {
const el = document.querySelector('.split-view__pane')
return el ? getComputedStyle(el).overflowY : null
})
expect(paneOverflows).toBe('auto')
})
test('stacked below the breakpoint it scrolls with the document again', async ({ page }) => {
// Pinning the height when the columns stack would trap both halves in short
// scrollers, so the constraint is lifted there on purpose.
await page.setViewportSize({ width: 700, height: 800 })
await page.goto('/app/models')
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
const overflow = await page.evaluate(() => {
const el = document.querySelector('.split-view__pane')
return el ? getComputedStyle(el).overflowY : null
})
expect(overflow).toBe('visible')
})
})

View File

@@ -0,0 +1,52 @@
import { test, expect } from './coverage-fixtures.js'
// Searching triggers a refetch. The search box lives in the rail column, so if
// a refetch unmounts the view it takes the field you are typing into with it,
// dropping focus and the caret. That is what this guards.
const MOCK = {
models: [
{ name: 'alpha-model', description: 'a', backend: 'llama-cpp', installed: false, tags: ['llm'] },
{ name: 'beta-model', description: 'b', backend: 'llama-cpp', installed: false, tags: ['llm'] },
],
allBackends: ['llama-cpp'], allTags: ['llm'],
availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
}
test.describe('Discover - searching keeps the view', () => {
test('a refetch keeps the search box, its focus and its value', async ({ page }) => {
let calls = 0
await page.route('**/api/models*', async (route) => {
calls += 1
// Slow the refetch so the loading window is real and observable.
if (calls > 1) await new Promise((r) => setTimeout(r, 600))
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) })
})
await page.goto('/app/models')
const search = page.locator('.filter-bar-group__search input')
await expect(search).toBeVisible({ timeout: 10_000 })
await search.click()
await search.fill('alpha')
// Mid-refetch: the field is still mounted, still focused, still holding
// what was typed, and the rail is marked busy rather than replaced.
await expect(search).toBeFocused()
await expect(search).toHaveValue('alpha')
await expect(page.locator('.entity-rail')).toBeVisible()
await page.waitForTimeout(900)
await expect(search).toBeFocused()
await expect(search).toHaveValue('alpha')
})
test('the first load still shows a skeleton, not an empty shell', async ({ page }) => {
// Nothing to keep on a cold start, so the skeleton is still right there.
await page.route('**/api/models*', async (route) => {
await new Promise((r) => setTimeout(r, 800))
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) })
})
await page.goto('/app/models')
await expect(page.getByTestId('gallery-loader')).toBeVisible({ timeout: 5_000 })
})
})

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