mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-07 05:43:30 -04:00
Compare commits
34 Commits
bot/issue-
...
feat/buun-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ea532fd8 | ||
|
|
490c6f8a4d | ||
|
|
7d15b39843 | ||
|
|
5e7f6621f1 | ||
|
|
b07fba8399 | ||
|
|
f5eb5e2a63 | ||
|
|
91046ba10a | ||
|
|
51bbf1ccb9 | ||
|
|
3f79cfb1dd | ||
|
|
c8aadeba3b | ||
|
|
bb56c06751 | ||
|
|
eb374e50ee | ||
|
|
bd076376be | ||
|
|
d28ccf32b5 | ||
|
|
95bd59d78e | ||
|
|
1741df0bf1 | ||
|
|
b6d2e94153 | ||
|
|
a0f7faaa2a | ||
|
|
133c546c3f | ||
|
|
8a68f3571c | ||
|
|
fd4ec083b9 | ||
|
|
8f74f74b10 | ||
|
|
cd62e8ff18 | ||
|
|
af98e76f84 | ||
|
|
7f9ffd9f54 | ||
|
|
5cb0c1a872 | ||
|
|
cd890b6a26 | ||
|
|
1c0380ad44 | ||
|
|
cb6e4d4391 | ||
|
|
cba54c5ea1 | ||
|
|
f951419207 | ||
|
|
58ea2f5d79 | ||
|
|
b89b0f73e5 | ||
|
|
45cd47cb99 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
149
.github/backend-matrix.yml
vendored
149
.github/backend-matrix.yml
vendored
@@ -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: ""
|
||||
|
||||
11
.github/workflows/gh-pages.yml
vendored
11
.github/workflows/gh-pages.yml
vendored
@@ -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
|
||||
|
||||
25
.github/workflows/test-extra.yml
vendored
25
.github/workflows/test-extra.yml
vendored
@@ -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
|
||||
|
||||
22
Makefile
22
Makefile
@@ -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
|
||||
|
||||
290
backend/Dockerfile.buun-llama-cpp
Normal file
290
backend/Dockerfile.buun-llama-cpp
Normal 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/. ./
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
|
||||
92
backend/cpp/buun-llama-cpp/Makefile
Normal file
92
backend/cpp/buun-llama-cpp/Makefile
Normal 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
|
||||
50
backend/cpp/buun-llama-cpp/apply-patches.sh
Executable file
50
backend/cpp/buun-llama-cpp/apply-patches.sh
Executable 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"
|
||||
57
backend/cpp/buun-llama-cpp/package.sh
Executable file
57
backend/cpp/buun-llama-cpp/package.sh
Executable 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/
|
||||
196
backend/cpp/buun-llama-cpp/patch-grpc-server.sh
Executable file
196
backend/cpp/buun-llama-cpp/patch-grpc-server.sh
Executable 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"
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
65
backend/cpp/buun-llama-cpp/run.sh
Executable file
65
backend/cpp/buun-llama-cpp/run.sh
Executable 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 "$@"
|
||||
34
backend/cpp/buun-llama-cpp/test-patch-grpc-server.sh
Executable file
34
backend/cpp/buun-llama-cpp/test-patch-grpc-server.sh
Executable 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"
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=0be97a7a5ad113f33e08729261649ccea2cdc5ff
|
||||
IK_LLAMA_VERSION?=cb9147fd0d9c08a9a84eee5ac405a73f4e10e3e1
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
37
core/backend/upscale.go
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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},
|
||||
|
||||
24
core/http/auth/features_moderation_test.go
Normal file
24
core/http/auth/features_moderation_test.go
Normal 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}))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
190
core/http/endpoints/openai/moderations.go
Normal file
190
core/http/endpoints/openai/moderations.go
Normal 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
|
||||
}
|
||||
105
core/http/endpoints/openai/moderations_test.go
Normal file
105
core/http/endpoints/openai/moderations_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
134
core/http/endpoints/openai/upscale.go
Normal file
134
core/http/endpoints/openai/upscale.go
Normal 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)
|
||||
}
|
||||
}
|
||||
89
core/http/endpoints/openai/upscale_test.go
Normal file
89
core/http/endpoints/openai/upscale_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
109
core/http/middleware/trace_summary.go
Normal file
109
core/http/middleware/trace_summary.go
Normal 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()
|
||||
}
|
||||
79
core/http/middleware/trace_summary_test.go
Normal file
79
core/http/middleware/trace_summary_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
23
core/http/react-ui/e2e/backends-notice.spec.js
Normal file
23
core/http/react-ui/e2e/backends-notice.spec.js
Normal 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)
|
||||
})
|
||||
55
core/http/react-ui/e2e/chat-transcript.spec.js
Normal file
55
core/http/react-ui/e2e/chat-transcript.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
50
core/http/react-ui/e2e/chrome-audit.spec.js
Normal file
50
core/http/react-ui/e2e/chrome-audit.spec.js
Normal 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([])
|
||||
})
|
||||
101
core/http/react-ui/e2e/console-narrow.spec.js
Normal file
101
core/http/react-ui/e2e/console-narrow.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
103
core/http/react-ui/e2e/home-lanes.spec.js
Normal file
103
core/http/react-ui/e2e/home-lanes.spec.js
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
172
core/http/react-ui/e2e/operate-overview.spec.js
Normal file
172
core/http/react-ui/e2e/operate-overview.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
|
||||
155
core/http/react-ui/e2e/studio-overview.spec.js
Normal file
155
core/http/react-ui/e2e/studio-overview.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
49
core/http/react-ui/e2e/studio-workbench.spec.js
Normal file
49
core/http/react-ui/e2e/studio-workbench.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
15
core/http/react-ui/e2e/theme-default.spec.js
Normal file
15
core/http/react-ui/e2e/theme-default.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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\/?$/)
|
||||
|
||||
42
core/http/react-ui/e2e/traces-latency.spec.js
Normal file
42
core/http/react-ui/e2e/traces-latency.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
33
core/http/react-ui/e2e/voice-library-empty.spec.js
Normal file
33
core/http/react-ui/e2e/voice-library-empty.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
624
|
||||
538
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "Neuer Chat",
|
||||
"clearAll": "Alle löschen",
|
||||
"deleteAllTitle": "Alle Unterhaltungen löschen"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,5 +124,8 @@
|
||||
"newChat": "New chat",
|
||||
"clearAll": "Clear all",
|
||||
"deleteAllTitle": "Delete all conversations"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "Nuevo chat",
|
||||
"clearAll": "Borrar todo",
|
||||
"deleteAllTitle": "Eliminar todas las conversaciones"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "Obrolan baru",
|
||||
"clearAll": "Hapus semua",
|
||||
"deleteAllTitle": "Hapus semua percakapan"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "Nuova chat",
|
||||
"clearAll": "Cancella tutto",
|
||||
"deleteAllTitle": "Elimina tutte le conversazioni"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user