Compare commits

...

34 Commits

Author SHA1 Message Date
localai-org-maint-bot
36ea532fd8 fix(tests): remove duplicate scheduling stubs
Assisted-by: Codex:gpt-5 [golangci-lint]
2026-08-03 20:08:15 +00:00
localai-org-maint-bot
490c6f8a4d fix(buun-llama-cpp): adapt shared grpc wrapper
Translate modern speculative fields to the pinned fork API, disable unsupported score and checkpoint features, and cover the compatibility transform with an idempotent regression test.

Assisted-by: Codex:gpt-5 [Codex]
2026-08-03 19:05:30 +00:00
localai-org-maint-bot
7d15b39843 fix(buun-llama-cpp): isolate fork patch series
The buun build copies the stock llama.cpp backend directory, including patches that target upstream. Remove that copied patch directory before invoking the shared build so only the explicit buun compatibility series is applied to the fork.

Assisted-by: Codex:gpt-5 [systematic-debugging]
2026-08-03 19:05:30 +00:00
localai-org-maint-bot
5e7f6621f1 fix(buun-llama-cpp): match speculative draft split anchor
The shared gRPC wrapper stores p_split under the draft sub-structure. Match that exact source spelling so the fork-specific patch stage reaches the build on every architecture.

Assisted-by: Codex:gpt-5 [Codex]
2026-08-03 19:05:30 +00:00
Ettore Di Giacinto
b07fba8399 fix(buun-llama-cpp): shim cudaMemcpy{To,From}Symbol + WARP_SIZE on fwht128 shuffles
Two more hipblas-only build failures in buun's fattn.cu, fixed under the
same patches/ infrastructure:

1. cudaMemcpyToSymbol / cudaMemcpyFromSymbol — buun's Q² calibration +
   TCQ codebook upload paths call the symbol variants of cudaMemcpy.
   ggml/src/ggml-cuda/vendors/hip.h aliases every other cudaMemcpy*
   name (cudaMemcpy, cudaMemcpyAsync, cudaMemcpy2DAsync, …) but the
   symbol pair was never added. 15+ "use of undeclared identifier"
   errors across fattn.cu lines 40, 54, 74-76, 94, 100-101, 371, 883,
   905, 954, 976, 1449, 1463. Add the two missing aliases alongside
   the existing memcpy block.

2. __shfl_xor_sync fwht128 calls — same 3-arg omission pattern as the
   earlier argmax top-K fix. Lines 512 (ggml_cuda_fwht128 intra-warp
   butterfly) and 536 (fwht128_store_half neighbor fetch) drop the
   width argument that hip.h:33 requires. Add WARP_SIZE.

Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:05:30 +00:00
Ettore Di Giacinto
f5eb5e2a63 fix(buun-llama-cpp): pass WARP_SIZE to argmax __shfl_xor_sync calls
Two call sites in ggml/src/ggml-cuda/argmax.cu (the top-K intra-warp
merge added by buun) use the 3-arg CUDA form __shfl_xor_sync(mask, var,
laneMask), omitting the optional width parameter. The hipification shim
at ggml/src/ggml-cuda/vendors/hip.h:33 is a function-like macro that
requires all four arguments, so hipcc fails with:

    argmax.cu:265: too few arguments provided to function-like macro
      invocation
    note: macro '__shfl_xor_sync' defined here:
      #define __shfl_xor_sync(mask, var, laneMask, width) \
              __shfl_xor(var, laneMask, width)

Every other call in the same file already passes WARP_SIZE explicitly;
aligning these two with that convention fixes the hipblas build without
changing CUDA codegen (warpSize is the CUDA default).

Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:05:30 +00:00
Ettore Di Giacinto
91046ba10a fix(buun-llama-cpp): shim atomicAdd(double*,double) for pre-sm_60 CUDA
Buun's Q² calibration path in ggml/src/ggml-cuda/fattn.cu calls
atomicAdd with a double* destination. Native double atomicAdd is only
available on CUDA compute capability 6.0 and later — LocalAI's CUDA 12
Docker image builds for the full published arch range (which includes
sm_50/sm_52), so nvcc fails with:

    fattn.cu:812: error: no instance of overloaded function "atomicAdd"
    matches the argument list, argument types are: (double *, double)

Add the canonical CAS-loop shim from the CUDA C Programming Guide
(B.15 Atomic Functions) guarded on __CUDA_ARCH__ < 600. On sm_60+ the
guard is false and nvcc picks up the native intrinsic as before.

Patch file lives under backend/cpp/buun-llama-cpp/patches/ and is
applied to the cloned fork tree by apply-patches.sh (the infrastructure
already put in place for exactly this class of backport).

Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:05:30 +00:00
Ettore Di Giacinto
51bbf1ccb9 ci(buun-llama-cpp): wire backend into test-extra + build matrix
Adds the buun-llama-cpp backend to the same CI pipelines that turboquant
and sherpa-onnx already use:

- scripts/changed-backends.js: path resolution for Dockerfile.buun-llama-cpp,
  plus fork-of-fork detection (changes under backend/cpp/llama-cpp/ also
  retrigger the buun pipeline, mirroring how turboquant is handled).
- .github/workflows/test-extra.yml: detect-changes output and a new
  tests-buun-llama-cpp-grpc job that runs make test-extra-backend-buun-llama-cpp
  (turbo3 V-cache, same rationale as tests-turboquant-grpc).
- .github/workflows/backend.yml: 9 matrix entries (CUDA 12/13, L4T CUDA
  13 ARM64, ROCm, SYCL f32/f16, CPU, L4T ARM64, Vulkan) paired with each
  existing turboquant entry so image builds have platform parity.

Also updates .agents/ai-coding-assistants.md to clarify that AI agents
operating under the human submitter's git identity SHOULD emit
Signed-off-by via `git commit -s` (never inventing or guessing another
identity) — documents the workflow this PR is using.

Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:05:30 +00:00
Ettore Di Giacinto
3f79cfb1dd fix(buun-llama-cpp): drop logit_bias_eog arg from params_from_json_cmpl
Previous substitution kept the call as 5 args, but buun predates the
upstream refactor that also *added* the logit_bias_eog parameter to
params_from_json_cmpl — buun's signature is still the 4-arg form
  (const llama_vocab*, const common_params&, int, const json&)
and it still derives logit_bias_eog internally from the common_params.

Replace the substitution with a line-delete. Guard matches both the
original call (ctx_server.get_meta().logit_bias_eog) and the previously
substituted form (params_base.sampling.logit_bias_eog) so the script
stays safe across re-runs and whatever state the tree was left in.

Assisted-by: Claude:Opus-4.7 [Read] [Edit] [Bash]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:05:30 +00:00
Ettore Di Giacinto
c8aadeba3b fix(buun-llama-cpp): backport logit_bias_eog field to grpc-server copy
LocalAI's shared grpc-server.cpp reaches
ctx_server.get_meta().logit_bias_eog twice (the twin params_from_json_cmpl
callsites). That accessor was added to server_context_meta upstream after
buun's 2026-04-05 fork-point, so compiling against buun errors with
  'struct server_context_meta' has no member named 'logit_bias_eog'.

Rewrite the call sites — only in the buun grpc-server.cpp copy — to source
the vector from params_base.sampling.logit_bias_eog instead. That vector is
the underlying data the upstream meta accessor eventually returns (buun
still carries common_params_sampling::logit_bias_eog at common.h:280), so
the substitution yields identical behavior on both trees.

The sed is guarded by a grep for the call site, so this patch is
self-disabling once buun rebases past the upstream refactor.

Assisted-by: Claude:Opus-4.7 [Read] [Edit] [Bash] [WebFetch]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:05:30 +00:00
Ettore Di Giacinto
bb56c06751 test(gallery): extend importer specs to cover buun-llama-cpp
Two additions that pair with the new backend:
- An Import()-side case that asserts preference buun-llama-cpp produces
  backend: buun-llama-cpp in the emitted YAML (mirrors the existing
  ik-llama-cpp and turboquant cases).
- AdditionalBackends() spec now asserts all three drop-in replacements
  are advertised, and verifies buun-llama-cpp's Modality/Description
  alongside the other two.

Assisted-by: Claude:Opus-4.7 [Read] [Edit] [Bash]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:05:30 +00:00
Ettore Di Giacinto
eb374e50ee feat(backend): add buun-llama-cpp fork (DFlash + TCQ KV-cache)
spiritbuun/buun-llama-cpp is a fork of TheTom/llama-cpp-turboquant that adds
two independent features on top: DFlash block-diffusion speculative decoding
(via a dedicated DFlashDraftModel GGUF arch) and two extra TCQ KV-cache
variants (turbo2_tcq, turbo3_tcq) on top of TurboQuant's turbo2/turbo3/turbo4.

Follows the turboquant thin-wrapper pattern — reuses backend/cpp/llama-cpp
grpc-server sources verbatim, patches only the build copy to extend the KV
allow-list and wire up buun-exclusive tree_budget / draft_topk options.
DraftModel is already wired end-to-end (proto field 39 → params.speculative),
so DFlash activation only needs the existing options passthrough
(spec_type:dflash) plus the drafter path in draft_model.

CacheTypeOptions now surfaces the five turbo* values so the React UI dropdown
shows them — benefits turboquant too (previously users had to type them in
YAML manually).

Assisted-by: Claude:Opus-4.7 [Read] [Edit] [Bash] [WebFetch]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-03 19:05:30 +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
210 changed files with 7760 additions and 699 deletions

View File

@@ -35,19 +35,33 @@ All contributions must comply with LocalAI's licensing requirements:
## Signed-off-by and Developer Certificate of Origin
**AI agents MUST NOT add `Signed-off-by` tags.** Only humans can legally
certify the Developer Certificate of Origin (DCO). The human submitter
is responsible for:
Only humans can certify the Developer Certificate of Origin (DCO). AI
agents MUST NOT invent or guess a human identity for `Signed-off-by`
doing so forges the DCO certification.
- Reviewing all AI-generated code
However, when a human operator explicitly directs the AI to commit on
their behalf, the AI is acting as a typing tool — no different from an
editor macro or `git commit -s`. In that case the AI SHOULD add
`Signed-off-by:` using the **configured `user.name` / `user.email`** of
the current git repository (i.e. the operator's own identity). The
resulting trailer is the operator's signature; they take responsibility
for it by reviewing and pushing the commit. The AI MUST NOT use any
other identity and MUST NOT add its own name to the sign-off.
When running `git commit`, prefer `git commit --signoff` (or `-s`) so
the trailer is emitted by git itself from the configured identity,
rather than hand-writing it in a heredoc — this guarantees the sign-off
matches whatever identity the operator is currently using.
The human submitter remains responsible for:
- Reviewing all AI-generated code before it's pushed or merged
- Ensuring compliance with licensing requirements
- Adding their own `Signed-off-by` tag (when the project requires DCO)
to certify the contribution
- Taking full responsibility for the contribution
AI agents MUST NOT add `Co-Authored-By` trailers for themselves either.
A human reviewer owns the contribution; the AI's involvement is recorded
via `Assisted-by` (see below).
AI agents MUST NOT add `Co-Authored-By` trailers for themselves. A human
reviewer owns the contribution; the AI's involvement is recorded via
`Assisted-by` (see below).
## Attribution
@@ -84,6 +98,12 @@ Assisted-by: Claude:claude-opus-4-7 golangci-lint
Signed-off-by: Jane Developer <jane@example.com>
```
The `Signed-off-by` line uses Jane's own identity because Jane is the
submitter operating the AI. If Jane asks Claude to create the commit via
`git commit -s`, git emits that exact trailer from Jane's configured
identity — no separate human step is needed beyond Jane reviewing the
diff before pushing.
## Scope and Responsibility
Using an AI assistant does not reduce the contributor's responsibility.

View File

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

View File

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

View File

@@ -480,6 +480,22 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-12-amd64'
# bigger-runner: same rationale as -gpu-nvidia-cuda-12-llama-cpp above
# (observed 6h5m wall-clock on v4.2.1, just past the 6h job timeout).
runs-on: 'bigger-runner'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1165,6 +1181,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-13-amd64'
# bigger-runner: observed 6h5m wall-clock on v4.2.1 — at the GHA timeout.
runs-on: 'bigger-runner'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1208,6 +1239,20 @@ include:
backend: "turboquant"
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-cuda-13-arm64'
base-image: "ubuntu:24.04"
runs-on: 'ubuntu-24.04-arm'
ubuntu-version: '2404'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -2477,6 +2522,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f32-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-intel-amd64'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f32'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2519,6 +2578,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-intel-sycl-f16-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-intel-amd64'
runs-on: 'ubuntu-latest'
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: 'sycl_f16'
cuda-major-version: ""
cuda-minor-version: ""
@@ -2985,6 +3058,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -3015,6 +3103,21 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-arm64'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
@@ -3276,6 +3379,20 @@ include:
dockerfile: "./backend/Dockerfile.turboquant"
context: "./"
ubuntu-version: '2204'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-arm64-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-l4t-cuda-12-arm64'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2204'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
@@ -3336,6 +3453,22 @@ include:
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-amd64'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
@@ -3368,6 +3501,22 @@ include:
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-buun-llama-cpp'
builder-base-image: 'quay.io/go-skynet/ci-cache:base-grpc-vulkan-arm64'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "buun-llama-cpp"
dockerfile: "./backend/Dockerfile.buun-llama-cpp"
context: "./"
ubuntu-version: '2404'
# Stablediffusion-ggml
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""

View File

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

View File

@@ -33,6 +33,7 @@ jobs:
llama-cpp: ${{ steps.detect.outputs.llama-cpp }}
ik-llama-cpp: ${{ steps.detect.outputs.ik-llama-cpp }}
turboquant: ${{ steps.detect.outputs.turboquant }}
buun-llama-cpp: ${{ steps.detect.outputs['buun-llama-cpp'] }}
vllm: ${{ steps.detect.outputs.vllm }}
sglang: ${{ steps.detect.outputs.sglang }}
acestep-cpp: ${{ steps.detect.outputs.acestep-cpp }}
@@ -716,6 +717,30 @@ jobs:
- name: Build turboquant backend image and run gRPC e2e tests
run: |
make test-extra-backend-turboquant
tests-buun-llama-cpp-grpc:
needs: detect-changes
if: needs.detect-changes.outputs['buun-llama-cpp'] == 'true' || needs.detect-changes.outputs.run-all == 'true'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Clone
uses: actions/checkout@v6
with:
submodules: true
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.25.4'
# Exercises the buun-llama-cpp (fork-of-a-fork) backend with the
# fork-specific TurboQuant/TCQ KV-cache types. BACKEND_TEST_CACHE_TYPE_V
# is set to turbo3 so the test round-trips through the fork's KV
# allow-list — picking a stock llama.cpp type would only re-test the
# shared code path. DFlash speculative decoding is not exercised here
# because the one known public target/drafter pair (Qwen3.5-27B) is too
# large for CI.
- name: Build buun-llama-cpp backend image and run gRPC e2e tests
run: |
make test-extra-backend-buun-llama-cpp
# tests-vllm-grpc is currently disabled in CI.
#
# The prebuilt vllm CPU wheel is compiled with AVX-512 VNNI/BF16

View File

@@ -1,4 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/buun-llama-cpp
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin
GOCMD=go
@@ -748,6 +749,19 @@ test-extra-backend-bonsai: docker-build-bonsai
BACKEND_TEST_MODEL_URL=https://huggingface.co/prism-ml/Bonsai-8B-gguf/resolve/main/Bonsai-8B-Q1_0.gguf \
$(MAKE) test-extra-backend
## buun-llama-cpp: exercises the fork-of-a-fork backend (spiritbuun/buun-llama-cpp)
## with the *TurboQuant/TCQ-specific* KV-cache types (turbo3 for V). Same rationale
## as turboquant above: picking a standard llama.cpp type would only re-test the
## shared code path. buun inherits turboquant's turbo2/turbo3/turbo4 and adds
## turbo2_tcq / turbo3_tcq on top. DFlash speculative decoding is not exercised
## here because no small DFlash drafter model exists (the known public pair is
## Qwen3.5-27B, ~54 GB).
test-extra-backend-buun-llama-cpp: docker-build-buun-llama-cpp
BACKEND_IMAGE=local-ai-backend:buun-llama-cpp \
BACKEND_TEST_CACHE_TYPE_K=q8_0 \
BACKEND_TEST_CACHE_TYPE_V=turbo3 \
$(MAKE) test-extra-backend
## Audio transcription wrapper for the llama-cpp backend.
## Drives the new AudioTranscription / AudioTranscriptionStream RPCs against
## ggml-org/Qwen3-ASR-0.6B-GGUF (a small ASR model that requires its mmproj
@@ -1284,6 +1298,11 @@ BACKEND_PRIVACY_FILTER = privacy-filter|privacy-filter|.|false|false
# against apt gRPC/protobuf rather than a prebuilt base-grpc image; the reason
# is on the audio-cpp block in .github/backend-matrix.yml.
BACKEND_AUDIO_CPP = audio-cpp|audio-cpp|.|false|false
# buun-llama-cpp is a fork-of-a-fork (spiritbuun/buun-llama-cpp forks
# TheTom/llama-cpp-turboquant) that adds DFlash block-diffusion speculative
# decoding and extra TCQ KV-cache variants on top of TurboQuant. Same thin
# wrapper pattern as turboquant — reuses backend/cpp/llama-cpp grpc-server.
BACKEND_BUUN_LLAMA_CPP = buun-llama-cpp|buun-llama-cpp|.|false|false
# Golang backends
BACKEND_PIPER = piper|golang|.|false|true
@@ -1388,6 +1407,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_BONSAI)))
$(eval $(call generate-docker-build-target,$(BACKEND_DS4)))
$(eval $(call generate-docker-build-target,$(BACKEND_PRIVACY_FILTER)))
$(eval $(call generate-docker-build-target,$(BACKEND_AUDIO_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_BUUN_LLAMA_CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_PIPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_LOCAL_STORE)))
$(eval $(call generate-docker-build-target,$(BACKEND_VALKEY_STORE)))
@@ -1456,7 +1476,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_SUPERTONIC)))
docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-buun-llama-cpp docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-privacy-filter docker-build-trellis2cpp docker-build-valkey-store docker-build-audio-cpp
########################################################
### Mock Backend for E2E Tests

View File

@@ -0,0 +1,290 @@
ARG BASE_IMAGE=ubuntu:24.04
ARG GRPC_BASE_IMAGE=${BASE_IMAGE}
# The grpc target does one thing, it builds and installs GRPC. This is in it's own layer so that it can be effectively cached by CI.
# You probably don't need to change anything here, and if you do, make sure that CI is adjusted so that the cache continues to work.
FROM ${GRPC_BASE_IMAGE} AS grpc
# This is a bit of a hack, but it's required in order to be able to effectively cache this layer in CI
ARG GRPC_MAKEFLAGS="-j4 -Otarget"
ARG GRPC_VERSION=v1.65.0
ARG CMAKE_FROM_SOURCE=false
# CUDA Toolkit 13.x compatibility: CMake 3.31.9+ fixes toolchain detection/arch table issues
ARG CMAKE_VERSION=3.31.10
ENV MAKEFLAGS=${GRPC_MAKEFLAGS}
WORKDIR /build
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
build-essential curl libssl-dev \
git wget && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install CMake (the version in 22.04 is too old)
RUN <<EOT bash
if [ "${CMAKE_FROM_SOURCE}" = "true" ]; then
curl -L -s https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}.tar.gz -o cmake.tar.gz && tar xvf cmake.tar.gz && cd cmake-${CMAKE_VERSION} && ./configure && make && make install
else
apt-get update && \
apt-get install -y \
cmake && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
fi
EOT
# We install GRPC to a different prefix here so that we can copy in only the build artifacts later
# saves several hundred MB on the final docker image size vs copying in the entire GRPC source tree
# and running make install in the target container
RUN git clone --recurse-submodules --jobs 4 -b ${GRPC_VERSION} --depth 1 --shallow-submodules https://github.com/grpc/grpc && \
mkdir -p /build/grpc/cmake/build && \
cd /build/grpc/cmake/build && \
sed -i "216i\ TESTONLY" "../../third_party/abseil-cpp/absl/container/CMakeLists.txt" && \
cmake -DgRPC_INSTALL=ON -DgRPC_BUILD_TESTS=OFF -DCMAKE_INSTALL_PREFIX:PATH=/opt/grpc ../.. && \
make && \
make install && \
rm -rf /build
FROM ${BASE_IMAGE} AS builder
ARG CMAKE_FROM_SOURCE=false
ARG CMAKE_VERSION=3.31.10
# We can target specific CUDA ARCHITECTURES like --build-arg CUDA_DOCKER_ARCH='75;86;89;120'
ARG CUDA_DOCKER_ARCH
ENV CUDA_DOCKER_ARCH=${CUDA_DOCKER_ARCH}
ARG CMAKE_ARGS
ENV CMAKE_ARGS=${CMAKE_ARGS}
ARG BACKEND=rerankers
ARG BUILD_TYPE
ENV BUILD_TYPE=${BUILD_TYPE}
ARG CUDA_MAJOR_VERSION
ARG CUDA_MINOR_VERSION
ARG SKIP_DRIVERS=false
ENV CUDA_MAJOR_VERSION=${CUDA_MAJOR_VERSION}
ENV CUDA_MINOR_VERSION=${CUDA_MINOR_VERSION}
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGETARCH
ARG TARGETVARIANT
ARG GO_VERSION=1.25.4
ARG UBUNTU_VERSION=2404
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
ccache git \
ca-certificates \
make \
pkg-config libcurl4-openssl-dev \
curl unzip \
libssl-dev wget && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Cuda
ENV PATH=/usr/local/cuda/bin:${PATH}
# HipBLAS requirements
ENV PATH=/opt/rocm/bin:${PATH}
# Vulkan requirements
RUN <<EOT bash
if [ "${BUILD_TYPE}" = "vulkan" ] && [ "${SKIP_DRIVERS}" = "false" ]; then
apt-get update && \
apt-get install -y --no-install-recommends \
software-properties-common pciutils wget gpg-agent && \
apt-get install -y libglm-dev cmake libxcb-dri3-0 libxcb-present0 libpciaccess0 \
libpng-dev libxcb-keysyms1-dev libxcb-dri3-dev libx11-dev g++ gcc \
libwayland-dev libxrandr-dev libxcb-randr0-dev libxcb-ewmh-dev \
git python-is-python3 bison libx11-xcb-dev liblz4-dev libzstd-dev \
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
if [ "amd64" = "$TARGETARCH" ]; then
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
mkdir -p /opt/vulkan-sdk && \
mv 1.4.335.0 /opt/vulkan-sdk/ && \
cd /opt/vulkan-sdk/1.4.335.0 && \
./vulkansdk --no-deps --maxjobs \
vulkan-loader \
vulkan-validationlayers \
vulkan-extensionlayer \
vulkan-tools \
shaderc && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
rm -rf /opt/vulkan-sdk
fi
if [ "arm64" = "$TARGETARCH" ]; then
mkdir vulkan && cd vulkan && \
curl -L -o vulkan-sdk.tar.xz https://github.com/mudler/vulkan-sdk-arm/releases/download/1.4.335.0/vulkansdk-ubuntu-24.04-arm-1.4.335.0.tar.xz && \
tar -xvf vulkan-sdk.tar.xz && \
rm vulkan-sdk.tar.xz && \
cd 1.4.335.0 && \
cp -rfv aarch64/bin/* /usr/bin/ && \
cp -rfv aarch64/lib/* /usr/lib/aarch64-linux-gnu/ && \
cp -rfv aarch64/include/* /usr/include/ && \
cp -rfv aarch64/share/* /usr/share/ && \
cd ../.. && \
rm -rf vulkan
fi
ldconfig && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
fi
EOT
# CuBLAS requirements
RUN <<EOT bash
if ( [ "${BUILD_TYPE}" = "cublas" ] || [ "${BUILD_TYPE}" = "l4t" ] ) && [ "${SKIP_DRIVERS}" = "false" ]; then
apt-get update && \
apt-get install -y --no-install-recommends \
software-properties-common pciutils
if [ "amd64" = "$TARGETARCH" ]; then
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/x86_64/cuda-keyring_1.1-1_all.deb
fi
if [ "arm64" = "$TARGETARCH" ]; then
if [ "${CUDA_MAJOR_VERSION}" = "13" ]; then
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/sbsa/cuda-keyring_1.1-1_all.deb
else
curl -O https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${UBUNTU_VERSION}/arm64/cuda-keyring_1.1-1_all.deb
fi
fi
dpkg -i cuda-keyring_1.1-1_all.deb && \
rm -f cuda-keyring_1.1-1_all.deb && \
apt-get update && \
apt-get install -y --no-install-recommends \
cuda-nvcc-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcufft-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcurand-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcublas-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcusparse-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} \
libcusolver-dev-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION}
if [ "${CUDA_MAJOR_VERSION}" = "13" ] && [ "arm64" = "$TARGETARCH" ]; then
apt-get install -y --no-install-recommends \
libcufile-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} libcudnn9-cuda-${CUDA_MAJOR_VERSION} cuda-cupti-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION} libnvjitlink-${CUDA_MAJOR_VERSION}-${CUDA_MINOR_VERSION}
fi
apt-get clean && \
rm -rf /var/lib/apt/lists/*
fi
EOT
# https://github.com/NVIDIA/Isaac-GR00T/issues/343
RUN <<EOT bash
if [ "${BUILD_TYPE}" = "cublas" ] && [ "${TARGETARCH}" = "arm64" ]; then
wget https://developer.download.nvidia.com/compute/cudss/0.6.0/local_installers/cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0_0.6.0-1_arm64.deb && \
dpkg -i cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0_0.6.0-1_arm64.deb && \
cp /var/cudss-local-tegra-repo-ubuntu${UBUNTU_VERSION}-0.6.0/cudss-*-keyring.gpg /usr/share/keyrings/ && \
apt-get update && apt-get -y install cudss cudss-cuda-${CUDA_MAJOR_VERSION} && \
wget https://developer.download.nvidia.com/compute/nvpl/25.5/local_installers/nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5_1.0-1_arm64.deb && \
dpkg -i nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5_1.0-1_arm64.deb && \
cp /var/nvpl-local-repo-ubuntu${UBUNTU_VERSION}-25.5/nvpl-*-keyring.gpg /usr/share/keyrings/ && \
apt-get update && apt-get install -y nvpl
fi
EOT
# If we are building with clblas support, we need the libraries for the builds
RUN if [ "${BUILD_TYPE}" = "clblas" ] && [ "${SKIP_DRIVERS}" = "false" ]; then \
apt-get update && \
apt-get install -y --no-install-recommends \
libclblast-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* \
; fi
RUN if [ "${BUILD_TYPE}" = "hipblas" ] && [ "${SKIP_DRIVERS}" = "false" ]; then \
apt-get update && \
apt-get install -y --no-install-recommends \
hipblas-dev \
rocblas-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
# I have no idea why, but the ROCM lib packages don't trigger ldconfig after they install, which results in local-ai and others not being able
# to locate the libraries. We run ldconfig ourselves to work around this packaging deficiency
ldconfig && \
# Log which GPU architectures have rocBLAS kernel support
echo "rocBLAS library data architectures:" && \
(ls /opt/rocm*/lib/rocblas/library/Kernels* 2>/dev/null || ls /opt/rocm*/lib64/rocblas/library/Kernels* 2>/dev/null) | grep -oP 'gfx[0-9a-z+-]+' | sort -u || \
echo "WARNING: No rocBLAS kernel data found" \
; fi
RUN echo "TARGETARCH: $TARGETARCH"
# We need protoc installed, and the version in 22.04 is too old. We will create one as part installing the GRPC build below
# but that will also being in a newer version of absl which stablediffusion cannot compile with. This version of protoc is only
# here so that we can generate the grpc code for the stablediffusion build
RUN <<EOT bash
if [ "amd64" = "$TARGETARCH" ]; then
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v27.1/protoc-27.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
fi
if [ "arm64" = "$TARGETARCH" ]; then
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v27.1/protoc-27.1-linux-aarch_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
fi
EOT
# Install CMake (the version in 22.04 is too old)
RUN <<EOT bash
if [ "${CMAKE_FROM_SOURCE}" = "true" ]; then
curl -L -s https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}.tar.gz -o cmake.tar.gz && tar xvf cmake.tar.gz && cd cmake-${CMAKE_VERSION} && ./configure && make && make install
else
apt-get update && \
apt-get install -y \
cmake && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
fi
EOT
COPY --from=grpc /opt/grpc /usr/local
COPY . /LocalAI
RUN <<'EOT' bash
set -euxo pipefail
if [[ -n "${CUDA_DOCKER_ARCH:-}" ]]; then
CUDA_ARCH_ESC="${CUDA_DOCKER_ARCH//;/\\;}"
export CMAKE_ARGS="${CMAKE_ARGS:-} -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCH_ESC}"
echo "CMAKE_ARGS(env) = ${CMAKE_ARGS}"
rm -rf /LocalAI/backend/cpp/buun-llama-cpp-*-build
fi
cd /LocalAI/backend/cpp/buun-llama-cpp
if [ "${TARGETARCH}" = "arm64" ] || [ "${BUILD_TYPE}" = "hipblas" ]; then
make buun-llama-cpp-fallback
make buun-llama-cpp-grpc
make buun-llama-cpp-rpc-server
else
make buun-llama-cpp-avx
make buun-llama-cpp-avx2
make buun-llama-cpp-avx512
make buun-llama-cpp-fallback
make buun-llama-cpp-grpc
make buun-llama-cpp-rpc-server
fi
EOT
# Copy libraries using a script to handle architecture differences
RUN make -BC /LocalAI/backend/cpp/buun-llama-cpp package
FROM scratch
# Copy all available binaries (the build process only creates the appropriate ones for the target architecture)
COPY --from=builder /LocalAI/backend/cpp/buun-llama-cpp/package/. ./

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?=545e29a6f2fde24298cb3b0f07baab4352987ac9
AUDIO_CPP_VERSION?=5a8312ef7b8aa7cf14e9a24ac568cabd8725d68a
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))

View File

@@ -0,0 +1,92 @@
# Pinned to the HEAD of master on https://github.com/spiritbuun/buun-llama-cpp.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
BUUN_LLAMA_VERSION?=22464d0848b87c5d56b52fdf6af2e5da46bf803e
LLAMA_REPO?=https://github.com/spiritbuun/buun-llama-cpp
CMAKE_ARGS?=
BUILD_TYPE?=
NATIVE?=false
ONEAPI_VARS?=/opt/intel/oneapi/setvars.sh
TARGET?=--target grpc-server
JOBS?=$(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
ARCH?=$(shell uname -m)
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
LLAMA_CPP_DIR := $(CURRENT_MAKEFILE_DIR)/../llama-cpp
GREEN := \033[0;32m
RESET := \033[0m
# buun-llama-cpp is a llama.cpp fork-of-a-fork (spiritbuun/buun-llama-cpp forked
# TheTom/llama-cpp-turboquant, which itself forked ggml-org/llama.cpp). Rather
# than duplicating grpc-server.cpp / CMakeLists.txt / prepare.sh we reuse the
# ones in backend/cpp/llama-cpp, and only swap which repo+sha the fetch step
# pulls. Each flavor target copies ../llama-cpp into a sibling
# ../buun-llama-cpp-<flavor>-build directory, then invokes llama-cpp's own
# build-llama-cpp-grpc-server with LLAMA_REPO/LLAMA_VERSION overridden to point
# at the fork.
PATCHES_DIR := $(CURRENT_MAKEFILE_DIR)/patches
# Each flavor target:
# 1. copies backend/cpp/llama-cpp/ (grpc-server.cpp + prepare.sh + CMakeLists.txt + Makefile)
# into a sibling buun-llama-cpp-<flavor>-build directory;
# 2. clones the buun fork into buun-llama-cpp-<flavor>-build/llama.cpp via the
# copy's own `llama.cpp` target, overriding LLAMA_REPO/LLAMA_VERSION;
# 3. applies patches from backend/cpp/buun-llama-cpp/patches/ to the cloned
# fork sources (for backporting upstream commits the fork hasn't pulled);
# 4. runs the copy's `grpc-server` target, which produces the binary we copy
# up as buun-llama-cpp-<flavor>.
define buun-llama-cpp-build
rm -rf $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build
cp -rf $(LLAMA_CPP_DIR) $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build
# Stock llama.cpp patches target upstream and may not apply to this fork.
# The buun-specific compatibility series is applied explicitly below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build purge
# Augment the copied grpc-server.cpp's KV-cache allow-list with the
# fork's turbo2/turbo3/turbo4/turbo2_tcq/turbo3_tcq types and wire up the
# DFlash-specific option handlers (tree_budget / draft_topk). We patch the
# *copy*, never the original under backend/cpp/llama-cpp/, so the stock
# llama-cpp build stays compiling against vanilla upstream.
bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/grpc-server.cpp
$(info $(GREEN)I buun-llama-cpp build info:$(1)$(RESET))
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BUUN_LLAMA_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build llama.cpp
bash $(CURRENT_MAKEFILE_DIR)/apply-patches.sh $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/llama.cpp $(PATCHES_DIR)
CMAKE_ARGS="$(CMAKE_ARGS) $(2)" TARGET="$(3)" \
LLAMA_REPO=$(LLAMA_REPO) LLAMA_VERSION=$(BUUN_LLAMA_VERSION) \
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build grpc-server
cp -rfv $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-$(1)-build/grpc-server buun-llama-cpp-$(1)
endef
buun-llama-cpp-avx2:
$(call buun-llama-cpp-build,avx2,-DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on,--target grpc-server)
buun-llama-cpp-avx512:
$(call buun-llama-cpp-build,avx512,-DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on,--target grpc-server)
buun-llama-cpp-avx:
$(call buun-llama-cpp-build,avx,-DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server)
buun-llama-cpp-fallback:
$(call buun-llama-cpp-build,fallback,-DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server)
buun-llama-cpp-grpc:
$(call buun-llama-cpp-build,grpc,-DGGML_RPC=ON -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off,--target grpc-server --target rpc-server)
buun-llama-cpp-rpc-server: buun-llama-cpp-grpc
cp -rf $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-grpc-build/llama.cpp/build/bin/rpc-server buun-llama-cpp-rpc-server
package:
bash package.sh
test:
bash test-patch-grpc-server.sh
purge:
rm -rf $(CURRENT_MAKEFILE_DIR)/../buun-llama-cpp-*-build
rm -rf buun-llama-cpp-* package
clean: purge

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Apply the buun-llama-cpp patch series to a cloned buun-llama-cpp checkout.
#
# buun-llama-cpp is a fork-of-a-fork that branched off upstream llama.cpp
# before some API changes the shared backend/cpp/llama-cpp/grpc-server.cpp
# depends on. We carry those upstream commits as patch files under
# backend/cpp/buun-llama-cpp/patches/ and apply them here so the reused
# grpc-server source compiles against the fork unmodified.
#
# Drop the corresponding patch from patches/ whenever the fork catches up with
# upstream — the build will fail fast if a patch stops applying, which is the
# signal to retire it.
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "usage: $0 <llama.cpp-src-dir> <patches-dir>" >&2
exit 2
fi
SRC_DIR=$1
PATCHES_DIR=$2
if [[ ! -d "$SRC_DIR" ]]; then
echo "source dir does not exist: $SRC_DIR" >&2
exit 2
fi
if [[ ! -d "$PATCHES_DIR" ]]; then
echo "no patches dir at $PATCHES_DIR, nothing to apply"
exit 0
fi
shopt -s nullglob
patches=("$PATCHES_DIR"/*.patch)
shopt -u nullglob
if [[ ${#patches[@]} -eq 0 ]]; then
echo "no .patch files in $PATCHES_DIR, nothing to apply"
exit 0
fi
cd "$SRC_DIR"
for patch in "${patches[@]}"; do
echo "==> applying $patch"
git apply --verbose "$patch"
done
echo "all buun-llama-cpp patches applied successfully"

View File

@@ -0,0 +1,57 @@
#!/bin/bash
# Script to copy the appropriate libraries based on architecture
# This script is used in the final stage of the Dockerfile
set -e
CURDIR=$(dirname "$(realpath $0)")
REPO_ROOT="${CURDIR}/../../.."
# Create lib directory
mkdir -p $CURDIR/package/lib
cp -avrf $CURDIR/buun-llama-cpp-* $CURDIR/package/
cp -rfv $CURDIR/run.sh $CURDIR/package/
# Detect architecture and copy appropriate libraries
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
# x86_64 architecture
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 $CURDIR/package/lib/ld.so
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
# ARM64 architecture
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 $CURDIR/package/lib/ld.so
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries based on BUILD_TYPE
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah $CURDIR/package/
ls -liah $CURDIR/package/lib/

View File

@@ -0,0 +1,196 @@
#!/bin/bash
# Patch the shared backend/cpp/llama-cpp/grpc-server.cpp *copy* used by the
# buun-llama-cpp build to account for three gaps between upstream and the fork:
#
# 1. Augment the kv_cache_types[] allow-list so `LoadModel` accepts the
# fork-specific `turbo2` / `turbo3` / `turbo4` cache types plus the buun
# additions `turbo2_tcq` / `turbo3_tcq`.
#
# 2. Adapt the post-refactor speculative-decoding fields and option handlers
# to the fork's legacy flat common_params_speculative layout, while adding
# buun-exclusive tree_budget / draft_topk support.
# These reference struct fields (common_params.speculative.tree_budget
# and .draft_topk) that only exist in buun's common/common.h — adding
# them to the shared backend/cpp/llama-cpp/grpc-server.cpp would break
# the stock llama-cpp build, so we inject them only into the buun copy.
#
# 3. Replace `get_media_marker()` (added upstream in ggml-org/llama.cpp#21962,
# server-side random per-instance marker) with the legacy "<__media__>"
# literal. The fork branched before that PR, so server-common.cpp has no
# get_media_marker symbol. The fork's mtmd_default_marker() still returns
# "<__media__>", and Go-side tooling falls back to that sentinel when the
# backend does not expose media_marker, so substituting the literal keeps
# behavior identical on the buun path.
#
# We patch the *copy* sitting in buun-llama-cpp-<flavor>-build/, never the
# original under backend/cpp/llama-cpp/, so the stock llama-cpp build keeps
# compiling against vanilla upstream.
#
# Idempotent: skips each insertion if its marker is already present (so re-runs
# of the same build dir don't double-insert).
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 <grpc-server.cpp>" >&2
exit 2
fi
SRC=$1
if [[ ! -f "$SRC" ]]; then
echo "grpc-server.cpp not found at $SRC" >&2
exit 2
fi
if grep -q 'GGML_TYPE_TURBO2_TCQ' "$SRC"; then
echo "==> $SRC already has buun cache types, skipping KV allow-list patch"
else
echo "==> patching $SRC to allow turbo2/turbo3/turbo4/turbo2_tcq/turbo3_tcq KV-cache types"
# Insert the five TURBO entries right after the first ` GGML_TYPE_Q5_1,`
# line (the kv_cache_types[] allow-list). Using awk because the builder
# image does not ship python3, and GNU sed's multi-line `a\` quoting is
# awkward.
awk '
/^ GGML_TYPE_Q5_1,$/ && !done {
print
print " // buun-llama-cpp fork extras — added by patch-grpc-server.sh"
print " GGML_TYPE_TURBO2_0,"
print " GGML_TYPE_TURBO3_0,"
print " GGML_TYPE_TURBO4_0,"
print " GGML_TYPE_TURBO2_TCQ,"
print " GGML_TYPE_TURBO3_TCQ,"
done = 1
next
}
{ print }
END {
if (!done) {
print "patch-grpc-server.sh: anchor ` GGML_TYPE_Q5_1,` not found" > "/dev/stderr"
exit 1
}
}
' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> KV allow-list patch OK"
fi
if grep -q 'buun-llama-cpp legacy speculative options' "$SRC"; then
echo "==> $SRC already has legacy speculative option handlers, skipping"
else
echo "==> replacing modern speculative option handlers with the fork-compatible set"
# Replace the whole speculative option section. The fork predates chained
# speculative types and the nested draft/ngram families, so retaining any
# modern-only handler makes the copied server fail at compile time.
awk '
/} else if \(!strcmp\(optname, "spec_type"\)/ && !done {
print " // buun-llama-cpp legacy speculative options"
print " } else if (!strcmp(optname, \"spec_type\") || !strcmp(optname, \"speculative_type\")) {"
print " auto type = common_speculative_type_from_name(optval_str.substr(0, optval_str.find(\",\")));"
print " if (type != COMMON_SPECULATIVE_TYPE_COUNT) params.speculative.type = type;"
print " } else if (!strcmp(optname, \"spec_n_max\") || !strcmp(optname, \"draft_max\")) {"
print " if (optval != NULL) { try { params.speculative.n_max = std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_n_min\") || !strcmp(optname, \"draft_min\")) {"
print " if (optval != NULL) { try { params.speculative.n_min = std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_p_min\") || !strcmp(optname, \"draft_p_min\")) {"
print " if (optval != NULL) { try { params.speculative.p_min = std::stof(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_p_split\")) {"
print " if (optval != NULL) { try { params.speculative.p_split = std::stof(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_ngram_size_n\") || !strcmp(optname, \"ngram_size_n\")) {"
print " if (optval != NULL) { try { params.speculative.ngram_size_n = (uint16_t)std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_ngram_size_m\") || !strcmp(optname, \"ngram_size_m\")) {"
print " if (optval != NULL) { try { params.speculative.ngram_size_m = (uint16_t)std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"spec_ngram_min_hits\") || !strcmp(optname, \"ngram_min_hits\")) {"
print " if (optval != NULL) { try { params.speculative.ngram_min_hits = (uint16_t)std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"draft_gpu_layers\")) {"
print " if (optval != NULL) { try { params.speculative.n_gpu_layers = std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"tree_budget\")) {"
print " if (optval != NULL) { try { params.speculative.tree_budget = std::stoi(optval_str); } catch (...) {} }"
print " } else if (!strcmp(optname, \"draft_topk\")) {"
print " if (optval != NULL) { try { params.speculative.draft_topk = std::stoi(optval_str); } catch (...) {} }"
skipping = 1
next
}
skipping && /^ }$/ { skipping = 0; done = 1; print; next }
!skipping { print }
END {
if (!done) {
print "patch-grpc-server.sh: speculative option section not found" > "/dev/stderr"
exit 1
}
}
' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> legacy speculative option-handler patch OK"
fi
# The modern server initializes a vector of speculative types when DraftModel
# is present. The fork still exposes a single enum value.
awk '
/const bool no_spec_type = params\.speculative\.types\.empty\(\)/ && !done {
print " if (params.speculative.type == COMMON_SPECULATIVE_TYPE_NONE) {"
print " params.speculative.type = COMMON_SPECULATIVE_TYPE_DRAFT;"
print " }"
skipping = 1
next
}
skipping && /^ }$/ { skipping = 0; done = 1; next }
!skipping { print }
' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
# Map supported post-refactor fields back to the names used by the pinned fork.
sed -E \
-e 's/params\.speculative\.draft\.mparams\.path/params.speculative.mparams_dft.path/g' \
-e 's/params\.speculative\.draft\.n_gpu_layers/params.speculative.n_gpu_layers/g' \
-e 's/ctx_server\.impl->model_tgt/ctx_server.impl->model/g' \
-e '/params\.cache_idle_slots =/d' \
-e '/params\.split_mode = LLAMA_SPLIT_MODE_TENSOR;/d' \
-e '/params\.speculative\.draft\.tensor_buft_overrides/d' \
"$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
if ! grep -q '^#define LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP' "$SRC"; then
sed '0,/^#include/{s/^#include/#define LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP 1\n\n#include/}' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
fi
if grep -qE 'ctx_server\.get_meta\(\)\.logit_bias_eog|params_base\.sampling\.logit_bias_eog,' "$SRC"; then
echo "==> patching $SRC to drop the logit_bias_eog arg from params_from_json_cmpl() callsites (buun still uses the pre-refactor 4-arg signature)"
# Upstream llama.cpp refactored params_from_json_cmpl to take a precomputed
# logit_bias_eog vector after buun's 2026-04-05 fork-point — simultaneously
# adding server_context_meta::logit_bias_eog as the supplier. Buun carries
# neither change: its params_from_json_cmpl is still 4-arg, and internally
# derives logit_bias_eog from the common_params it's passed. So we just
# delete the argument line entirely — the remaining 4 args match buun's
# signature and the resulting behavior matches upstream bit-for-bit
# (upstream's 5th arg is the same data buun derives internally).
#
# Guard is broad so this works whether the line has been run through this
# block before (leaving params_base.sampling.logit_bias_eog,) or not
# (leaving the original ctx_server.get_meta().logit_bias_eog,).
sed -E '/^[[:space:]]+(ctx_server\.get_meta\(\)\.logit_bias_eog|params_base\.sampling\.logit_bias_eog),$/d' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> logit_bias_eog arg drop OK"
else
echo "==> $SRC has no logit_bias_eog arg line, skipping"
fi
if grep -q 'get_media_marker()' "$SRC"; then
echo "==> patching $SRC to replace get_media_marker() with legacy \"<__media__>\" literal"
# Only one call site today (ModelMetadata), but replace all occurrences to
# stay robust if upstream adds more. Use a temp file to avoid relying on
# sed -i portability (the builder image uses GNU sed, but keeping this
# consistent with the awk block above).
sed 's/get_media_marker()/"<__media__>"/g' "$SRC" > "$SRC.tmp"
mv "$SRC.tmp" "$SRC"
echo "==> get_media_marker() substitution OK"
else
echo "==> $SRC has no get_media_marker() call, skipping media-marker patch"
fi
echo "==> all patches applied"

View File

@@ -0,0 +1,46 @@
Subject: [PATCH] ggml-cuda/fattn: provide atomicAdd(double*,double) shim for pre-sm_60
Buun's Q² calibration path in ggml_cuda_turbo_scale_q calls
atomicAdd(&d_q_channel_sq_fattn[threadIdx.x], (double)(val * val));
but native double atomicAdd is only available on compute capability 6.0
and newer. Compiling against a CUDA arch list that includes older
architectures (LocalAI's CUDA 12 Docker image builds for the full
published arch range) fails with:
fattn.cu(812): error: no instance of overloaded function "atomicAdd"
matches the argument list, argument types are: (double *, double)
Add the canonical CUDA-programming-guide shim at the top of fattn.cu so
pre-sm_60 codegen has a definition to call. On sm_60+ the native CUDA
intrinsic is used and the shim is elided via __CUDA_ARCH__.
--- a/ggml/src/ggml-cuda/fattn.cu
+++ b/ggml/src/ggml-cuda/fattn.cu
@@ -7,6 +7,27 @@
#include <atomic>
+// Pre-sm_60 double atomicAdd shim. Native double atomicAdd(double*,double)
+// is only available on CUDA compute capability 6.0+ (see CUDA C Programming
+// Guide, B.15 Atomic Functions). Buun's Q² calibration path below calls
+// atomicAdd with a double*; without this definition, nvcc fails to find a
+// matching overload whenever the compile target list includes pre-sm_60
+// architectures. The standard CAS loop implementation below matches the
+// semantics of the native intrinsic.
+#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ < 600
+static __device__ double atomicAdd(double * address, double val) {
+ unsigned long long int * address_as_ull = (unsigned long long int *)address;
+ unsigned long long int old = *address_as_ull;
+ unsigned long long int assumed;
+ do {
+ assumed = old;
+ old = atomicCAS(address_as_ull, assumed,
+ __double_as_longlong(val + __longlong_as_double(assumed)));
+ } while (assumed != old);
+ return __longlong_as_double(old);
+}
+#endif
+
// InnerQ: update the fattn-side inverse scale array from host (all devices)
void turbo_innerq_update_fattn_scales(const float * scale_inv) {
int cur_device;

View File

@@ -0,0 +1,32 @@
Subject: [PATCH] ggml-cuda/argmax: pass WARP_SIZE to the top-K __shfl_xor_sync calls
Two __shfl_xor_sync calls in the top-K intra-warp merge drop the `width`
argument and rely on the CUDA default (warpSize). Every other call in
the same file already passes WARP_SIZE explicitly, and the HIP/ROCm
compatibility shim at ggml/src/ggml-cuda/vendors/hip.h:33 is a 4-arg
function-like macro — so the 3-arg form fails to preprocess when
building with hipcc against ROCm:
argmax.cu:265: error: too few arguments provided to function-like
macro invocation
note: macro '__shfl_xor_sync' defined here:
#define __shfl_xor_sync(mask, var, laneMask, width) \
__shfl_xor(var, laneMask, width)
Align the two call sites with the rest of the file by passing WARP_SIZE
explicitly. On CUDA the generated code is unchanged (warpSize is the
default); on HIP it now matches the macro's arity.
--- a/ggml/src/ggml-cuda/argmax.cu
+++ b/ggml/src/ggml-cuda/argmax.cu
@@ -262,8 +262,8 @@
// Each step: lane gets partner's min element, if it beats our min, replace and re-heapify
for (int offset = WARP_SIZE / 2; offset > 0; offset >>= 1) {
for (int i = 0; i < K; i++) {
- float partner_val = __shfl_xor_sync(0xFFFFFFFF, heap_val[i], offset);
- int partner_idx = __shfl_xor_sync(0xFFFFFFFF, heap_idx[i], offset);
+ float partner_val = __shfl_xor_sync(0xFFFFFFFF, heap_val[i], offset, WARP_SIZE);
+ int partner_idx = __shfl_xor_sync(0xFFFFFFFF, heap_idx[i], offset, WARP_SIZE);
if (partner_val > heap_val[0]) {
heap_val[0] = partner_val;
heap_idx[0] = partner_idx;

View File

@@ -0,0 +1,24 @@
Subject: [PATCH] ggml-cuda/vendors/hip: alias cudaMemcpy{To,From}Symbol to hip counterparts
Buun's Q² calibration + TCQ codebook upload paths in fattn.cu use
cudaMemcpyToSymbol / cudaMemcpyFromSymbol. The HIP-compat header in
ggml/src/ggml-cuda/vendors/hip.h already aliases the scalar cudaMemcpy
family (cudaMemcpy, cudaMemcpyAsync, cudaMemcpy2DAsync, …) but is
missing the symbol variants. Building with hipcc therefore fails with
15+ "use of undeclared identifier 'cudaMemcpyToSymbol'" errors.
Add the two missing aliases alongside the existing memcpy block. HIP
provides hipMemcpy{To,From}Symbol with the same signature as CUDA's
equivalents, so this is a straight name substitution.
--- a/ggml/src/ggml-cuda/vendors/hip.h
+++ b/ggml/src/ggml-cuda/vendors/hip.h
@@ -85,6 +85,8 @@
#define cudaMemcpyDeviceToDevice hipMemcpyDeviceToDevice
#define cudaMemcpyDeviceToHost hipMemcpyDeviceToHost
#define cudaMemcpyHostToDevice hipMemcpyHostToDevice
+#define cudaMemcpyToSymbol hipMemcpyToSymbol
+#define cudaMemcpyFromSymbol hipMemcpyFromSymbol
#define cudaMemcpyKind hipMemcpyKind
#define cudaMemset hipMemset
#define cudaMemsetAsync hipMemsetAsync

View File

@@ -0,0 +1,36 @@
Subject: [PATCH] ggml-cuda/fattn: pass WARP_SIZE to fwht128 __shfl_xor_sync calls
Same issue as the argmax top-K fix: two __shfl_xor_sync call sites in
the FWHT-128 butterfly kernels (ggml_cuda_fwht128 and fwht128_store_half)
use the 3-arg CUDA form and omit the `width` argument that the HIP
function-like macro in vendors/hip.h:33 requires. Hipcc fails with:
fattn.cu:512: too few arguments provided to function-like macro
invocation
note: macro '__shfl_xor_sync' defined here:
#define __shfl_xor_sync(mask, var, laneMask, width) \
__shfl_xor(var, laneMask, width)
Add WARP_SIZE to both calls. CUDA codegen is unchanged (warpSize is the
default); HIP now matches the macro arity.
--- a/ggml/src/ggml-cuda/fattn.cu
+++ b/ggml/src/ggml-cuda/fattn.cu
@@ -509,7 +509,7 @@
// Intra-warp passes: shuffle xor with stride h, no smem, no sync.
#pragma unroll
for (int h = 1; h <= 16; h *= 2) {
- const float other = __shfl_xor_sync(0xFFFFFFFF, val, h);
+ const float other = __shfl_xor_sync(0xFFFFFFFF, val, h, WARP_SIZE);
val = (tid & h) ? (other - val) : (val + other);
}
@@ -533,7 +533,7 @@
static __device__ __forceinline__ void fwht128_store_half(
float val, half * dst_base) {
const int tid = threadIdx.x;
- const float neighbor = __shfl_xor_sync(0xFFFFFFFF, val, 1);
+ const float neighbor = __shfl_xor_sync(0xFFFFFFFF, val, 1, WARP_SIZE);
if ((tid & 1) == 0) {
const half2 packed = __floats2half2_rn(val, neighbor);
*((half2 *)(dst_base + tid)) = packed;

View File

@@ -0,0 +1,65 @@
#!/bin/bash
set -ex
# Get the absolute current dir where the script is located
CURDIR=$(dirname "$(realpath $0)")
cd /
echo "CPU info:"
grep -e "model\sname" /proc/cpuinfo | head -1
grep -e "flags" /proc/cpuinfo | head -1
BINARY=buun-llama-cpp-fallback
if grep -q -e "\savx\s" /proc/cpuinfo ; then
echo "CPU: AVX found OK"
if [ -e $CURDIR/buun-llama-cpp-avx ]; then
BINARY=buun-llama-cpp-avx
fi
fi
if grep -q -e "\savx2\s" /proc/cpuinfo ; then
echo "CPU: AVX2 found OK"
if [ -e $CURDIR/buun-llama-cpp-avx2 ]; then
BINARY=buun-llama-cpp-avx2
fi
fi
# Check avx 512
if grep -q -e "\savx512f\s" /proc/cpuinfo ; then
echo "CPU: AVX512F found OK"
if [ -e $CURDIR/buun-llama-cpp-avx512 ]; then
BINARY=buun-llama-cpp-avx512
fi
fi
if [ -n "$LLAMACPP_GRPC_SERVERS" ]; then
if [ -e $CURDIR/buun-llama-cpp-grpc ]; then
BINARY=buun-llama-cpp-grpc
fi
fi
# Extend ld library path with the dir where this script is located/lib
if [ "$(uname)" == "Darwin" ]; then
export DYLD_LIBRARY_PATH=$CURDIR/lib:$DYLD_LIBRARY_PATH
else
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
# Tell rocBLAS where to find TensileLibrary data (GPU kernel tuning files)
if [ -d "$CURDIR/lib/rocblas/library" ]; then
export ROCBLAS_TENSILE_LIBPATH=$CURDIR/lib/rocblas/library
fi
fi
# If there is a lib/ld.so, use it
if [ -f $CURDIR/lib/ld.so ]; then
echo "Using lib/ld.so"
echo "Using binary: $BINARY"
exec $CURDIR/lib/ld.so $CURDIR/$BINARY "$@"
fi
echo "Using binary: $BINARY"
exec $CURDIR/$BINARY "$@"
# We should never reach this point, however just in case we do, run fallback
exec $CURDIR/buun-llama-cpp-fallback "$@"

View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SOURCE="$SCRIPT_DIR/../llama-cpp/grpc-server.cpp"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
cp "$SOURCE" "$TMP_DIR/grpc-server.cpp"
bash "$SCRIPT_DIR/patch-grpc-server.sh" "$TMP_DIR/grpc-server.cpp"
bash "$SCRIPT_DIR/../llama-cpp/disable-score-task.sh" "$TMP_DIR/grpc-server.cpp"
bash "$SCRIPT_DIR/patch-grpc-server.sh" "$TMP_DIR/grpc-server.cpp"
for unsupported in \
'params.cache_idle_slots' \
'params.speculative.types' \
'params.speculative.draft.' \
'common_speculative_types_from_names' \
'COMMON_SPECULATIVE_TYPE_DRAFT_SIMPLE' \
'ctx_server.impl->model_tgt'; do
if grep -Fq "$unsupported" "$TMP_DIR/grpc-server.cpp"; then
echo "unsupported buun API remains: $unsupported" >&2
exit 1
fi
done
grep -Fq '#define LOCALAI_LLAMA_CPP_NO_SCORE_TASK 1' "$TMP_DIR/grpc-server.cpp"
grep -Fq '#define LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP 1' "$TMP_DIR/grpc-server.cpp"
grep -Fq 'params.speculative.mparams_dft.path = request->draftmodel();' "$TMP_DIR/grpc-server.cpp"
grep -Fq 'params.speculative.type = COMMON_SPECULATIVE_TYPE_DRAFT;' "$TMP_DIR/grpc-server.cpp"
grep -Fq 'ctx_server.impl->model' "$TMP_DIR/grpc-server.cpp"
echo "buun grpc-server compatibility transform passed"

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
CRISPASR_VERSION?=66ac7843e319b588f5410051c575affd19424fb3
CRISPASR_VERSION?=fcb79282a6bc52e13d858026c42b24fb6e63c97a
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=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?=e31a86ce9110b11a98bd5990c329093244c2d1e3
STABLEDIFFUSION_GGML_VERSION?=db99efdd6d2a43c7937fd55b3359206c680a75b0
CMAKE_ARGS+=-DGGML_MAX_NAME=128

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -38,6 +38,14 @@ var CacheTypeOptions = []FieldOption{
{Value: "q4_1", Label: "Q4_1"},
{Value: "q5_0", Label: "Q5_0"},
{Value: "q5_1", Label: "Q5_1"},
// TurboQuant KV-cache types — accepted by the turboquant and
// buun-llama-cpp fork backends; stock llama-cpp will reject them at load.
{Value: "turbo2", Label: "Turbo2 (TurboQuant)"},
{Value: "turbo3", Label: "Turbo3 (TurboQuant)"},
{Value: "turbo4", Label: "Turbo4 (TurboQuant)"},
// Trellis-Coded Quantization variants — buun-llama-cpp only.
{Value: "turbo2_tcq", Label: "Turbo2 TCQ (buun-llama-cpp)"},
{Value: "turbo3_tcq", Label: "Turbo3 TCQ (buun-llama-cpp)"},
}
var DiffusersPipelineOptions = []FieldOption{

View File

@@ -38,6 +38,7 @@ func (i *LlamaCPPImporter) AdditionalBackends() []KnownBackendEntry {
{Name: "ik-llama-cpp", Modality: "text", Description: "GGUF drop-in replacement for llama-cpp with ik-quants"},
{Name: "turboquant", Modality: "text", Description: "GGUF drop-in replacement for llama-cpp with TurboQuant optimizations"},
{Name: "vllm-cpp", Modality: "text", Description: "vLLM-style continuous-batching engine (vllm.cpp) consuming GGUF, by the LocalAI team"},
{Name: "buun-llama-cpp", Modality: "text", Description: "GGUF drop-in replacement for llama-cpp with DFlash speculative decoding and TurboQuant/TCQ KV-cache quantization"},
}
}
@@ -136,7 +137,7 @@ func (i *LlamaCPPImporter) Import(details Details) (gallery.ModelConfig, error)
backend := "llama-cpp"
if b, ok := preferencesMap["backend"].(string); ok {
switch b {
case "ik-llama-cpp", "turboquant", "vllm-cpp":
case "ik-llama-cpp", "turboquant", "vllm-cpp", "buun-llama-cpp":
backend = b
}
}

View File

@@ -203,6 +203,23 @@ var _ = Describe("LlamaCPPImporter", func() {
Expect(modelConfig.Files[0].Filename).To(Equal("my-model.gguf"))
})
It("swaps the emitted backend to buun-llama-cpp when preferred", func() {
preferences := json.RawMessage(`{"backend": "buun-llama-cpp"}`)
details := Details{
URI: "https://example.com/my-model.gguf",
Preferences: preferences,
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: buun-llama-cpp"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(modelConfig.ConfigFile).NotTo(ContainSubstring("backend: llama-cpp\n"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(modelConfig.ConfigFile).To(ContainSubstring("model: my-model.gguf"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(len(modelConfig.Files)).To(Equal(1))
Expect(modelConfig.Files[0].Filename).To(Equal("my-model.gguf"))
})
It("keeps backend: llama-cpp for unknown backend preferences", func() {
// Unknown backend values must not leak into the emitted YAML —
// we only honour the curated drop-in replacements.
@@ -551,7 +568,7 @@ var _ = Describe("LlamaCPPImporter", func() {
})
Context("AdditionalBackends", func() {
It("advertises ik-llama-cpp, turboquant and vllm-cpp as drop-in replacements", func() {
It("advertises all llama-cpp drop-in replacements", func() {
entries := importer.AdditionalBackends()
names := make([]string, 0, len(entries))
@@ -560,7 +577,7 @@ var _ = Describe("LlamaCPPImporter", func() {
names = append(names, e.Name)
byName[e.Name] = e
}
Expect(names).To(ConsistOf("ik-llama-cpp", "turboquant", "vllm-cpp"))
Expect(names).To(ConsistOf("ik-llama-cpp", "turboquant", "vllm-cpp", "buun-llama-cpp"))
for _, name := range names {
e := byName[name]

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

@@ -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

@@ -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,103 @@
import { test, expect } from './coverage-fixtures.js'
// Home's resident-model list and the app footer.
const SYS_INFO = {
backends: ['llama-cpp'],
loaded_models: [
{ id: 'qwen3-8b-instruct', backend: 'llama-cpp' },
{ id: 'parakeet-tdt-0.6b' },
],
}
async function mockLoaded(page) {
await page.route('**/system', route => route.fulfill({ json: SYS_INFO }))
await page.route('**/v1/models', route =>
route.fulfill({ json: { data: [{ id: 'qwen3-8b-instruct' }, { id: 'parakeet-tdt-0.6b' }] } }))
}
test.describe('Home resident models', () => {
test('resident models read as lanes, not status chips', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const lanes = page.locator('.home-loaded .lane')
await expect(lanes).toHaveCount(2)
// Model ids are identifiers, so they are set in mono like every other
// identifier in the app.
const family = await lanes.first().locator('.lane__name').evaluate(
el => getComputedStyle(el).fontFamily.toLowerCase())
expect(family).toMatch(/mono|consol|menlo/)
})
test('each lane keeps its stop control', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const lane = page.locator('.home-loaded .lane').first()
await expect(lane.getByRole('button', { name: /stop/i })).toBeVisible()
})
test('the header reports how many are resident as a figure', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const stat = page.locator('[data-testid="home-stat-loaded"]')
await expect(stat).toBeVisible()
await expect(stat).toContainText('2')
// Digits that sit in a column need to line up.
const numeric = await stat.locator('.home-stat__value').evaluate(
el => getComputedStyle(el).fontVariantNumeric)
expect(numeric).toContain('tabular-nums')
})
test('a resident model names the engine serving it', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
// Lanes are sorted by id, so target by content rather than position.
const qwen = page.locator('.home-loaded .lane', { hasText: 'qwen3-8b-instruct' })
await expect(qwen).toContainText('llama-cpp')
})
test('a model without a config shows no engine rather than a guess', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
// parakeet has no backend in the payload; the column stays blank.
const parakeet = page.locator('.home-loaded .lane', { hasText: 'parakeet-tdt-0.6b' })
await expect(parakeet).not.toContainText('llama-cpp')
})
test('jump-back-in offers the three places worth returning to', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const lanes = page.locator('.lanes--jump .lane')
await expect(lanes).toHaveCount(3)
await expect(lanes.first()).toContainText('Discover')
})
test('nothing resident still says so', async ({ page }) => {
await page.route('**/system', route =>
route.fulfill({ json: { backends: ['llama-cpp'], loaded_models: [] } }))
await page.route('**/v1/models', route => route.fulfill({ json: { data: [{ id: 'a-model' }] } }))
await page.goto('/app')
await expect(page.locator('.home-loaded-empty')).toBeVisible()
await expect(page.locator('.home-loaded .lane')).toHaveCount(0)
})
})
test.describe('App footer', () => {
test('is one line, not three stacked rows', async ({ page }) => {
await page.goto('/app')
const footer = page.locator('.app-footer')
await expect(footer).toBeVisible()
// Three centred rows of chrome cost more vertical space than the content
// they sit under is usually worth.
const height = await footer.evaluate(el => el.getBoundingClientRect().height)
expect(height).toBeLessThan(56)
})
test('keeps every link it had', async ({ page }) => {
await page.goto('/app')
const footer = page.locator('.app-footer')
for (const name of [/github/i, /documentation/i, /author/i]) {
await expect(footer.getByRole('link', { name })).toBeVisible()
}
})
})

View File

@@ -1786,6 +1786,19 @@ test.describe("Models Gallery - Discover split view", () => {
);
});
test("a build that fits at some context sizes warns rather than erroring", async ({
page,
}) => {
await railItem(page, "llama-model").click();
const verdict = page.locator(".discover__chart-verdict");
await expect(verdict).toBeVisible();
// A model that fits at 32k but not 64k is a trade-off, and #11288 keeps a
// test on such a build still being installable. Only "fits nowhere" earns
// the error tone; anything short of that warns.
await expect(verdict).toHaveClass(/discover__chart-verdict--warn/);
await expect(verdict).not.toHaveClass(/discover__chart-verdict--bad/);
});
test("a host with no GPU gets no chart rather than an unanchored one", async ({
page,
}) => {

View File

@@ -56,81 +56,23 @@ async function gotoModels(page) {
}
test.describe("Models gallery - recommended panel prominence", () => {
test("first visit with nothing installed shows the panel expanded", async ({ page }) => {
test("it is a section in the flow, not a dismissable card", async ({ page }) => {
await mockGallery(page, 0);
await gotoModels(page);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
await expect(grid(page)).toBeVisible();
await expect(grid(page).getByText("tiny-chat")).toBeVisible();
await expect(panel(page)).toBeVisible();
// No close button and no collapse: this is the one thing the page has to
// say about the machine it runs on, not an interruption to be shut.
await expect(panel(page).locator("button[aria-expanded]")).toHaveCount(0);
await expect(panel(page).getByRole("button", { name: /dismiss|close/i })).toHaveCount(0);
// And no card chrome, so it sits in the pane rather than on top of it.
const border = await panel(page).evaluate((el) => getComputedStyle(el).borderTopWidth);
expect(parseFloat(border)).toBe(0);
});
test("a user with models installed gets it collapsed by default", async ({ page }) => {
await mockGallery(page, 12);
await gotoModels(page);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
await expect(grid(page)).toBeHidden();
// Collapsed is a summary, not a removal: the heading stays on the page.
await expect(panel(page).getByText("Recommended for your hardware")).toBeVisible();
await expect(panel(page).getByText("2 models suggested")).toBeVisible();
});
test("the collapsed summary expands again on activation", async ({ page }) => {
await mockGallery(page, 12);
await gotoModels(page);
await expect(grid(page)).toBeHidden();
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
await expect(grid(page)).toBeVisible();
await expect(page.evaluate((k) => localStorage.getItem(k), COLLAPSE_KEY)).resolves.toBe("0");
});
test("the collapse choice persists across a reload", async ({ page }) => {
await mockGallery(page, 0);
await gotoModels(page);
await expect(grid(page)).toBeVisible();
await toggle(page).click();
await expect(grid(page)).toBeHidden();
await page.reload();
await expect(panel(page)).toBeVisible({ timeout: 20_000 });
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
await expect(grid(page)).toBeHidden();
});
test("dismissing it persists across a reload", async ({ page }) => {
await mockGallery(page, 0);
await gotoModels(page);
await panel(page).getByRole("button", { name: "Dismiss recommendations" }).click();
await expect(panel(page)).toHaveCount(0);
await expect(page.evaluate((k) => localStorage.getItem(k), DISMISS_KEY)).resolves.toBe("1");
await page.reload();
// The rail having entries is the marker that the page finished rendering
// without the panel. It used to be the table, which no longer exists.
await expect(
page.locator('[data-testid="discover-rail-item"]').first(),
).toBeVisible({ timeout: 20_000 });
await expect(panel(page)).toHaveCount(0);
});
test("the toggle is keyboard operable and exposes its state", async ({ page }) => {
await mockGallery(page, 12);
await gotoModels(page);
await toggle(page).focus();
await expect(toggle(page)).toBeFocused();
await page.keyboard.press("Enter");
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
// aria-controls must resolve to the region it actually shows and hides.
await expect(toggle(page)).toHaveAttribute("aria-controls", "rec-models-content");
await expect(grid(page)).toBeVisible();
});
test("recommendations render and their install buttons still work", async ({ page }) => {
await mockGallery(page, 0);
@@ -141,11 +83,23 @@ test.describe("Models gallery - recommended panel prominence", () => {
});
await gotoModels(page);
const card = grid(page).locator(".rec-models-item", { hasText: "tiny-chat" });
await expect(card).toBeVisible();
await expect(card.getByText("512.0 MB")).toBeVisible();
await card.getByRole("button", { name: "Install" }).click();
// Ranked candidates read in fit order, so these are lanes now rather than
// a grid of equal cards.
const row = grid(page).locator(".lane", { hasText: "tiny-chat" });
await expect(row).toBeVisible();
await expect(row.getByText("512.0 MB")).toBeVisible();
await row.getByRole("button", { name: "Install" }).click();
await expect.poll(() => installed).toBe("tiny-chat");
});
test("the best fit is called out, the rest are alternatives", async ({ page }) => {
await mockGallery(page, 0);
await gotoModels(page);
const rows = grid(page).locator(".lane");
await expect(rows.first().locator(".lane__tag--evidence")).toHaveText("Best fit");
// One opinion per page: the others are alternatives, not runners-up worth
// their own colour.
await expect(grid(page).locator(".lane__tag--evidence")).toHaveCount(1);
});
});

View File

@@ -0,0 +1,172 @@
import { test, expect } from './coverage-fixtures.js'
// Operate overview (src/pages/OperateOverview.jsx).
//
// The page exists to answer "is anything wrong" without visiting four other
// pages, so the tests are written against that behaviour rather than against
// the markup: what does it say when nothing is wrong, and does each source of
// trouble actually surface.
const OVERVIEW = '[data-testid="operate-overview"]'
const CLEAR = '[data-testid="operate-attention-clear"]'
const ITEM = '[data-testid="operate-attention-item"]'
const NO_UPGRADES = {}
const ONE_UPGRADE = {
'llama-cpp': {
backend_name: 'llama-cpp',
installed_version: '0.9.4',
available_version: '0.9.7',
},
}
// A quiet installation: nothing running, nothing stale, every node healthy.
async function mockQuiet(page, { upgrades = NO_UPGRADES, operations = [] } = {}) {
await page.route('**/api/backends/upgrades', route =>
route.fulfill({ json: upgrades }))
await page.route('**/api/operations', route =>
route.fulfill({ json: operations }))
await page.route('**/api/nodes', route =>
route.fulfill({ json: [{ id: 'node-a', status: 'healthy', healthy: true }] }))
}
test.describe('Operate overview', () => {
test('Operate opens the overview, not whichever page happens to be first', async ({ page }) => {
await mockQuiet(page)
await page.goto('/app')
await page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }).click()
// Today this lands on /app/backends purely because Backends is the first
// entry in operateConsole.groups — an ordering accident, not a decision.
await expect(page).toHaveURL(/\/app\/operate$/)
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('says so plainly when nothing needs attention', async ({ page }) => {
await mockQuiet(page)
await page.goto('/app/operate')
await expect(page.locator(CLEAR)).toBeVisible()
// The empty state is one line, not a panel full of reassuring green.
await expect(page.locator(ITEM)).toHaveCount(0)
})
test('a stale backend becomes an attention item naming the version jump', async ({ page }) => {
await mockQuiet(page, { upgrades: ONE_UPGRADE })
await page.goto('/app/operate')
const item = page.locator(ITEM, { hasText: 'llama-cpp' })
await expect(item).toBeVisible()
await expect(item).toContainText('0.9.4')
await expect(item).toContainText('0.9.7')
await expect(page.locator(CLEAR)).toHaveCount(0)
})
test('a failed operation becomes an attention item', async ({ page }) => {
await mockQuiet(page, {
operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', error: 'no space left on device' }],
})
await page.goto('/app/operate')
await expect(page.locator(ITEM, { hasText: 'qwen3-8b' })).toBeVisible()
})
test('the rail reports backend updates alongside the label', async ({ page }) => {
await mockQuiet(page, { upgrades: ONE_UPGRADE })
await page.goto('/app/operate')
const backends = page.locator('.console-rail a.nav-item[href="/app/backends"]')
await expect(backends).toBeVisible()
await expect(backends.locator('.nav-signal')).toContainText('1')
})
test('the rail groups Runtime, Cluster, Observability and Administration', async ({ page }) => {
await mockQuiet(page)
await page.goto('/app/operate')
const rail = page.locator('.console-rail')
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
}
// Six headings for thirteen items was the defect; the old pairs are gone.
for (const gone of ['Inference', 'Access']) {
await expect(rail.locator('.console-group-title', { hasText: new RegExp(`^${gone}$`) })).toHaveCount(0)
}
})
test('regrouping does not change what a non-distributed host can see', async ({ page }) => {
await page.route('**/api/features', route =>
route.fulfill({ json: { distributed: false, agents: true, mcp: true } }))
await mockQuiet(page)
await page.goto('/app/operate')
const rail = page.locator('.console-rail')
await expect(rail.locator('a.nav-item[href="/app/backends"]')).toBeVisible()
// Gating is the thing most likely to break silently when items move group.
await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toHaveCount(0)
await expect(rail.locator('a.nav-item[href="/app/scheduling"]')).toHaveCount(0)
})
test('the sidebar keeps its operations badge', async ({ page }) => {
// Regression guard: this change edits the same config the badge reads, and
// the badge deliberately lives on the always-visible sidebar entry rather
// than the collapsible rail.
await mockQuiet(page, {
operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', progress: 40 }],
})
await page.goto('/app')
await expect(page.locator('.sidebar-nav .nav-badge')).toBeVisible()
})
test('does not poll the summary away from Operate', async ({ page }) => {
let upgradeCalls = 0
await page.route('**/api/backends/upgrades', route => {
upgradeCalls += 1
route.fulfill({ json: NO_UPGRADES })
})
await page.route('**/api/operations', route => route.fulfill({ json: [] }))
await page.goto('/app/chat')
await expect(page.locator('.sidebar')).toBeVisible()
await page.waitForTimeout(1500)
// Nobody asked for this data outside Operate; a dashboard-shaped poll on
// every page is exactly what OperationsContext exists to avoid.
expect(upgradeCalls).toBe(0)
})
})
test.describe('Operate overview headline', () => {
const SUMMARY = {
total: 18402, errors: 37, p95_ms: 842, window_hours: 24,
buckets: Array.from({ length: 12 }, (_, i) => ({ count: 100 + i * 10, errors: i })),
}
test('reports counted totals rather than fetching the trace list', async ({ page }) => {
let listCalls = 0
await page.route('**/api/traces?**', route => { listCalls += 1; route.fulfill({ json: [] }) })
await page.route('**/api/traces/summary', route => route.fulfill({ json: SUMMARY }))
await mockQuiet(page)
await page.goto('/app/operate')
const headline = page.locator('.operate-headline')
await expect(headline).toBeVisible()
await expect(headline).toContainText('18,402')
await expect(headline).toContainText('37')
await expect(headline).toContainText('842')
// The whole point of the endpoint: three numbers, not the buffer.
expect(listCalls).toBe(0)
})
test('a quiet installation keeps the grid and says why it is empty', async ({ page }) => {
// Hiding the grid removed the page's structure exactly when someone was
// most likely to be looking at it, and "0 failed" is information.
await page.route('**/api/traces/summary', route =>
route.fulfill({ json: { total: 0, errors: 0, p95_ms: 0, window_hours: 24, buckets: [] } }))
await mockQuiet(page)
await page.goto('/app/operate')
await expect(page.locator('.operate-headline')).toBeVisible()
await expect(page.locator('.operate-headline__cell')).toHaveCount(4)
await expect(page.locator('.operate-headline__note')).toBeVisible()
})
test('the sections state counts rather than listing their destinations', async ({ page }) => {
await page.route('**/api/traces/summary', route =>
route.fulfill({ json: { total: 18402, errors: 37, p95_ms: 842, window_hours: 24, buckets: [] } }))
await mockQuiet(page)
await page.goto('/app/operate')
const runtime = page.locator('.lanes--sections .lane').first()
await expect(runtime).toContainText('backends')
await expect(runtime).toContainText('running')
})
})

View File

@@ -19,6 +19,7 @@ const PAGES = [
['/app/account', 'Account'],
['/app/studio', 'Studio'],
['/app/manage', 'Manage'],
['/app/operate', 'Operate overview'],
['/app/backends', 'Backends'],
['/app/activity', 'Activity'],
['/app/settings', 'Settings'],

View File

@@ -0,0 +1,155 @@
import { test, expect } from './coverage-fixtures.js'
// Studio overview (src/pages/StudioOverview.jsx).
//
// Studio was a tab strip over six generators that opened on Images and told you
// nothing about what this machine could actually run. The tests are about that:
// what the strip reports before you click, and the difference between a
// modality that is switched off and one that merely has no model.
const OVERVIEW = '[data-testid="studio-overview"]'
const MODALITY = '[data-testid="studio-modality"]'
const tabFor = (page, key) => page.locator(`.studio-tab[data-tab="${key}"]`)
const model = (id, ...capabilities) => ({ id, capabilities })
// Images and speech covered, video and sound not. 3D and transform are feature
// flags rather than models, so they are controlled separately.
const SOME_MODELS = {
data: [
model('flux.1-schnell', 'FLAG_IMAGE'),
model('kokoro-82m', 'FLAG_TTS'),
model('qwen3-8b', 'FLAG_CHAT'),
],
}
async function mockCapabilities(page, payload = SOME_MODELS) {
await page.route('**/api/models/capabilities', route => route.fulfill({ json: payload }))
}
test.describe('Studio overview', () => {
test('Studio opens on the overview rather than dropping into Images', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('a generator path opens that generator', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio/images')
await expect(page.locator(OVERVIEW)).toHaveCount(0)
await expect(page.locator('.media-layout')).toBeVisible()
})
test('an unrecognised tab falls back to the overview, not to Images', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio/nonsense')
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('the tab strip reports which modalities have a model', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
// Filled: something installed advertises the capability.
await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible()
await expect(tabFor(page, 'tts').locator('.studio-tab__dot--on')).toBeVisible()
// Hollow: the modality is available, nothing serves it yet.
await expect(tabFor(page, 'video').locator('.studio-tab__dot--off')).toBeVisible()
await expect(tabFor(page, 'sound').locator('.studio-tab__dot--off')).toBeVisible()
})
test('a modality with no model offers a way to install one', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
const video = page.locator(`${MODALITY}[data-modality="video"]`)
await expect(video).toBeVisible()
// The point of the lane: not a dead tab, a route to fixing it.
await expect(video.locator('a[href*="/app/models"]')).toBeVisible()
})
test('a modality with a model names it instead of offering an install', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
const images = page.locator(`${MODALITY}[data-modality="images"]`)
await expect(images).toContainText('flux.1-schnell')
await expect(images.locator('a[href*="/app/models"]')).toHaveCount(0)
})
test('a disabled feature gets no tab and no lane at all', async ({ page }) => {
// Switched off is a different thing from "no model installed", and
// conflating them is how someone ends up staring at a control that cannot
// work. 3d is a permission rather than an /api/features entry, and
// hasFeature() short-circuits to true for admins and for auth-off
// installations, so withholding it needs a real non-admin session.
await page.route('**/api/auth/status', route => route.fulfill({
json: {
authEnabled: true,
user: { name: 'someone', role: 'user', permissions: { images: true, video: true, tts: true, sound: true } },
},
}))
await mockCapabilities(page)
await page.goto('/app/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await expect(tabFor(page, 'threed')).toHaveCount(0)
await expect(page.locator(`${MODALITY}[data-modality="threed"]`)).toHaveCount(0)
})
test('asks the capabilities endpoint once, not once per modality', async ({ page }) => {
let calls = 0
await page.route('**/api/models/capabilities', route => {
calls += 1
route.fulfill({ json: SOME_MODELS })
})
await page.goto('/app/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await page.waitForTimeout(500)
// useModels() fetches the whole list and filters in the browser, so one
// hook per modality would be six identical requests on every mount.
expect(calls).toBe(1)
})
test('an installation with no models at all still renders every modality', async ({ page }) => {
await mockCapabilities(page, { data: [] })
await page.goto('/app/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await expect(page.locator(MODALITY).first()).toBeVisible()
await expect(page.locator('.studio-tab__dot--on')).toHaveCount(0)
})
test('recent outputs surface what was generated earlier', async ({ page }) => {
await mockCapabilities(page)
// History is localStorage, written by each generator. The overview is the
// first place it is read across modalities rather than within one.
await page.addInitScript(() => {
localStorage.setItem('localai_image_history', JSON.stringify([
{ id: 'i1', createdAt: Date.now(), model: 'flux.1-schnell', prompt: 'a brass orrery', elapsedMs: 6100 },
]))
})
await page.goto('/app/studio')
const shelf = page.locator('[data-testid="studio-recent"]')
await expect(shelf).toBeVisible()
await expect(shelf).toContainText('flux.1-schnell')
})
test('no history means no empty shelf', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
await expect(page.locator('[data-testid="studio-recent"]')).toHaveCount(0)
})
test('the overview is reachable back from a generator tab', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio/images')
await tabFor(page, 'overview').click()
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('a legacy ?tab= link is redirected to its path', async ({ page }) => {
// Bookmarks and older docs still use the query form; they must keep working
// and must land on the canonical URL rather than a second spelling of it.
await mockCapabilities(page)
await page.goto('/app/studio?tab=images')
await expect(page).toHaveURL(/\/app\/studio\/images$/)
await expect(page.locator('.media-layout')).toBeVisible()
})
})

View File

@@ -2,7 +2,7 @@ import { test, expect } from './coverage-fixtures.js'
test.describe('Studio - Transform', () => {
test('Studio exposes a Transform tab that renders Audio Transform', async ({ page }) => {
await page.goto('/app/studio?tab=transform')
await page.goto('/app/studio/transform')
await expect(page.locator('.studio-tab', { hasText: 'Transform' })).toBeVisible()
await expect(page.locator('h1.page-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 })
})

View File

@@ -0,0 +1,49 @@
import { test, expect } from './coverage-fixtures.js'
// The generator workbenches (mock 5b/5c): the control column and the record of
// what the form actually sent.
test.describe('Studio workbench', () => {
test('the control column is a hairline field stack, not a shadowed card', async ({ page }) => {
await page.goto('/app/studio/images')
const controls = page.locator('.media-controls')
await expect(controls).toBeVisible()
const style = await controls.evaluate(el => {
const cs = getComputedStyle(el)
return { shadow: cs.boxShadow, radius: cs.borderTopLeftRadius }
})
expect(style.shadow).toBe('none')
expect(style.radius).toBe('0px')
})
test('fields are separated by a rule and labelled in caps', async ({ page }) => {
await page.goto('/app/studio/images')
const label = page.locator('.media-controls .form-label').first()
await expect(label).toBeVisible()
const cs = await label.evaluate(el => getComputedStyle(el).textTransform)
expect(cs).toBe('uppercase')
})
test('no request is shown before one has been made', async ({ page }) => {
// A panel describing a request nobody sent is a tutorial, not a record.
await page.goto('/app/studio/images')
await expect(page.locator('.request-panel')).toHaveCount(0)
})
test('generating records the request that was actually sent', async ({ page }) => {
await page.route('**/api/models/capabilities', route =>
route.fulfill({ json: { data: [{ id: 'flux-mock', capabilities: ['FLAG_IMAGE'] }] } }))
await page.route('**/v1/images/generations', route =>
route.fulfill({ json: { data: [{ url: 'https://example.invalid/a.png' }] } }))
await page.goto('/app/studio/images')
await page.locator('.media-controls textarea').first().fill('a brass orrery')
await page.getByRole('button', { name: /generate/i }).click()
const panel = page.locator('.request-panel')
await expect(panel).toBeVisible()
await expect(panel).toContainText('/v1/images/generations')
await expect(panel).toContainText('a brass orrery')
await expect(panel.getByRole('button', { name: /curl/i })).toBeVisible()
})
})

View File

@@ -0,0 +1,15 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Theme default', () => {
test('a fresh install opens dark even when the OS prefers light', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' })
await page.goto('/app')
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
})
test('a stored choice still wins', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('localai-theme', 'light'))
await page.goto('/app')
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
})
})

View File

@@ -276,9 +276,11 @@ test.describe('3D generation', () => {
})
})
await page.goto('/app/studio?tab=threed')
await page.goto('/app/studio/threed')
await expect(page.getByRole('button', { name: '3D', exact: true })).toHaveCount(0)
await expect(page.locator('.studio-tab', { hasText: 'Images' })).toHaveClass(/studio-tab-active/)
// Falls back to the overview rather than Images. Landing on Images was
// never a decision, only the first entry in the tab array.
await expect(page.locator('.studio-tab[data-tab="overview"]')).toHaveClass(/studio-tab-active/)
await page.goto('/app/3d')
await expect(page).toHaveURL(/\/app\/?$/)

View File

@@ -0,0 +1,42 @@
import { test, expect } from './coverage-fixtures.js'
// Traces rows carry latency as a shape, not only as a number buried in the
// expanded detail (mock 6d).
const TRACES = [
{ id: '1', timestamp: new Date().toISOString(), duration: 4_200_000_000,
request: { method: 'POST', path: '/v1/chat/completions' }, response: { status: 500 }, error: 'context length exceeded' },
{ id: '2', timestamp: new Date().toISOString(), duration: 980_000_000,
request: { method: 'POST', path: '/v1/chat/completions' }, response: { status: 200 } },
{ id: '3', timestamp: new Date().toISOString(), duration: 186_000_000,
request: { method: 'POST', path: '/v1/embeddings' }, response: { status: 200 } },
]
test.describe('Traces latency', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/traces**', route => route.fulfill({ json: TRACES }))
await page.goto('/app/traces')
})
test('every row shows a latency bar and figure', async ({ page }) => {
const cells = page.locator('.lat')
await expect(cells).toHaveCount(3)
await expect(cells.first()).toContainText('4.20s')
})
test('the bar is scaled against the slowest request in view', async ({ page }) => {
// Wait for the rows: goto alone does not guarantee the fetch has painted.
await expect(page.locator('.lat__bar i')).toHaveCount(3)
const widths = await page.locator('.lat__bar i').evaluateAll(
els => els.map(el => parseFloat(el.style.width)))
// 4.2s is the slowest, so it is full; 186ms is a sliver of it.
expect(widths[0]).toBe(100)
expect(widths[2]).toBeLessThan(20)
})
test('a slow request is marked, not just long', async ({ page }) => {
await expect(page.locator('.lat')).toHaveCount(3)
// Colour carries the threshold; the figure carries the value.
await expect(page.locator('.lat__bar--slow')).toHaveCount(1)
})
})

View File

@@ -0,0 +1,33 @@
import { test, expect } from './coverage-fixtures.js'
// The empty voice library must offer its action, visibly, inside the panel.
test.describe('Voice library empty state', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/voice-profiles', route => route.fulfill({ json: [] }))
await page.goto('/app/voice-library')
})
test('the create action is visible and a normal size', async ({ page }) => {
const action = page.locator('.empty-state__actions a.btn').first()
await expect(action).toBeVisible()
const box = await action.boundingBox()
// It had been carrying the panel's own min-height:430px, which made it an
// invisible box that pushed itself out of view.
expect(box.height).toBeLessThan(80)
})
test('the action sits inside the panel, not past its edge', async ({ page }) => {
const panel = page.locator('.empty-state').first()
const action = page.locator('.empty-state__actions a.btn').first()
const [p, a] = [await panel.boundingBox(), await action.boundingBox()]
expect(a.y + a.height).toBeLessThanOrEqual(p.y + p.height + 1)
})
test('the panel renders its icon', async ({ page }) => {
const icon = page.locator('.empty-state-icon').first()
await expect(icon).toBeVisible()
const box = await icon.boundingBox()
expect(box.width).toBeGreaterThan(0)
})
})

View File

@@ -1 +1 @@
624
538

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",
@@ -143,5 +145,36 @@
"explorer": {
"title": "Explorer",
"subtitle": "Dateien und Konfiguration durchsuchen"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "No requests served in this window yet.",
"host": "Host memory"
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "Neuer Chat",
"clearAll": "Alle löschen",
"deleteAllTitle": "Alle Unterhaltungen löschen"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "LocalAI per Chat verwalten",
@@ -47,7 +49,8 @@
"count_one": "{{count}} Modell geladen",
"count_other": "{{count}} Modelle geladen",
"stop": "Modell stoppen",
"stopAll": "Alle stoppen"
"stopAll": "Alle stoppen",
"serving": "Serving"
},
"stopDialog": {
"title": "Modell stoppen",
@@ -88,5 +91,14 @@
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
"operateSummary": "{{models}} models configured · nodes, activity and traces"
}
}

View File

@@ -5,6 +5,32 @@
"video": "Video",
"tts": "TTS",
"sound": "Audio",
"transform": "Transform",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
@@ -157,5 +183,10 @@
"clearMessage": "Alle Verlaufseinträge entfernen? Diese Aktion kann nicht rückgängig gemacht werden.",
"clearConfirm": "Löschen",
"cleared": "Verlauf gelöscht"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -10,7 +10,9 @@
"installStarted": "{{model}} wird installiert…",
"installFailed": "Installation fehlgeschlagen: {{message}}",
"dismiss": "Empfehlungen ausblenden",
"summary": "{{n}} Modelle vorgeschlagen"
"summary": "{{n}} Modelle vorgeschlagen",
"bestFit": "Best fit",
"alternative": "Also fits"
},
"stats": {
"available": "Verfügbar",

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "Laufzeit",
"administration": "Verwaltung"
},
"items": {
"home": "Start",
@@ -57,7 +59,8 @@
"settings": "Einstellungen",
"api": "API",
"middleware": "Middleware",
"activity": "Aktivität"
"activity": "Aktivität",
"overview": "Übersicht"
},
"footer": {
"github": "GitHub",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",
@@ -166,5 +168,36 @@
"explorer": {
"title": "Explorer",
"subtitle": "Browse files and configuration"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "No requests served in this window yet.",
"host": "Host memory"
}
}
}
}

View File

@@ -124,5 +124,8 @@
"newChat": "New chat",
"clearAll": "Clear all",
"deleteAllTitle": "Delete all conversations"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "Manage LocalAI by chatting",
@@ -47,7 +49,8 @@
"count_one": "{{count}} model loaded",
"count_other": "{{count}} models loaded",
"stop": "Stop model",
"stopAll": "Stop all"
"stopAll": "Stop all",
"serving": "Serving"
},
"stopDialog": {
"title": "Stop Model",
@@ -103,5 +106,14 @@
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
"operateSummary": "{{models}} models configured · nodes, activity and traces"
}
}

View File

@@ -6,7 +6,33 @@
"tts": "TTS",
"sound": "Sound",
"transform": "Transform",
"threed": "3D"
"threed": "3D",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
"image": {
@@ -426,5 +452,10 @@
"clearMessage": "Remove all history entries? This cannot be undone.",
"clearConfirm": "Clear",
"cleared": "History cleared"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -11,7 +11,9 @@
"installStarted": "Installing {{model}}…",
"installFailed": "Install failed: {{message}}",
"dismiss": "Dismiss recommendations",
"summary": "{{n}} models suggested"
"summary": "{{n}} models suggested",
"bestFit": "Best fit",
"alternative": "Also fits"
},
"stats": {
"available": "Available",

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "Runtime",
"administration": "Administration"
},
"items": {
"home": "Home",
@@ -58,7 +60,8 @@
"system": "System",
"settings": "Settings",
"api": "API",
"activity": "Activity"
"activity": "Activity",
"overview": "Overview"
},
"footer": {
"github": "GitHub",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",
@@ -143,5 +145,36 @@
"explorer": {
"title": "Explorador",
"subtitle": "Explora archivos y configuración"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "No requests served in this window yet.",
"host": "Host memory"
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "Nuevo chat",
"clearAll": "Borrar todo",
"deleteAllTitle": "Eliminar todas las conversaciones"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "Administra LocalAI chateando",
@@ -47,7 +49,8 @@
"count_one": "{{count}} modelo cargado",
"count_other": "{{count}} modelos cargados",
"stop": "Detener modelo",
"stopAll": "Detener todos"
"stopAll": "Detener todos",
"serving": "Serving"
},
"stopDialog": {
"title": "Detener modelo",
@@ -88,5 +91,14 @@
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
"operateSummary": "{{models}} models configured · nodes, activity and traces"
}
}

View File

@@ -5,6 +5,32 @@
"video": "Video",
"tts": "TTS",
"sound": "Sonido",
"transform": "Transform",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
@@ -157,5 +183,10 @@
"clearMessage": "¿Eliminar todas las entradas del historial? Esto no se puede deshacer.",
"clearConfirm": "Borrar",
"cleared": "Historial borrado"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -10,7 +10,9 @@
"installStarted": "Instalando {{model}}…",
"installFailed": "Error al instalar: {{message}}",
"dismiss": "Descartar recomendaciones",
"summary": "{{n}} modelos sugeridos"
"summary": "{{n}} modelos sugeridos",
"bestFit": "Best fit",
"alternative": "Also fits"
},
"stats": {
"available": "Disponibles",

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "Runtime",
"administration": "Administración"
},
"items": {
"home": "Inicio",
@@ -57,7 +59,8 @@
"settings": "Configuración",
"api": "API",
"middleware": "Middleware",
"activity": "Actividad"
"activity": "Actividad",
"overview": "Resumen"
},
"footer": {
"github": "GitHub",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",
@@ -166,5 +168,36 @@
"explorer": {
"title": "Penjelajah",
"subtitle": "Jelajahi file dan konfigurasi"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "No requests served in this window yet.",
"host": "Host memory"
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "Obrolan baru",
"clearAll": "Hapus semua",
"deleteAllTitle": "Hapus semua percakapan"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} model dimuat",
"noModelsLoaded": "Tidak ada model yang dimuat",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "Kelola LocalAI melalui obrolan",
@@ -47,7 +49,8 @@
"count_one": "{{count}} model dimuat",
"count_other": "{{count}} model dimuat",
"stop": "Hentikan model",
"stopAll": "Hentikan semua"
"stopAll": "Hentikan semua",
"serving": "Serving"
},
"stopDialog": {
"title": "Hentikan Model",
@@ -88,5 +91,14 @@
"browse": "Jelajahi API",
"hide": "Sembunyikan endpoint",
"dismiss": "Abaikan"
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
"operateSummary": "{{models}} models configured · nodes, activity and traces"
}
}

View File

@@ -5,7 +5,33 @@
"video": "Video",
"tts": "TTS",
"sound": "Suara",
"transform": "Transformasi"
"transform": "Transformasi",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
"image": {
@@ -204,5 +230,10 @@
"clearMessage": "Hapus semua entri riwayat? Tindakan ini tidak dapat dibatalkan.",
"clearConfirm": "Hapus",
"cleared": "Riwayat dihapus"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -11,7 +11,9 @@
"installStarted": "Menginstal {{model}}…",
"installFailed": "Instalasi gagal: {{message}}",
"dismiss": "Tutup rekomendasi",
"summary": "{{n}} model disarankan"
"summary": "{{n}} model disarankan",
"bestFit": "Best fit",
"alternative": "Also fits"
},
"stats": {
"available": "Tersedia",

View File

@@ -24,7 +24,9 @@
"observability": "Observabilitas",
"access": "Akses",
"system": "Sistem",
"activity": "Activity"
"activity": "Activity",
"runtime": "Runtime",
"administration": "Administrasi"
},
"items": {
"home": "Beranda",
@@ -57,7 +59,8 @@
"system": "Sistem",
"settings": "Pengaturan",
"api": "API",
"activity": "Aktivitas"
"activity": "Aktivitas",
"overview": "Ikhtisar"
},
"footer": {
"github": "GitHub",

View File

@@ -12,6 +12,8 @@
"timeLeft": "{{value}} left",
"cancel": "Cancel",
"cancelLabel": "Cancel {{name}}",
"pause": "Pause",
"pauseLabel": "Pause {{name}} and keep downloaded data",
"retry": "Retry",
"retryLabel": "Retry {{name}}",
"nodeCount": "{{count}} nodes",
@@ -143,5 +145,36 @@
"explorer": {
"title": "Esplora risorse",
"subtitle": "Sfoglia file e configurazioni"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "No requests served in this window yet.",
"host": "Host memory"
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "Nuova chat",
"clearAll": "Cancella tutto",
"deleteAllTitle": "Elimina tutte le conversazioni"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} modelli caricati",
"noModelsLoaded": "Nessun modello caricato",
"nodes_one": "{{count}} nodo",
"nodes_other": "{{count}} nodi"
"nodes_other": "{{count}} nodi",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "Gestisci LocalAI chattando",
@@ -47,7 +49,8 @@
"count_one": "{{count}} modello caricato",
"count_other": "{{count}} modelli caricati",
"stop": "Ferma modello",
"stopAll": "Ferma tutti"
"stopAll": "Ferma tutti",
"serving": "Serving"
},
"stopDialog": {
"title": "Ferma modello",
@@ -88,5 +91,14 @@
"browse": "Esplora le API",
"hide": "Nascondi gli endpoint",
"dismiss": "Ignora"
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
"operateSummary": "{{models}} models configured · nodes, activity and traces"
}
}

View File

@@ -5,6 +5,32 @@
"video": "Video",
"tts": "TTS",
"sound": "Audio",
"transform": "Transform",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
@@ -157,5 +183,10 @@
"clearMessage": "Rimuovere tutte le voci della cronologia? Questa azione non può essere annullata.",
"clearConfirm": "Cancella",
"cleared": "Cronologia cancellata"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -10,7 +10,9 @@
"installStarted": "Installazione di {{model}}…",
"installFailed": "Installazione non riuscita: {{message}}",
"dismiss": "Nascondi i consigli",
"summary": "{{n}} modelli suggeriti"
"summary": "{{n}} modelli suggeriti",
"bestFit": "Best fit",
"alternative": "Also fits"
},
"stats": {
"available": "Disponibili",

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "Runtime",
"administration": "Amministrazione"
},
"items": {
"home": "Home",
@@ -57,7 +59,8 @@
"settings": "Impostazioni",
"api": "API",
"middleware": "Middleware",
"activity": "Attività"
"activity": "Attività",
"overview": "Panoramica"
},
"footer": {
"github": "GitHub",

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