diff --git a/.dockerignore b/.dockerignore
index 4de42deef..5a71590bd 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -8,6 +8,8 @@ volumes
examples/chatbot-ui/models
backend/go/image/stablediffusion-ggml/build/
backend/go/*/build
+backend/go/kimodocpp/build-*
+backend/go/kimodocpp/kimodocpp
backend/go/*/.cache
backend/go/*/sources
backend/go/*/package
diff --git a/.github/backend-matrix.yml b/.github/backend-matrix.yml
index 53cb07d8d..fa61fa514 100644
--- a/.github/backend-matrix.yml
+++ b/.github/backend-matrix.yml
@@ -3559,6 +3559,63 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
+ # kimodo.cpp: CPU and Vulkan, with native runners for each architecture.
+ - build-type: ''
+ cuda-major-version: ""
+ cuda-minor-version: ""
+ platforms: 'linux/amd64'
+ platform-tag: 'amd64'
+ tag-latest: 'auto'
+ tag-suffix: '-cpu-kimodocpp'
+ runs-on: 'ubuntu-latest'
+ base-image: "ubuntu:24.04"
+ skip-drivers: 'false'
+ backend: "kimodocpp"
+ dockerfile: "./backend/Dockerfile.golang"
+ context: "./"
+ ubuntu-version: '2404'
+ - build-type: ''
+ cuda-major-version: ""
+ cuda-minor-version: ""
+ platforms: 'linux/arm64'
+ platform-tag: 'arm64'
+ tag-latest: 'auto'
+ tag-suffix: '-cpu-kimodocpp'
+ runs-on: 'ubuntu-24.04-arm'
+ base-image: "ubuntu:24.04"
+ skip-drivers: 'false'
+ backend: "kimodocpp"
+ dockerfile: "./backend/Dockerfile.golang"
+ context: "./"
+ ubuntu-version: '2404'
+ - build-type: 'vulkan'
+ cuda-major-version: ""
+ cuda-minor-version: ""
+ platforms: 'linux/amd64'
+ platform-tag: 'amd64'
+ tag-latest: 'auto'
+ tag-suffix: '-gpu-vulkan-kimodocpp'
+ runs-on: 'ubuntu-latest'
+ base-image: "ubuntu:24.04"
+ skip-drivers: 'false'
+ backend: "kimodocpp"
+ dockerfile: "./backend/Dockerfile.golang"
+ context: "./"
+ ubuntu-version: '2404'
+ - build-type: 'vulkan'
+ cuda-major-version: ""
+ cuda-minor-version: ""
+ platforms: 'linux/arm64'
+ platform-tag: 'arm64'
+ tag-latest: 'auto'
+ tag-suffix: '-gpu-vulkan-kimodocpp'
+ runs-on: 'ubuntu-24.04-arm'
+ base-image: "ubuntu:24.04"
+ skip-drivers: 'false'
+ backend: "kimodocpp"
+ dockerfile: "./backend/Dockerfile.golang"
+ context: "./"
+ ubuntu-version: '2404'
# trellis2cpp
- build-type: ''
cuda-major-version: ""
@@ -6461,6 +6518,10 @@ include:
# Darwin matrix (consumed by backend-jobs-darwin).
includeDarwin:
+ - backend: "kimodocpp"
+ tag-suffix: "-cpu-darwin-arm64-kimodocpp"
+ build-type: "cpu"
+ lang: "go"
- backend: "diffusers"
tag-suffix: "-metal-darwin-arm64-diffusers"
build-type: "mps"
diff --git a/.github/workflows/bump_deps.yaml b/.github/workflows/bump_deps.yaml
index d320ab509..e8569473a 100644
--- a/.github/workflows/bump_deps.yaml
+++ b/.github/workflows/bump_deps.yaml
@@ -90,6 +90,10 @@ jobs:
variable: "TRELLIS2CPP_VERSION"
branch: "pbr-textures"
file: "backend/go/trellis2cpp/Makefile"
+ - repository: "localai-org/kimodo.cpp"
+ variable: "KIMODO_VERSION"
+ branch: "main"
+ file: "backend/go/kimodocpp/Makefile"
- repository: "mudler/go-piper"
variable: "PIPER_VERSION"
branch: "master"
@@ -176,7 +180,7 @@ jobs:
if: github.repository == 'mudler/LocalAI'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Bump CTranslate2 ROCm wheel pin 🔧
id: bump
run: |
diff --git a/.github/workflows/test-extra.yml b/.github/workflows/test-extra.yml
index 8087a9413..4ca43d986 100644
--- a/.github/workflows/test-extra.yml
+++ b/.github/workflows/test-extra.yml
@@ -39,6 +39,7 @@ jobs:
qwen3-tts-cpp: ${{ steps.detect.outputs.qwen3-tts-cpp }}
magpie-tts-cpp: ${{ steps.detect.outputs.magpie-tts-cpp }}
trellis2cpp: ${{ steps.detect.outputs.trellis2cpp }}
+ kimodocpp: ${{ steps.detect.outputs.kimodocpp }}
rfdetr-cpp: ${{ steps.detect.outputs.rfdetr-cpp }}
locate-anything-cpp: ${{ steps.detect.outputs.locate-anything-cpp }}
vibevoice-cpp: ${{ steps.detect.outputs.vibevoice-cpp }}
@@ -989,9 +990,31 @@ jobs:
- name: Test rfdetr-cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/rfdetr-cpp test
- # Weight-free packaged-backend smoke for trellis2cpp. Starting run.sh loads
- # libtrellis2 + ggml, resolves the complete C ABI (including remeshing), and
- # answers gRPC Health without downloading or loading the multi-GB model set.
+ # Load the packaged Kimodo C ABI and answer gRPC Health without model downloads.
+ tests-kimodocpp:
+ needs: detect-changes
+ if: needs.detect-changes.outputs.kimodocpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Native build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y g++-14 cmake curl unzip
+ - name: Generate protocol bindings
+ run: make protogen-go
+ - name: Build and test CPU backend without model downloads
+ env:
+ CXX: g++-14
+ CC: gcc-14
+ run: |
+ make -C backend/go/kimodocpp BUILD_TYPE=cpu JOBS=4
+ KIMODO_TEST_PACKAGE="$PWD/backend/go/kimodocpp/package" make -C backend/go/kimodocpp test
+
tests-trellis2cpp:
needs: detect-changes
if: needs.detect-changes.outputs.trellis2cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
diff --git a/ADOPTERS.md b/ADOPTERS.md
index a8edc0281..9226256cd 100644
--- a/ADOPTERS.md
+++ b/ADOPTERS.md
@@ -27,6 +27,7 @@ To be removed, open a pull request deleting your row, or email
| Organisation | What they use it for | Status |
|---|---|---|
+| [solstone](https://solstone.app) | Runs [parakeet.cpp](https://github.com/mudler/parakeet.cpp) on-device as our speech-to-text server. | Production |
| [walcz.de](https://walcz.de) | Self-hosted appliance for a German B2B consultancy: local-only inference on AMD Strix Halo (gfx1151/ROCm), agents with MCP tools, RAG over an internal knowledge base, and a document/bookkeeping pipeline. | Production |
| _Your organisation here_ | | |
diff --git a/Makefile b/Makefile
index 51f61fc63..b2034aaa6 100644
--- a/Makefile
+++ b/Makefile
@@ -641,6 +641,7 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/go/rfdetr-cpp
$(MAKE) -C backend/go/locate-anything-cpp
$(MAKE) -C backend/go/trellis2cpp
+ $(MAKE) -C backend/go/kimodocpp
$(MAKE) -C backend/go/valkey-store
test-extra: prepare-test-extra
@@ -679,6 +680,7 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/go/vllm-cpp test
$(MAKE) -C backend/go/nemo-speech-cpp test
$(MAKE) -C backend/go/trellis2cpp test
+ $(MAKE) -C backend/go/kimodocpp test
$(MAKE) -C backend/go/valkey-store test
##
@@ -1334,6 +1336,7 @@ BACKEND_HUGGINGFACE = huggingface|golang|.|false|true
BACKEND_SILERO_VAD = silero-vad|golang|.|false|true
BACKEND_STABLEDIFFUSION_GGML = stablediffusion-ggml|golang|.|--progress=plain|true
BACKEND_TRELLIS2CPP = trellis2cpp|golang|.|--progress=plain|true
+BACKEND_KIMODOCPP = kimodocpp|golang|.|--progress=plain|true
BACKEND_WHISPER = whisper|golang|.|false|true
BACKEND_CRISPASR = crispasr|golang|.|false|true
BACKEND_PARAKEET_CPP = parakeet-cpp|golang|.|false|true
@@ -1439,6 +1442,14 @@ $(eval $(call generate-docker-build-target,$(BACKEND_HUGGINGFACE)))
$(eval $(call generate-docker-build-target,$(BACKEND_SILERO_VAD)))
$(eval $(call generate-docker-build-target,$(BACKEND_STABLEDIFFUSION_GGML)))
$(eval $(call generate-docker-build-target,$(BACKEND_TRELLIS2CPP)))
+$(eval $(call generate-docker-build-target,$(BACKEND_KIMODOCPP)))
+.NOTPARALLEL: backends/kimodocpp backends/kimodocpp-darwin
+docker-build-backends: docker-build-kimodocpp
+
+backends/kimodocpp-darwin:
+ BACKEND=kimodocpp BUILD_TYPE=cpu $(MAKE) build-darwin-go-backend
+ ./local-ai backends install "ocifile://$(abspath ./backend-images/kimodocpp.tar)"
+
$(eval $(call generate-docker-build-target,$(BACKEND_WHISPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_CRISPASR)))
$(eval $(call generate-docker-build-target,$(BACKEND_PARAKEET_CPP)))
diff --git a/README.md b/README.md
index 60c788c50..e4c1da1e3 100644
--- a/README.md
+++ b/README.md
@@ -243,6 +243,7 @@ Most backends wrap a best-in-class upstream engine. A handful of them are native
| [face-detect.cpp](https://github.com/mudler/face-detect.cpp) | Face detection, recognition, demographics and anti-spoofing (SCRFD/ArcFace, YuNet/SFace), replacing the Python insightface backend |
| [free-splatter.cpp](https://github.com/localai-org/free-splatter.cpp) | Pose-free 3D reconstruction (FreeSplatter): turns a handful of plain photos into 3D Gaussians, no camera poses or GPU required |
| [trellis2.cpp](https://github.com/localai-org/trellis2cpp) | C++/GGML port of Microsoft TRELLIS.2: single-image to textured 3D mesh (GLB with PBR materials) |
+| [kimodo.cpp](https://github.com/localai-org/kimodo.cpp) | C++/GGML text-to-motion on CPU and Vulkan, exported as animated skeleton GLB |
| [privacy-filter.cpp](https://github.com/localai-org/privacy-filter.cpp) | Standalone GGML PII/NER token-classification engine powering LocalAI's PII redaction tier |
| [LocalVQE](https://github.com/localai-org/LocalVQE) | Joint acoustic echo cancellation, noise suppression, and dereverberation |
| [local-store](https://github.com/mudler/LocalAI) | Local-first vector database for embeddings (shipped in-tree) |
@@ -295,6 +296,9 @@ A huge thank you to our generous sponsors who support this project covering CI e
+
+
+
diff --git a/backend/backend.proto b/backend/backend.proto
index c929d7f5b..7fcfe183a 100644
--- a/backend/backend.proto
+++ b/backend/backend.proto
@@ -18,6 +18,7 @@ service Backend {
rpc UpscaleImage(UpscaleImageRequest) returns (Result) {}
rpc GenerateVideo(GenerateVideoRequest) returns (Result) {}
rpc Generate3D(Generate3DRequest) returns (Result) {}
+ rpc Animate3D(Animate3DRequest) returns (Result) {}
rpc AudioTranscription(TranscriptRequest) returns (TranscriptResult) {}
rpc AudioTranscriptionStream(TranscriptRequest) returns (stream TranscriptStreamResponse) {}
// AudioTranscriptionLive is the bidirectional live-microphone ASR RPC. The
@@ -691,6 +692,20 @@ message GenerateVideoRequest {
string ModelIdentity = 15;
}
+// A named conditioning input. Media references are staged local paths by the
+// time they reach the backend; text is passed verbatim.
+message AnimationInput {
+ string type = 1; // text, image, video, or mesh
+ string data = 2;
+}
+
+message Animate3DRequest {
+ map inputs = 1;
+ string dst = 2;
+ map params = 3;
+ string model_identity = 4;
+}
+
message Generate3DRequest {
string src = 1; // Path to the staged conditioning image (3D generation is image-conditioned)
string dst = 2; // Output path for the generated binary glTF (.glb) asset
diff --git a/backend/cpp/audio-cpp/Makefile b/backend/cpp/audio-cpp/Makefile
index 953adba96..0b4f17f3d 100644
--- a/backend/cpp/audio-cpp/Makefile
+++ b/backend/cpp/audio-cpp/Makefile
@@ -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?=efb04233dab73aeee4b2912042a90e7b36329061
+AUDIO_CPP_VERSION?=a074d6b8cdb16b89cd028876e83629a538d49b9a
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
diff --git a/backend/cpp/bonsai/Makefile b/backend/cpp/bonsai/Makefile
index 522791d42..7872f966a 100644
--- a/backend/cpp/bonsai/Makefile
+++ b/backend/cpp/bonsai/Makefile
@@ -1,7 +1,7 @@
# Pinned to the HEAD of the `prism` branch on https://github.com/PrismML-Eng/llama.cpp.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
-BONSAI_VERSION?=312bb2a93ea2bf798333fa859614fbf913ecb9e2
+BONSAI_VERSION?=7dffb158de30ebb8ef9d64f33c6b0b2d7c1e6313
LLAMA_REPO?=https://github.com/PrismML-Eng/llama.cpp
CMAKE_ARGS?=
@@ -41,7 +41,6 @@ define bonsai-build
# and are applied by apply-patches.sh below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build purge
- bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-$(1)-build/grpc-server.cpp
$(info $(GREEN)I bonsai build info:$(1)$(RESET))
@@ -80,7 +79,6 @@ bonsai-cpu-all:
# and are applied by apply-patches.sh below.
rm -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/patches
$(MAKE) -C $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build purge
- bash $(CURRENT_MAKEFILE_DIR)/patch-grpc-server.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-score-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
bash $(LLAMA_CPP_DIR)/disable-tts-task.sh $(CURRENT_MAKEFILE_DIR)/../bonsai-cpu-all-build/grpc-server.cpp
$(info $(GREEN)I bonsai build info:cpu-all-variants$(RESET))
@@ -96,10 +94,10 @@ bonsai-cpu-all:
@echo "Collected ggml shared backends:" && ls -la ggml-shared-libs/
bonsai-grpc:
- $(call bonsai-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)
+ $(call bonsai-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 ggml-rpc-server)
bonsai-rpc-server: bonsai-grpc
- cp -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-grpc-build/llama.cpp/build/bin/rpc-server bonsai-rpc-server
+ cp -rf $(CURRENT_MAKEFILE_DIR)/../bonsai-grpc-build/llama.cpp/build/bin/ggml-rpc-server bonsai-rpc-server
package:
bash package.sh
diff --git a/backend/cpp/bonsai/patch-grpc-server.sh b/backend/cpp/bonsai/patch-grpc-server.sh
deleted file mode 100644
index aa9b23a50..000000000
--- a/backend/cpp/bonsai/patch-grpc-server.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/bin/bash
-# Adapt the shared llama.cpp gRPC source to the older JSON API in Bonsai.
-
-set -euo pipefail
-
-if [[ $# -ne 1 ]]; then
- echo "usage: $0 " >&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 'common_json_error' "$SRC"; then
- echo "==> patching $SRC to use the Bonsai JSON exception type"
- awk '{ gsub(/common_json_error/, "json::parse_error"); print }' "$SRC" > "$SRC.tmp"
- mv "$SRC.tmp" "$SRC"
- echo "==> Bonsai JSON exception patch OK"
-else
- echo "==> $SRC already uses a Bonsai-compatible JSON exception type, skipping"
-fi
diff --git a/backend/cpp/ds4/CMakeLists.txt b/backend/cpp/ds4/CMakeLists.txt
index 5783db942..b22b22044 100644
--- a/backend/cpp/ds4/CMakeLists.txt
+++ b/backend/cpp/ds4/CMakeLists.txt
@@ -70,6 +70,7 @@ target_include_directories(hw_grpc_proto PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
set(DS4_OBJS "${DS4_DIR}/ds4.o")
if(DS4_GPU STREQUAL "cuda")
list(APPEND DS4_OBJS
+ "${DS4_DIR}/ds4_engram.o"
"${DS4_DIR}/ds4_cuda.o"
"${DS4_DIR}/cuda/mmq/ds4_ggml_stubs.o"
"${DS4_DIR}/cuda/mmq/ds4_mmq.o"
@@ -79,14 +80,17 @@ if(DS4_GPU STREQUAL "cuda")
"${DS4_DIR}/cuda/mmq/mmvq.o"
"${DS4_DIR}/cuda/mmq/ds4_repack.o")
elseif(DS4_GPU STREQUAL "metal")
- list(APPEND DS4_OBJS "${DS4_DIR}/ds4_metal.o")
+ list(APPEND DS4_OBJS
+ "${DS4_DIR}/ds4_engram.o"
+ "${DS4_DIR}/ds4_metal.o")
elseif(DS4_GPU STREQUAL "cpu")
set(DS4_OBJS "${DS4_DIR}/ds4_cpu.o")
endif()
# Upstream splits image preprocessing, distributed inference, tensor-parallel
# transport, the SSD expert cache, and layer placement into GPU-agnostic
-# translation units. Link them regardless of DS4_GPU.
+# translation units. Link the common subset regardless of DS4_GPU; engram
+# lookup is part of the CUDA and Metal engines only.
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_image.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_distributed.o")
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_tp.o")
diff --git a/backend/cpp/ds4/Makefile b/backend/cpp/ds4/Makefile
index 39ab3c760..b9282e54c 100644
--- a/backend/cpp/ds4/Makefile
+++ b/backend/cpp/ds4/Makefile
@@ -1,10 +1,10 @@
# ds4 backend Makefile.
#
-# Upstream pin lives below as DS4_VERSION?=6289c516273979173abbc062209a81dd3706b804
+# Upstream pin lives below as DS4_VERSION?=8db1d1d155cb0400a86a86b9c62d0defb3a6148b
# (.github/bump_deps.sh) can find and update it - matches the
# llama-cpp / ik-llama-cpp / turboquant convention.
-DS4_VERSION?=6289c516273979173abbc062209a81dd3706b804
+DS4_VERSION?=8db1d1d155cb0400a86a86b9c62d0defb3a6148b
DS4_REPO?=https://github.com/antirez/ds4
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
@@ -80,17 +80,17 @@ endif
endif
# Upstream splits image preprocessing, distributed inference, tensor-parallel
-# transport, the SSD expert cache, and layer placement into GPU-agnostic
-# translation units. They are shared by every GPU mode, so append them
-# unconditionally below.
+# transport, the SSD expert cache, layer placement, and engram lookup into
+# GPU-agnostic translation units. They are shared by every GPU mode, so append
+# them unconditionally below.
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS += -DDS4_GPU=cuda
- DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o \
+ DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o ds4_engram.o \
cuda/mmq/ds4_ggml_stubs.o cuda/mmq/ds4_mmq.o cuda/mmq/ds4_mmq_d2r.o \
cuda/mmq/quantize.o cuda/mmq/mmid.o cuda/mmq/mmvq.o cuda/mmq/ds4_repack.o
else ifeq ($(UNAME_S),Darwin)
CMAKE_ARGS += -DDS4_GPU=metal
- DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+ DS4_OBJ_TARGET := ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o ds4_engram.o
else
# CPU reference path (Linux only - macOS CPU path is broken by VM bug per ds4 README).
CMAKE_ARGS += -DDS4_GPU=cpu
@@ -121,7 +121,7 @@ ds4/ds4.o: ds4
ifeq ($(BUILD_TYPE),cublas)
+$(MAKE) -C ds4 $(DS4_ARCH_MAKEVARS) $(DS4_OBJ_TARGET)
else ifeq ($(UNAME_S),Darwin)
- +$(MAKE) -C ds4 ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
+ +$(MAKE) -C ds4 ds4.o ds4_image.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o ds4_engram.o
else
+$(MAKE) -C ds4 ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
endif
diff --git a/backend/cpp/ik-llama-cpp/Makefile b/backend/cpp/ik-llama-cpp/Makefile
index d7d205199..fb34184c8 100644
--- a/backend/cpp/ik-llama-cpp/Makefile
+++ b/backend/cpp/ik-llama-cpp/Makefile
@@ -1,5 +1,5 @@
-IK_LLAMA_VERSION?=3bb386eb68ffee0a5dc7db21da0735d594929eeb
+IK_LLAMA_VERSION?=2ae132fa601ea06818ed3584f50f7eb4f72d4967
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=
diff --git a/backend/cpp/llama-cpp/Makefile b/backend/cpp/llama-cpp/Makefile
index 739757999..1807c0bab 100644
--- a/backend/cpp/llama-cpp/Makefile
+++ b/backend/cpp/llama-cpp/Makefile
@@ -1,5 +1,5 @@
-LLAMA_VERSION?=df03399b885831b2a1603b3abb0d8c156808e363
+LLAMA_VERSION?=50631b3d2c569ad8e5c112090cd28570b1268ee0
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=
@@ -67,6 +67,7 @@ ifeq ($(BUILD_TYPE),sycl_f16)
CMAKE_ARGS+=-DGGML_SYCL=ON \
-DCMAKE_C_COMPILER=icx \
-DCMAKE_CXX_COMPILER=icpx \
+ -DCMAKE_DISABLE_PRECOMPILE_HEADERS=ON \
-DCMAKE_CXX_FLAGS="-fsycl" \
-DGGML_SYCL_F16=ON
endif
@@ -75,6 +76,7 @@ ifeq ($(BUILD_TYPE),sycl_f32)
CMAKE_ARGS+=-DGGML_SYCL=ON \
-DCMAKE_C_COMPILER=icx \
-DCMAKE_CXX_COMPILER=icpx \
+ -DCMAKE_DISABLE_PRECOMPILE_HEADERS=ON \
-DCMAKE_CXX_FLAGS="-fsycl"
endif
diff --git a/backend/cpp/turboquant/Makefile b/backend/cpp/turboquant/Makefile
index 9a70bb1b5..27a113796 100644
--- a/backend/cpp/turboquant/Makefile
+++ b/backend/cpp/turboquant/Makefile
@@ -1,7 +1,7 @@
# Pinned to the HEAD of feature/turboquant-kv-cache on https://github.com/TheTom/llama-cpp-turboquant.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
-TURBOQUANT_VERSION?=8a891f4b566efdbd3cea92fafee3227a0a267683
+TURBOQUANT_VERSION?=407f3237bfb3eeaff61546797de3d8c1a96be748
LLAMA_REPO?=https://github.com/TheTom/llama-cpp-turboquant
CMAKE_ARGS?=
@@ -101,10 +101,10 @@ turboquant-cpu-all:
@echo "Collected ggml shared backends:" && ls -la ggml-shared-libs/
turboquant-grpc:
- $(call turboquant-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)
+ $(call turboquant-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 ggml-rpc-server)
turboquant-rpc-server: turboquant-grpc
- cp -rf $(CURRENT_MAKEFILE_DIR)/../turboquant-grpc-build/llama.cpp/build/bin/rpc-server turboquant-rpc-server
+ cp -rf $(CURRENT_MAKEFILE_DIR)/../turboquant-grpc-build/llama.cpp/build/bin/ggml-rpc-server turboquant-rpc-server
package:
bash package.sh
diff --git a/backend/go/crispasr/Makefile b/backend/go/crispasr/Makefile
index 3a3538346..faee41a45 100644
--- a/backend/go/crispasr/Makefile
+++ b/backend/go/crispasr/Makefile
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
-CRISPASR_VERSION?=301acd87b036764973b8bfba71e0a21818036d33
+CRISPASR_VERSION?=647db2c7abed1fc82a69767f6e8b3993b94b8417
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
diff --git a/backend/go/kimodocpp/.gitignore b/backend/go/kimodocpp/.gitignore
new file mode 100644
index 000000000..a9fab6f76
--- /dev/null
+++ b/backend/go/kimodocpp/.gitignore
@@ -0,0 +1,9 @@
+/sources/
+/build-native/
+/build-cpu/
+/build-vulkan/
+/build-native-avx2/
+/build-cpu-avx2/
+/build-vulkan-avx2/
+/package/
+/kimodocpp
diff --git a/backend/go/kimodocpp/CMakeLists.txt b/backend/go/kimodocpp/CMakeLists.txt
new file mode 100644
index 000000000..a1e4194bb
--- /dev/null
+++ b/backend/go/kimodocpp/CMakeLists.txt
@@ -0,0 +1,8 @@
+cmake_minimum_required(VERSION 3.25)
+project(localai_kimodo LANGUAGES CXX)
+
+set(BUILD_SHARED_LIBS ON CACHE BOOL "" FORCE)
+set(KIMODO_BUILD_TESTS OFF CACHE BOOL "" FORCE)
+set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
+add_subdirectory(sources/kimodo.cpp kimodo)
+target_sources(kimodo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/native/bridge.cpp)
diff --git a/backend/go/kimodocpp/Makefile b/backend/go/kimodocpp/Makefile
new file mode 100644
index 000000000..997e7d11e
--- /dev/null
+++ b/backend/go/kimodocpp/Makefile
@@ -0,0 +1,61 @@
+KIMODO_REPO?=https://github.com/localai-org/kimodo.cpp
+KIMODO_VERSION?=5679ff19ba0a522c0b0516e9a9d402fe1af2c027
+BUILD_TYPE?=
+GOCMD?=go
+GO_TAGS?=
+JOBS?=4
+CMAKE_ARGS?=
+BUILD_DIR?=build-$(if $(filter vulkan,$(BUILD_TYPE)),vulkan,cpu)
+ifeq ($(shell uname -sm),Linux x86_64)
+CPU_VARIANTS=$(BUILD_DIR)-avx2/.built
+endif
+
+CMAKE_ARGS+=-DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF -DGGML_METAL=OFF
+ifeq ($(shell uname -s),Darwin)
+CMAKE_ARGS+=-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON -DCMAKE_INSTALL_RPATH=@loader_path
+endif
+ifeq ($(BUILD_TYPE),vulkan)
+CMAKE_ARGS+=-DKIMODO_ENABLE_VULKAN=ON
+else
+CMAKE_ARGS+=-DKIMODO_ENABLE_VULKAN=OFF
+endif
+
+.PHONY: all build package test clean purge FORCE
+all: build
+
+FORCE:
+
+sources/kimodo.cpp/.localai-version: FORCE
+ @test -d sources/kimodo.cpp/.git || git clone $(KIMODO_REPO) sources/kimodo.cpp
+ @if test -f $@ && test "$$(git -C sources/kimodo.cpp rev-parse HEAD)" = "$(KIMODO_VERSION)"; then exit 0; fi; \
+ cd sources/kimodo.cpp && (git diff --quiet && git diff --cached --quiet || \
+ { echo "kimodo.cpp has local source changes; preserve them and run make clean before updating the pin" >&2; exit 1; }) && \
+ git fetch origin $(KIMODO_VERSION) && git checkout $(KIMODO_VERSION) && \
+ git submodule update --init --recursive --depth 1 && touch .localai-version
+
+$(BUILD_DIR)/.built: sources/kimodo.cpp/.localai-version CMakeLists.txt native/bridge.cpp Makefile
+ cmake -S . -B $(BUILD_DIR) $(CMAKE_ARGS)
+ cmake --build $(BUILD_DIR) --target kimodo -j$(JOBS)
+ touch $@
+
+$(BUILD_DIR)-avx2/.built: sources/kimodo.cpp/.localai-version CMakeLists.txt Makefile
+ cmake -S . -B $(BUILD_DIR)-avx2 $(CMAKE_ARGS) -DGGML_AVX=ON -DGGML_AVX2=ON -DGGML_FMA=ON -DGGML_F16C=ON -DGGML_BMI2=ON
+ cmake --build $(BUILD_DIR)-avx2 --target ggml-cpu -j$(JOBS)
+ touch $@
+
+kimodocpp: main.go kimodo.go glb.go $(BUILD_DIR)/.built $(CPU_VARIANTS)
+ CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o $@ ./
+
+package: kimodocpp
+ BUILD_DIR=$(BUILD_DIR) bash package.sh
+
+build: package
+
+test:
+ CGO_ENABLED=0 $(GOCMD) test -v ./...
+
+purge:
+ rm -rf build-native build-cpu build-vulkan build-native-avx2 build-cpu-avx2 build-vulkan-avx2
+
+clean: purge
+ rm -rf package kimodocpp sources
diff --git a/backend/go/kimodocpp/README.md b/backend/go/kimodocpp/README.md
new file mode 100644
index 000000000..79ce88e8c
--- /dev/null
+++ b/backend/go/kimodocpp/README.md
@@ -0,0 +1,35 @@
+# kimodocpp
+
+Persistent gRPC adapter for the pinned kimodo.cpp C API. The small C++ bridge
+exposes upstream skeleton metadata and runtime configuration; Go exports the
+returned motion as a node-only animated GLB. See
+[3D animation](../../../docs/content/features/3d-animation.md) for the API and
+runtime options.
+
+```sh
+make protogen-go
+make -C backend/go/kimodocpp BUILD_TYPE=cpu
+KIMODO_TEST_PACKAGE="$PWD/backend/go/kimodocpp/package" make -C backend/go/kimodocpp test
+make -C backend/go/kimodocpp BUILD_TYPE=vulkan
+```
+
+Requires CMake 3.25+, a C++23 compiler, and Go. Vulkan additionally requires the
+Vulkan SDK with shader tooling. Linux and macOS/Apple Silicon CPU are supported;
+Metal is disabled because upstream implements CPU/Vulkan only.
+
+Normal CI tests need no model downloads. To exercise real weights, set
+`KIMODO_TEST_LIBRARY` to the built `libkimodo.so` (or dylib),
+`KIMODO_TEST_MOTION` to a motion GGUF, `KIMODO_TEST_TEXT` to the complete text
+GGUF beside `tokenizer.gguf` (or legacy bundle directory), and
+`KIMODO_TEST_DEVICE=cpu` or `vulkan`, then run the tests.
+The real-model test generates two clips with one session to cover reuse.
+Set `KIMODO_TEST_TEXT_LAYER_CHUNK=8` to exercise bounded streaming instead of
+the default all-layer residency.
+
+Upstream now respects the configured thread count in both encoders and keeps
+motion weights and execution graphs resident. The adapter defaults to 32 text
+layers so the text weights also stay resident between requests. When bumping
+the pinned upstream commit, verify the C ABI layout/version and all three
+skeleton families. Pin changes update a clean cached checkout automatically;
+local source modifications stop the update rather than being discarded. Preserve
+any such changes before using `make clean` to replace that generated checkout.
diff --git a/backend/go/kimodocpp/glb.go b/backend/go/kimodocpp/glb.go
new file mode 100644
index 000000000..80e0c5132
--- /dev/null
+++ b/backend/go/kimodocpp/glb.go
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: MIT
+package main
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os"
+)
+
+type animationJoint struct {
+ Name string
+ Parent int
+ Offset [3]float32
+}
+
+type animationAccessor struct {
+ BufferView int `json:"bufferView"`
+ ComponentType int `json:"componentType"`
+ Count int `json:"count"`
+ Type string `json:"type"`
+ Min []float32 `json:"min,omitempty"`
+ Max []float32 `json:"max,omitempty"`
+}
+
+func writeAnimationGLB(path string, roots, rotations []float32, joints []animationJoint) error {
+ frames := len(roots) / 3
+ if frames < 1 || len(joints) == 0 || len(roots)%3 != 0 || len(rotations) != frames*len(joints)*4 {
+ return fmt.Errorf("invalid animation dimensions")
+ }
+ for _, values := range [][]float32{roots, rotations} {
+ for _, value := range values {
+ if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
+ return fmt.Errorf("animation contains non-finite values")
+ }
+ }
+ }
+ nodes := make([]map[string]any, len(joints))
+ for index, joint := range joints {
+ if joint.Name == "" || (index == 0 && joint.Parent != -1) || (index > 0 && (joint.Parent < 0 || joint.Parent >= index)) {
+ return fmt.Errorf("invalid skeleton hierarchy at joint %d", index)
+ }
+ nodes[index] = map[string]any{"name": joint.Name, "translation": joint.Offset}
+ children := []int{}
+ for child, candidate := range joints {
+ if candidate.Parent == index {
+ children = append(children, child)
+ }
+ }
+ if len(children) > 0 {
+ nodes[index]["children"] = children
+ }
+ }
+ times := make([]float32, frames)
+ for frame := range times {
+ times[frame] = float32(frame) / 30
+ }
+ bin := make([]byte, 0, 4*(len(times)+len(roots)+len(rotations)))
+ views := []map[string]int{}
+ accessors := []animationAccessor{}
+ addTrack := func(values []float32, kind string) int {
+ offset := len(bin)
+ for _, value := range values {
+ bin = binary.LittleEndian.AppendUint32(bin, math.Float32bits(value))
+ }
+ views = append(views, map[string]int{"buffer": 0, "byteOffset": offset, "byteLength": len(bin) - offset})
+ accessors = append(accessors, animationAccessor{BufferView: len(views) - 1, ComponentType: 5126, Count: frames, Type: kind})
+ return len(accessors) - 1
+ }
+ addTrack(times, "SCALAR")
+ accessors[0].Min, accessors[0].Max = []float32{0}, []float32{times[frames-1]}
+ samplers, channels := []map[string]any{}, []map[string]any{}
+ addChannel := func(node int, target string, accessor int) {
+ samplers = append(samplers, map[string]any{"input": 0, "output": accessor, "interpolation": "LINEAR"})
+ channels = append(channels, map[string]any{"sampler": len(samplers) - 1, "target": map[string]any{"node": node, "path": target}})
+ }
+ addChannel(0, "translation", addTrack(roots, "VEC3"))
+ track := make([]float32, frames*4)
+ for joint := range joints {
+ for frame := range frames {
+ copy(track[frame*4:], rotations[(frame*len(joints)+joint)*4:][:4])
+ }
+ addChannel(joint, "rotation", addTrack(track, "VEC4"))
+ }
+ document := map[string]any{
+ "asset": map[string]string{"version": "2.0", "generator": "LocalAI kimodocpp"},
+ "scene": 0, "scenes": []map[string]any{{"nodes": []int{0}}}, "nodes": nodes,
+ "buffers": []map[string]int{{"byteLength": len(bin)}}, "bufferViews": views, "accessors": accessors,
+ "animations": []map[string]any{{"name": "Motion", "samplers": samplers, "channels": channels}},
+ "extras": map[string]any{"fps": 30, "output_type": "skeleton_animation"},
+ }
+ jsonChunk, err := json.Marshal(document)
+ if err != nil {
+ return err
+ }
+ for len(jsonChunk)%4 != 0 {
+ jsonChunk = append(jsonChunk, ' ')
+ }
+ output := make([]byte, 0, 28+len(jsonChunk)+len(bin))
+ for _, value := range []uint32{0x46546c67, 2, uint32(28 + len(jsonChunk) + len(bin)), uint32(len(jsonChunk)), 0x4e4f534a} {
+ output = binary.LittleEndian.AppendUint32(output, value)
+ }
+ output = append(output, jsonChunk...)
+ output = binary.LittleEndian.AppendUint32(output, uint32(len(bin)))
+ output = binary.LittleEndian.AppendUint32(output, 0x004e4942)
+ output = append(output, bin...)
+ return os.WriteFile(path, output, 0o600)
+}
diff --git a/backend/go/kimodocpp/integration_test.go b/backend/go/kimodocpp/integration_test.go
new file mode 100644
index 000000000..b2ccd8dd3
--- /dev/null
+++ b/backend/go/kimodocpp/integration_test.go
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: MIT
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ pb "github.com/mudler/LocalAI/pkg/grpc/proto"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("real-model generation", Label("real-models"), func() {
+ It("loads the text bundle and generates two clips with one native session", func() {
+ library := os.Getenv("KIMODO_TEST_LIBRARY")
+ if library == "" {
+ Skip("set KIMODO_TEST_LIBRARY, KIMODO_TEST_MOTION and KIMODO_TEST_TEXT for real-model smoke testing")
+ }
+ Expect(loadNativeLibrary(library)).To(Succeed())
+ backend := &Kimodo{}
+ DeferCleanup(backend.Free)
+ device := os.Getenv("KIMODO_TEST_DEVICE")
+ if device == "" {
+ device = "cpu"
+ }
+ options := []string{"text_bundle:" + os.Getenv("KIMODO_TEST_TEXT"), "device:" + device}
+ if chunk := os.Getenv("KIMODO_TEST_TEXT_LAYER_CHUNK"); chunk != "" {
+ options = append(options, "text_layer_chunk:"+chunk)
+ }
+ Expect(backend.Load(&pb.ModelOptions{ModelFile: os.Getenv("KIMODO_TEST_MOTION"), Threads: 8,
+ Options: options})).To(Succeed())
+ for index := range 2 {
+ path := filepath.Join(GinkgoT().TempDir(), "animation.glb")
+ By("generating a clip with the existing session")
+ Expect(backend.Animate3D(&pb.Animate3DRequest{Dst: path,
+ Inputs: map[string]*pb.AnimationInput{"prompt": {Type: "text", Data: "A person walks forward."}},
+ Params: map[string]string{"frames": "60", "steps": "1", "seed": "42"},
+ })).To(Succeed(), "clip %d", index)
+ data, err := os.ReadFile(path)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(len(data)).To(BeNumerically(">", 1000))
+ Expect(string(data[:4])).To(Equal("glTF"))
+ }
+ })
+})
diff --git a/backend/go/kimodocpp/kimodo.go b/backend/go/kimodocpp/kimodo.go
new file mode 100644
index 000000000..c2f2a41bd
--- /dev/null
+++ b/backend/go/kimodocpp/kimodo.go
@@ -0,0 +1,227 @@
+// SPDX-License-Identifier: MIT
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "maps"
+ "math"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "unicode/utf8"
+ "unsafe"
+
+ "github.com/mudler/LocalAI/pkg/grpc/base"
+ pb "github.com/mudler/LocalAI/pkg/grpc/proto"
+ "github.com/mudler/xlog"
+)
+
+type generationOptions struct {
+ Size uint32
+ _ uint32
+ Seed uint64
+ Frames uint32
+ Steps uint32
+ TextGuidance float32
+ ConstraintGuidance float32
+}
+
+var (
+ nativeABI func() int32
+ nativeLoad func(string, string, string, uintptr, *byte, int32) uintptr
+ nativeFree func(uintptr)
+ nativeGenerate func(uintptr, string, *generationOptions, *byte, int32) uintptr
+ nativeMotionFree func(uintptr)
+ nativeFrames func(uintptr) int32
+ nativeJoints func(uintptr) int32
+ nativeRotations func(uintptr) *float32
+ nativeRoots func(uintptr) *float32
+ nativeConfigure func(string, int32, int32) int32
+ nativeJointName func(int32, int32) string
+ nativeJointParent func(int32, int32) int32
+ nativeJointOffset func(int32, int32) *float32
+)
+
+type Kimodo struct {
+ base.Base
+ mu sync.Mutex
+ model uintptr
+ defaults map[string]string
+}
+
+func parseGeneration(params map[string]string) (generationOptions, error) {
+ options := generationOptions{Seed: 0, Frames: 150, Steps: 100, TextGuidance: 2, ConstraintGuidance: 2}
+ options.Size = uint32(unsafe.Sizeof(options))
+ for name, value := range params {
+ switch name {
+ case "seed", "frames", "steps":
+ bits := 32
+ if name == "seed" {
+ bits = 64
+ }
+ number, err := strconv.ParseUint(value, 10, bits)
+ if err != nil {
+ return options, fmt.Errorf("invalid %s: %w", name, err)
+ }
+ switch name {
+ case "seed":
+ options.Seed = number
+ case "frames":
+ options.Frames = uint32(number)
+ case "steps":
+ options.Steps = uint32(number)
+ }
+ case "text_guidance":
+ number, err := strconv.ParseFloat(value, 32)
+ if err != nil || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 || number > 100 {
+ return options, fmt.Errorf("text_guidance must be finite and in 0..100")
+ }
+ options.TextGuidance = float32(number)
+ default:
+ return options, fmt.Errorf("unsupported animation parameter %q", name)
+ }
+ }
+ if options.Frames < 60 || options.Frames > 150 {
+ return options, fmt.Errorf("frames must be in 60..150")
+ }
+ if options.Steps < 1 || options.Steps > 1000 {
+ return options, fmt.Errorf("steps must be in 1..1000")
+ }
+ return options, nil
+}
+
+func (k *Kimodo) Load(options *pb.ModelOptions) error {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ defaults := map[string]string{}
+ motion := options.ModelFile
+ if !filepath.IsAbs(motion) {
+ motion = filepath.Join(options.ModelPath, motion)
+ }
+ textBundle := ""
+ device := os.Getenv("KIMODO_BACKEND")
+ if device == "" {
+ device = "auto"
+ }
+ threads := int(options.Threads)
+ if threads <= 0 {
+ threads = runtime.NumCPU()
+ }
+ chunk := 32
+ for _, option := range options.Options {
+ name, value, ok := strings.Cut(option, ":")
+ if !ok {
+ return fmt.Errorf("invalid kimodo option %q", option)
+ }
+ switch name {
+ case "text_bundle":
+ textBundle = value
+ case "device":
+ device = value
+ case "text_layer_chunk":
+ var err error
+ chunk, err = strconv.Atoi(value)
+ if err != nil || chunk < 1 || chunk > 32 {
+ return fmt.Errorf("text_layer_chunk must be in 1..32")
+ }
+ default:
+ defaults[name] = value
+ }
+ }
+ if _, err := parseGeneration(defaults); err != nil {
+ return err
+ }
+ if textBundle == "" {
+ return fmt.Errorf("text_bundle is required for text-to-motion")
+ }
+ if !filepath.IsAbs(textBundle) {
+ textBundle = filepath.Join(options.ModelPath, textBundle)
+ }
+ if options.ModelFile == "" {
+ return fmt.Errorf("motion model is required")
+ }
+ if code := nativeConfigure(device, int32(threads), int32(chunk)); code != 0 {
+ return fmt.Errorf("cannot configure kimodo device %q (code %d)", device, code)
+ }
+ errorBuffer := make([]byte, 1024)
+ loaded := nativeLoad(motion, textBundle, "", 0, &errorBuffer[0], int32(len(errorBuffer)))
+ if loaded == 0 {
+ return fmt.Errorf("loading kimodo: %s", nativeError(errorBuffer))
+ }
+ if k.model != 0 {
+ nativeFree(k.model)
+ }
+ k.model, k.defaults = loaded, defaults
+ xlog.Info("Kimodo loaded", "device", device, "threads", threads, "text_layer_chunk", chunk)
+ return nil
+}
+
+func nativeError(buffer []byte) string {
+ if end := bytes.IndexByte(buffer, 0); end >= 0 {
+ buffer = buffer[:end]
+ }
+ return string(buffer)
+}
+
+func (k *Kimodo) Free() error {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ if k.model != 0 {
+ nativeFree(k.model)
+ k.model = 0
+ }
+ return nil
+}
+
+func (k *Kimodo) Animate3D(request *pb.Animate3DRequest) error {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ if k.model == 0 {
+ return fmt.Errorf("kimodo model is not loaded")
+ }
+ prompt := request.Inputs["prompt"]
+ if len(request.Inputs) != 1 || prompt == nil || prompt.Type != "text" ||
+ strings.TrimSpace(prompt.Data) == "" || len(prompt.Data) > 4096 ||
+ !utf8.ValidString(prompt.Data) || strings.ContainsRune(prompt.Data, 0) {
+ return fmt.Errorf("kimodo requires one UTF-8 text prompt of 1..4096 bytes without NUL characters")
+ }
+ if request.Dst == "" {
+ return fmt.Errorf("animation destination is required")
+ }
+ params := maps.Clone(k.defaults)
+ if params == nil {
+ params = map[string]string{}
+ }
+ maps.Copy(params, request.Params)
+ options, err := parseGeneration(params)
+ if err != nil {
+ return err
+ }
+ errorBuffer := make([]byte, 1024)
+ motion := nativeGenerate(k.model, prompt.Data, &options, &errorBuffer[0], int32(len(errorBuffer)))
+ if motion == 0 {
+ return fmt.Errorf("generating kimodo motion: %s", nativeError(errorBuffer))
+ }
+ defer nativeMotionFree(motion)
+ frames, joints := nativeFrames(motion), nativeJoints(motion)
+ if frames != int32(options.Frames) || (joints != 22 && joints != 30 && joints != 34) {
+ return fmt.Errorf("unexpected kimodo motion dimensions: %d frames, %d joints", frames, joints)
+ }
+ roots, rotations := nativeRoots(motion), nativeRotations(motion)
+ if roots == nil || rotations == nil {
+ return fmt.Errorf("kimodo returned empty motion buffers")
+ }
+ skeleton := make([]animationJoint, joints)
+ for joint := range joints {
+ offset := nativeJointOffset(joints, joint)
+ if offset == nil {
+ return fmt.Errorf("missing skeleton joint %d", joint)
+ }
+ skeleton[joint] = animationJoint{Name: nativeJointName(joints, joint), Parent: int(nativeJointParent(joints, joint)), Offset: [3]float32(unsafe.Slice(offset, 3))}
+ }
+ return writeAnimationGLB(request.Dst, unsafe.Slice(roots, int(frames)*3), unsafe.Slice(rotations, int(frames*joints)*4), skeleton)
+}
diff --git a/backend/go/kimodocpp/kimodo_test.go b/backend/go/kimodocpp/kimodo_test.go
new file mode 100644
index 000000000..abd38dc52
--- /dev/null
+++ b/backend/go/kimodocpp/kimodo_test.go
@@ -0,0 +1,155 @@
+// SPDX-License-Identifier: MIT
+package main
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "math"
+ "os"
+ "path/filepath"
+ "testing"
+ "unsafe"
+
+ pb "github.com/mudler/LocalAI/pkg/grpc/proto"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestKimodo(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Kimodo backend")
+}
+
+var _ = Describe("generation parameters", func() {
+ It("uses the reference sampling settings and the C ABI layout", func() {
+ options, err := parseGeneration(nil)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(options.Size).To(Equal(uint32(32)))
+ Expect(unsafe.Offsetof(options.Seed)).To(Equal(uintptr(8)))
+ Expect(options.Frames).To(Equal(uint32(150)))
+ Expect(options.Steps).To(Equal(uint32(100)))
+ Expect(options.TextGuidance).To(Equal(float32(2)))
+ })
+ It("preserves explicit zero guidance and seed", func() {
+ options, err := parseGeneration(map[string]string{"seed": "0", "text_guidance": "0"})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(options.Seed).To(BeZero())
+ Expect(options.TextGuidance).To(BeZero())
+ })
+ DescribeTable("rejects invalid parameters before inference", func(name, value string) {
+ _, err := parseGeneration(map[string]string{name: value})
+ Expect(err).To(HaveOccurred())
+ },
+ Entry("short clip", "frames", "59"), Entry("long clip", "frames", "151"),
+ Entry("negative seed", "seed", "-1"), Entry("overflow seed", "seed", "18446744073709551616"),
+ Entry("fractional steps", "steps", "2.5"), Entry("zero steps", "steps", "0"),
+ Entry("NaN guidance", "text_guidance", "NaN"), Entry("infinite guidance", "text_guidance", "+Inf"),
+ Entry("unsupported texture", "texture_steps", "12"),
+ )
+})
+
+var _ = Describe("text encoder loading", func() {
+ var chunk int32
+ var textPath string
+ BeforeEach(func() {
+ configure, load, free := nativeConfigure, nativeLoad, nativeFree
+ DeferCleanup(func() { nativeConfigure, nativeLoad, nativeFree = configure, load, free })
+ chunk, textPath = 0, ""
+ nativeConfigure = func(_ string, threads, layers int32) int32 {
+ Expect(threads).To(Equal(int32(8)))
+ chunk = layers
+ return 0
+ }
+ nativeLoad = func(_ string, text, _ string, _ uintptr, _ *byte, _ int32) uintptr {
+ textPath = text
+ return 1
+ }
+ nativeFree = func(uintptr) {}
+ })
+ DescribeTable("loads monolithic encoders or legacy bundles with all layers resident by default", func(text string) {
+ backend := &Kimodo{}
+ DeferCleanup(backend.Free)
+ Expect(backend.Load(&pb.ModelOptions{ModelFile: "motion.gguf", ModelPath: "/models", Threads: 8,
+ Options: []string{"text_bundle:" + text}})).To(Succeed())
+ Expect(chunk).To(Equal(int32(32)))
+ Expect(textPath).To(Equal(filepath.Join("/models", text)))
+ }, Entry("Q8 monolith", "kimodo/text/Llama-3-Kimodo-Q8_0.gguf"), Entry("low-bit monolith", "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf"), Entry("legacy directory", "kimodo/text"))
+ DescribeTable("honors the layer residency option", func(value string, expected int32) {
+ backend := &Kimodo{}
+ DeferCleanup(backend.Free)
+ Expect(backend.Load(&pb.ModelOptions{ModelFile: "motion.gguf", Threads: 8,
+ Options: []string{"text_bundle:/custom/encoder.gguf", "text_layer_chunk:" + value}})).To(Succeed())
+ Expect(chunk).To(Equal(expected))
+ Expect(textPath).To(Equal("/custom/encoder.gguf"))
+ }, Entry("single layer", "1", int32(1)), Entry("streaming", "8", int32(8)), Entry("full residency", "32", int32(32)))
+ DescribeTable("rejects invalid layer counts before native loading", func(value string) {
+ backend := &Kimodo{}
+ Expect(backend.Load(&pb.ModelOptions{ModelFile: "motion.gguf", Threads: 8,
+ Options: []string{"text_bundle:encoder.gguf", "text_layer_chunk:" + value}})).To(MatchError("text_layer_chunk must be in 1..32"))
+ Expect(chunk).To(BeZero())
+ Expect(textPath).To(BeEmpty())
+ }, Entry("zero", "0"), Entry("negative", "-1"), Entry("too many", "33"), Entry("fractional", "8.5"), Entry("empty", ""))
+})
+
+var _ = Describe("skeleton GLB export", func() {
+ DescribeTable("writes animation channels for every joint", func(count int) {
+ joints := make([]animationJoint, count)
+ for index := range joints {
+ joints[index] = animationJoint{Name: "joint", Parent: index - 1}
+ }
+ rotations := make([]float32, 2*count*4)
+ for i := 3; i < len(rotations); i += 4 {
+ rotations[i] = 1
+ }
+ path := filepath.Join(GinkgoT().TempDir(), "animation.glb")
+ Expect(writeAnimationGLB(path, []float32{0, 0, 0, 1, 2, 3}, rotations, joints)).To(Succeed())
+ data, err := os.ReadFile(path)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(string(data[:4])).To(Equal("glTF"))
+ Expect(binary.LittleEndian.Uint32(data[4:])).To(Equal(uint32(2)))
+ Expect(binary.LittleEndian.Uint32(data[8:])).To(Equal(uint32(len(data))))
+ jsonLength := int(binary.LittleEndian.Uint32(data[12:]))
+ var document struct {
+ Nodes []json.RawMessage `json:"nodes"`
+ Meshes []json.RawMessage `json:"meshes"`
+ Accessors []animationAccessor `json:"accessors"`
+ Animations []struct {
+ Channels []json.RawMessage `json:"channels"`
+ } `json:"animations"`
+ }
+ Expect(json.Unmarshal(data[20:20+jsonLength], &document)).To(Succeed())
+ Expect(document.Nodes).To(HaveLen(count))
+ Expect(document.Meshes).To(BeEmpty())
+ Expect(document.Animations).To(HaveLen(1))
+ Expect(document.Animations[0].Channels).To(HaveLen(count + 1))
+ Expect(document.Accessors[0].Min).To(Equal([]float32{0}))
+ Expect(document.Accessors[0].Max).To(Equal([]float32{1.0 / 30}))
+ binaryStart := 28 + jsonLength
+ Expect(math.Float32frombits(binary.LittleEndian.Uint32(data[binaryStart+4:]))).To(Equal(float32(1.0 / 30)))
+ }, Entry("SMPL-X", 22), Entry("SOMA", 30), Entry("G1", 34))
+ It("rejects malformed or non-finite native buffers", func() {
+ joint := []animationJoint{{Name: "root", Parent: -1}}
+ Expect(writeAnimationGLB("", []float32{0}, nil, joint)).NotTo(Succeed())
+ Expect(writeAnimationGLB("", []float32{0, 0, 0}, []float32{0, 0, float32(math.NaN()), 1}, joint)).NotTo(Succeed())
+ })
+})
+
+var _ = Describe("native resource lifetime", func() {
+ It("releases the native motion when exporting fails", func() {
+ oldGenerate, oldFree := nativeGenerate, nativeMotionFree
+ oldFrames, oldJoints := nativeFrames, nativeJoints
+ DeferCleanup(func() {
+ nativeGenerate, nativeMotionFree = oldGenerate, oldFree
+ nativeFrames, nativeJoints = oldFrames, oldJoints
+ })
+ nativeGenerate = func(uintptr, string, *generationOptions, *byte, int32) uintptr { return 2 }
+ freed := false
+ nativeMotionFree = func(handle uintptr) { Expect(handle).To(Equal(uintptr(2))); freed = true }
+ nativeFrames = func(uintptr) int32 { return 150 }
+ nativeJoints = func(uintptr) int32 { return 999 }
+ backend := &Kimodo{model: 1}
+ err := backend.Animate3D(&pb.Animate3DRequest{Dst: "unused.glb", Inputs: map[string]*pb.AnimationInput{"prompt": {Type: "text", Data: "walking"}}})
+ Expect(err).To(MatchError(ContainSubstring("dimensions")))
+ Expect(freed).To(BeTrue())
+ })
+})
diff --git a/backend/go/kimodocpp/main.go b/backend/go/kimodocpp/main.go
new file mode 100644
index 000000000..1b05d5d4b
--- /dev/null
+++ b/backend/go/kimodocpp/main.go
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: MIT
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/ebitengine/purego"
+ "github.com/mudler/LocalAI/pkg/grpc"
+)
+
+func main() {
+ addr := flag.String("addr", "localhost:50051", "gRPC listen address")
+ flag.Parse()
+ if err := loadNativeLibrary(os.Getenv("KIMODO_LIBRARY")); err != nil {
+ panic(err)
+ }
+ if err := grpc.StartServer(*addr, &Kimodo{}); err != nil {
+ panic(err)
+ }
+}
+
+func loadNativeLibrary(path string) error {
+ lib, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_GLOBAL)
+ if err != nil {
+ return err
+ }
+ for _, binding := range []struct {
+ function any
+ name string
+ }{
+ {&nativeABI, "kimodo_abi_version"},
+ {&nativeLoad, "kimodo_model_load"},
+ {&nativeFree, "kimodo_model_free"},
+ {&nativeGenerate, "kimodo_generate"},
+ {&nativeMotionFree, "kimodo_motion_free"},
+ {&nativeFrames, "kimodo_motion_frames"},
+ {&nativeJoints, "kimodo_motion_joints"},
+ {&nativeRotations, "kimodo_motion_local_rotations_xyzw"},
+ {&nativeRoots, "kimodo_motion_root_positions"},
+ {&nativeConfigure, "localai_kimodo_configure"},
+ {&nativeJointName, "localai_kimodo_joint_name"},
+ {&nativeJointParent, "localai_kimodo_joint_parent"},
+ {&nativeJointOffset, "localai_kimodo_joint_offset"},
+ } {
+ purego.RegisterLibFunc(binding.function, lib, binding.name)
+ }
+ if version := nativeABI(); version != 1 {
+ return fmt.Errorf("kimodo ABI mismatch: expected 1, got %d", version)
+ }
+ return nil
+}
diff --git a/backend/go/kimodocpp/native/bridge.cpp b/backend/go/kimodocpp/native/bridge.cpp
new file mode 100644
index 000000000..22ca7478c
--- /dev/null
+++ b/backend/go/kimodocpp/native/bridge.cpp
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: MIT
+#include
+#include "../sources/kimodo.cpp/src/skeleton.hpp"
+
+#include
+#include
+#include
+
+#ifdef KIMODO_HAVE_GGML_VULKAN
+#include
+#endif
+
+namespace {
+const kimodo::detail::skeleton_spec *skeleton(int joints) {
+ switch (joints) {
+ case 22: return kimodo::detail::find_skeleton("smplx22");
+ case 30: return kimodo::detail::find_skeleton("soma30");
+ case 34: return kimodo::detail::find_skeleton("g1skel34");
+ default: return nullptr;
+ }
+}
+}
+
+extern "C" {
+// The upstream runtime currently reads environment variables instead of its
+// C API runtime_options. Set the native environment before creating a session.
+KIMODO_API int localai_kimodo_configure(const char *device, int threads, int chunk) {
+ if (!device || threads < 1 || chunk < 1 || chunk > 32) return -1;
+ if (std::strcmp(device, "cpu") && std::strcmp(device, "vulkan") && std::strcmp(device, "auto")) return -1;
+ if (std::strcmp(device, "vulkan") == 0) {
+#ifdef KIMODO_HAVE_GGML_VULKAN
+ try {
+ if (ggml_backend_vk_get_device_count() == 0) return -2;
+ } catch (...) {
+ // GGML can throw when no working ICD is installed. Never unwind into Go.
+ return -2;
+ }
+#else
+ return -2;
+#endif
+ }
+ char thread_value[32], chunk_value[32];
+ std::snprintf(thread_value, sizeof(thread_value), "%d", threads);
+ std::snprintf(chunk_value, sizeof(chunk_value), "%d", chunk);
+ if (setenv("KIMODO_BACKEND", device, 1) != 0 ||
+ setenv("KIMODO_THREADS", thread_value, 1) != 0 ||
+ setenv("KIMODO_TEXT_LAYER_CHUNK", chunk_value, 1) != 0) return -1;
+ return 0;
+}
+
+KIMODO_API const char *localai_kimodo_joint_name(int joints, int joint) {
+ const auto *spec = skeleton(joints);
+ return spec && joint >= 0 && joint < joints ? spec->names[joint].data() : nullptr;
+}
+
+KIMODO_API int localai_kimodo_joint_parent(int joints, int joint) {
+ const auto *spec = skeleton(joints);
+ return spec && joint >= 0 && joint < joints ? spec->parents[joint] : -2;
+}
+
+KIMODO_API const float *localai_kimodo_joint_offset(int joints, int joint) {
+ const auto *spec = skeleton(joints);
+ return spec && joint >= 0 && joint < joints ? spec->offsets[joint].data() : nullptr;
+}
+}
diff --git a/backend/go/kimodocpp/package.sh b/backend/go/kimodocpp/package.sh
new file mode 100644
index 000000000..f9065c473
--- /dev/null
+++ b/backend/go/kimodocpp/package.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+set -euo pipefail
+
+BACKEND_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
+# The package is generated; never mix libraries from different build variants.
+rm -rf -- "$BACKEND_DIR/package"
+mkdir -p "$BACKEND_DIR/package/lib"
+if [ -d "$BACKEND_DIR/${BUILD_DIR:-build-cpu}-avx2" ]; then
+ mkdir -p "$BACKEND_DIR/package/variants/avx2"
+ find "$BACKEND_DIR/${BUILD_DIR:-build-cpu}-avx2" -name 'libggml-cpu.so*' -exec cp -a {} "$BACKEND_DIR/package/variants/avx2/" \;
+fi
+cp "$BACKEND_DIR/kimodocpp" "$BACKEND_DIR/run.sh" "$BACKEND_DIR/package/"
+chmod +x "$BACKEND_DIR/package/run.sh"
+find "$BACKEND_DIR/${BUILD_DIR:-build-cpu}" \( -name 'libkimodo.so*' -o -name 'libkimodo.dylib' -o -name 'libggml*.so*' -o -name 'libggml*.dylib' \) -exec cp -a {} "$BACKEND_DIR/package/lib/" \;
+cp "$BACKEND_DIR/sources/kimodo.cpp/LICENSE" "$BACKEND_DIR/package/LICENSE.kimodo"
+cp "$BACKEND_DIR/sources/kimodo.cpp/NOTICE" "$BACKEND_DIR/package/NOTICE.kimodo"
+cp "$BACKEND_DIR/sources/kimodo.cpp/ggml/LICENSE" "$BACKEND_DIR/package/LICENSE.ggml"
+
+if [ "$(uname -s)" != Darwin ]; then
+ # purego's executable also imports libdl/libpthread, even with CGO disabled.
+ for library in "$BACKEND_DIR/package/kimodocpp" "$BACKEND_DIR"/package/lib/*.so*; do
+ while read -r dependency; do
+ [ -f "$dependency" ] && cp -L "$dependency" "$BACKEND_DIR/package/lib/"
+ done < <(ldd "$library" | awk '/=> \// {print $3}')
+ done
+ loader=$(ldd "$BACKEND_DIR/package/lib/libkimodo.so" | awk '/ld-linux/ {print $1; exit}')
+ if [ -f "$loader" ]; then cp -L "$loader" "$BACKEND_DIR/package/lib/ld.so"; fi
+ source "$BACKEND_DIR/../../../scripts/build/package-gpu-libs.sh" "$BACKEND_DIR/package/lib"
+ package_gpu_libs
+ if [ "${BUILD_TYPE:-}" = vulkan ]; then
+ # NVIDIA's host ICD dlopens EGL; it is not visible in libkimodo's ldd tree.
+ for soname in libEGL.so.1 libGLdispatch.so.0 libX11.so.6 libXext.so.6; do
+ dependency=$(ldconfig -p | awk -v name="$soname" '$1 == name {print $NF; exit}')
+ if [ ! -f "$dependency" ]; then
+ echo "Missing Vulkan ICD runtime dependency: $soname" >&2
+ exit 1
+ fi
+ copy_lib "$dependency"
+ done
+ sweep_transitive_deps
+ fi
+fi
diff --git a/backend/go/kimodocpp/package_test.go b/backend/go/kimodocpp/package_test.go
new file mode 100644
index 000000000..4f0728bc1
--- /dev/null
+++ b/backend/go/kimodocpp/package_test.go
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: MIT
+package main
+
+import (
+ "context"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/grpc"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("packaged backend", func() {
+ It("starts with bundled libraries and answers health without weights", func() {
+ directory := os.Getenv("KIMODO_TEST_PACKAGE")
+ if directory == "" {
+ Skip("set KIMODO_TEST_PACKAGE to test the built package")
+ }
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ Expect(err).NotTo(HaveOccurred())
+ address := listener.Addr().String()
+ Expect(listener.Close()).To(Succeed())
+ command := exec.Command("bash", filepath.Join(directory, "run.sh"), "--addr", address)
+ command.Stdout, command.Stderr = GinkgoWriter, GinkgoWriter
+ Expect(command.Start()).To(Succeed())
+ DeferCleanup(func() { _ = command.Process.Kill(); _ = command.Wait() })
+ client := grpc.NewClient(address, false, nil, false)
+ Eventually(func() bool {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ healthy, err := client.HealthCheck(ctx)
+ return err == nil && healthy
+ }, 20*time.Second, 100*time.Millisecond).Should(BeTrue())
+ })
+})
diff --git a/backend/go/kimodocpp/run.sh b/backend/go/kimodocpp/run.sh
new file mode 100644
index 000000000..8d37e6e67
--- /dev/null
+++ b/backend/go/kimodocpp/run.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+set -euo pipefail
+
+BACKEND_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
+if [ "$(uname -s)" = Darwin ]; then
+ export KIMODO_LIBRARY="$BACKEND_DIR/lib/libkimodo.dylib"
+ export DYLD_LIBRARY_PATH="$BACKEND_DIR/lib:${DYLD_LIBRARY_PATH:-}"
+else
+ export KIMODO_LIBRARY="$BACKEND_DIR/lib/libkimodo.so"
+ CPU_LIBRARY_DIR="$BACKEND_DIR/lib"
+ if [ -d "$BACKEND_DIR/variants/avx2" ] &&
+ grep -qw avx2 /proc/cpuinfo && grep -qw fma /proc/cpuinfo &&
+ grep -qw f16c /proc/cpuinfo && grep -qw bmi2 /proc/cpuinfo; then
+ CPU_LIBRARY_DIR="$BACKEND_DIR/variants/avx2"
+ fi
+ export LD_LIBRARY_PATH="$CPU_LIBRARY_DIR:$BACKEND_DIR/lib:${LD_LIBRARY_PATH:-}"
+fi
+
+if [ -f "$BACKEND_DIR/lib/ld.so" ]; then
+ exec "$BACKEND_DIR/lib/ld.so" "$BACKEND_DIR/kimodocpp" "$@"
+fi
+exec "$BACKEND_DIR/kimodocpp" "$@"
diff --git a/backend/go/nemo-speech-cpp/Makefile b/backend/go/nemo-speech-cpp/Makefile
index cc6d24242..f2cdd528c 100644
--- a/backend/go/nemo-speech-cpp/Makefile
+++ b/backend/go/nemo-speech-cpp/Makefile
@@ -12,7 +12,7 @@
# runs 'make -C backend/go/$(BACKEND) build' and then copies package/), so it
# has to produce the binary and the package, not just the shared libraries.
-NEMO_SPEECH_VERSION?=a5b6953c4a579a2bbd1c0913ad8a85c2a4d99953
+NEMO_SPEECH_VERSION?=07003daa7eefea542076310722ccaa89709ee3c3
NEMO_SPEECH_REPO?=https://github.com/NVIDIA/NeMo-Speech.cpp
GOCMD?=go
diff --git a/backend/go/omnivoice-cpp/Makefile b/backend/go/omnivoice-cpp/Makefile
index 92810a5ba..090674b94 100644
--- a/backend/go/omnivoice-cpp/Makefile
+++ b/backend/go/omnivoice-cpp/Makefile
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# omnivoice.cpp version
OMNIVOICE_REPO?=https://github.com/ServeurpersoCom/omnivoice.cpp
-OMNIVOICE_VERSION?=040c8b344d8c670ce1475194751d119b5ef82c78
+OMNIVOICE_VERSION?=cd6922ac3cb465f1c0a22465e77db21d367204fe
SO_TARGET?=libgomnivoicecpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
diff --git a/backend/go/sam3-cpp/Makefile b/backend/go/sam3-cpp/Makefile
index f91bb356a..68f66a474 100644
--- a/backend/go/sam3-cpp/Makefile
+++ b/backend/go/sam3-cpp/Makefile
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# sam3.cpp
SAM3_REPO?=https://github.com/PABannier/sam3.cpp
-SAM3_VERSION?=01832ef85fcc8eb6488f1d01cd247f07e96ff5a9
+SAM3_VERSION?=416186c501d060df7ca02989d49b38080f5f81f3
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
diff --git a/backend/go/stablediffusion-ggml/Makefile b/backend/go/stablediffusion-ggml/Makefile
index 8a5ea0b46..31ae17abb 100644
--- a/backend/go/stablediffusion-ggml/Makefile
+++ b/backend/go/stablediffusion-ggml/Makefile
@@ -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?=b68d58624d227682eb4b95ef8bcf569cd1311eb5
+STABLEDIFFUSION_GGML_VERSION?=2ea8aff7ef603977dc2ece7856bf9736dba96652
CMAKE_ARGS+=-DGGML_MAX_NAME=128
diff --git a/backend/go/stablediffusion-ggml/cpp/gosd.cpp b/backend/go/stablediffusion-ggml/cpp/gosd.cpp
index b876df256..4a1911015 100644
--- a/backend/go/stablediffusion-ggml/cpp/gosd.cpp
+++ b/backend/go/stablediffusion-ggml/cpp/gosd.cpp
@@ -1410,9 +1410,10 @@ int gen_video(sd_vid_gen_params_t *p, int steps, char *dst, float cfg_scale, int
// Generate
int num_frames_out = 0;
+ int effective_fps = fps;
sd_image_t* frames = nullptr;
sd_audio_t* audio = nullptr;
- bool ok = generate_video(sd_c, p, &frames, &num_frames_out, &audio);
+ bool ok = generate_video(sd_c, p, &frames, &num_frames_out, &audio, &effective_fps);
std::free(p);
if (!ok || !frames || num_frames_out == 0) {
@@ -1425,7 +1426,7 @@ int gen_video(sd_vid_gen_params_t *p, int steps, char *dst, float cfg_scale, int
fprintf(stderr, "Generated %d frames, muxing to %s via ffmpeg\n", num_frames_out, dst);
- int rc = ffmpeg_mux_raw_to_mp4(frames, num_frames_out, fps, audio, dst);
+ int rc = ffmpeg_mux_raw_to_mp4(frames, num_frames_out, effective_fps, audio, dst);
for (int i = 0; i < num_frames_out; i++) {
if (frames[i].data) free(frames[i].data);
diff --git a/backend/go/vllm-cpp/Makefile b/backend/go/vllm-cpp/Makefile
index 598d814c3..a322b9b62 100644
--- a/backend/go/vllm-cpp/Makefile
+++ b/backend/go/vllm-cpp/Makefile
@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
# vllm.cpp version
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
-VLLM_CPP_VERSION?=6bf3abb580982f4fd2e4525ef37802ee0ce28981
+VLLM_CPP_VERSION?=f3cd97e379fbeca4e50415edbdd52d2517b98ef8
# MLX GEMM provider (darwin/metal only; see the metal branch below for why).
# Consumed as the prebuilt pip wheel: building MLX from source needs `xcrun
diff --git a/backend/go/vllm-cpp/govllmcpp.go b/backend/go/vllm-cpp/govllmcpp.go
index 20e587a6b..db7555cd2 100644
--- a/backend/go/vllm-cpp/govllmcpp.go
+++ b/backend/go/vllm-cpp/govllmcpp.go
@@ -1,6 +1,6 @@
package main
-// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v23).
+// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v26).
//
// The structs below are hand-mirrored PODs of the C declarations, with
// explicit padding so the Go layout matches the C layout on linux/darwin
@@ -21,7 +21,7 @@ import (
// the header of the VLLM_CPP_VERSION pinned in the Makefile: the build checks
// the two against each other, because a mismatch is only caught at runtime by
// registerLib, where it takes the backend down on every load (issue #11379).
-const abiVersion = 23
+const abiVersion = 26
// The ABI's tri-state toggles (enable_prefix_caching ABI v7,
// enable_jump_forward ABI v10) share one encoding: 0 is NOT "off", it is
@@ -51,28 +51,28 @@ const (
vllmOK = 0
)
-// cModelParams mirrors vllm_model_params. The int32 fields sit in pairs so the
-// interior needs no padding on LP64, but the struct is 8-aligned (it holds
-// pointers) and ends on a lone int32, so the trailing pad is explicit. Offsets
-// and total size are asserted in vllmcpp_test.go.
+// cModelParams mirrors vllm_model_params. Go's natural alignment and the
+// explicit pad after LanguageModelOnly match the C layout on LP64. Offsets and
+// total size are asserted in vllmcpp_test.go.
type cModelParams struct {
- ModelPath uintptr // const char*
- TokenizerConfigPath uintptr // const char*; NULL = /... (ABI v9)
- BlockSize int32
- NumBlocks int32
- MaxModelLen int32
- MaxNumSeqs int32
- ToolParser uintptr // const char*; NULL = auto-detect (ABI v4)
- ReasoningParser uintptr // const char*; NULL = auto-detect (ABI v5)
- SpeculativeConfig uintptr // const char* JSON; NULL = no speculation (ABI v6)
- EnablePrefixCaching int32 // tri-state 0/1/2 (ABI v7)
- MaxNumBatchedTokens int32 // <= 0 = per-arch default (ABI v9)
- SchedulingPolicy uintptr // const char*; NULL = "fcfs" (ABI v9)
- KVTransferConfig uintptr // const char* JSON; NULL = no connector (ABI v9)
- OffloadConfig uintptr // const char* JSON; NULL = no weight offload
- EnableJumpForward int32 // tri-state 0/1/2 (ABI v10)
- // v14/v16 tail. LocalAI sets none of these (0 is "auto" for the device and
- // "unset" for both sizing knobs, i.e. the pre-v14 engine byte for byte), but
+ ModelPath uintptr // const char*
+ TokenizerConfigPath uintptr // const char*; NULL = /... (ABI v9)
+ BlockSize int32
+ NumBlocks int32
+ MaxModelLen int32
+ MaxNumSeqs int32
+ ToolParser uintptr // const char*; NULL = auto-detect (ABI v4)
+ ReasoningParser uintptr // const char*; NULL = auto-detect (ABI v5)
+ SpeculativeConfig uintptr // const char* JSON; NULL = no speculation (ABI v6)
+ EnablePrefixCaching int32 // tri-state 0/1/2 (ABI v7)
+ MaxNumBatchedTokens int32 // <= 0 = per-arch default (ABI v9)
+ SchedulingPolicy uintptr // const char*; NULL = "fcfs" (ABI v9)
+ KVTransferConfig uintptr // const char* JSON; NULL = no connector (ABI v9)
+ OffloadConfig uintptr // const char* JSON; NULL = no weight offload
+ EnableJumpForward int32 // tri-state 0/1/2 (ABI v10)
+ DisableSlidingWindow int32 // tri-state 0/1/2 (ABI v26)
+ // LocalAI sets none of the device and sizing fields below (0 is "auto" for
+ // the device and "unset" for both sizing knobs), but
// the fields MUST be mirrored: the C side reads sizeof(vllm_model_params)
// bytes off the pointer we hand it, so a Go struct that stopped at
// EnableJumpForward would have vllm_engine_load read 24 bytes past our
@@ -84,6 +84,7 @@ type cModelParams struct {
_ [4]byte
LimitMMPerPrompt uintptr // const char* JSON; NULL = default limits (ABI v19)
MMProjPath uintptr // const char*; NULL = no GGUF projector (ABI v22)
+ KVCacheDType uintptr // const char*; NULL = auto (ABI v24)
}
// cSamplingParams mirrors vllm_sampling_params (structured fields included).
diff --git a/backend/go/vllm-cpp/options.go b/backend/go/vllm-cpp/options.go
index b75e5a7fc..7e3edf4d3 100644
--- a/backend/go/vllm-cpp/options.go
+++ b/backend/go/vllm-cpp/options.go
@@ -103,6 +103,12 @@ type videoOptions struct {
height int32
numFrames int32
steps int32
+ // Directory for runtime prompt-activated LoRA. The engine resolves
+ // prompt tags against safetensors files in this
+ // directory at request time (row ROAD-V1-LORA-RUNTIME). Distinct from the
+ // load-time lora_path/lora_strength fusion, which bakes deltas into the
+ // weights at load.
+ loraDir string
// Where frames + WAV are written. Empty = a temporary directory beside the
// requested output, removed once the mux succeeds. Set it to keep the
// frame_%06d.ppm runs around (they are what ref2va's ref_video consumes).
@@ -258,6 +264,8 @@ func applyVideoOption(vo *videoOptions, key, value string) bool {
vo.workdir = v
case "video_crf":
vo.crf = parseInt32(v, vo.crf)
+ case "video_lora_dir":
+ vo.loraDir = v
case "ffmpeg", "ffmpeg_path":
vo.ffmpeg = v
default:
diff --git a/backend/go/vllm-cpp/video.go b/backend/go/vllm-cpp/video.go
index 1b2777087..f9506a033 100644
--- a/backend/go/vllm-cpp/video.go
+++ b/backend/go/vllm-cpp/video.go
@@ -140,7 +140,7 @@ func (v *VllmCpp) loadVideo(opts *pb.ModelOptions, dit string) error {
DequantBf16: vo.dequantBf16,
Fp4Resident: vo.fp4Resident,
}
- var keep [][]byte
+ var keep []any
setStr := func(dst *uintptr, s string) {
if s == "" {
return
@@ -159,11 +159,39 @@ func (v *VllmCpp) loadVideo(opts *pb.ModelOptions, dit string) error {
setStr(&mp.PromptEmbedsPath, vo.promptEmbedsPath)
setStr(&mp.Partition, vo.partition)
+ // Load-time LoRA fusion: the standard LocalAI lora_adapters/lora_scales
+ // config fields carry into the vllm.cpp extras seam as indexed
+ // lora_path/lora_strength pairs (row ROAD-V1-DIT-LORA). The engine fuses
+ // the deltas into the DiT weights at load, so the adapters are "always
+ // loaded" — there is no per-request activation in this path.
+ extraKeys, extraValues := buildLoraExtras(
+ opts.GetLoraAdapters(), opts.GetLoraScales(),
+ opts.GetLoraAdapter(), opts.GetLoraScale(),
+ opts.ModelPath)
+ loraN := len(extraKeys) / 2 // each load-time adapter = lora_path + lora_strength
+ // Runtime prompt-activated LoRA: lora_dir tells the engine where to
+ // resolve prompt tags at request time (row
+ // ROAD-V1-LORA-RUNTIME). The engine strips the tags from the prompt and
+ // applies the deltas per-request without touching base weights.
+ if vo.loraDir != "" {
+ extraKeys = append(extraKeys, "lora_dir")
+ extraValues = append(extraValues, vo.loraDir)
+ }
+ if len(extraKeys) > 0 {
+ keyPtrs, keyBacking := cStringArray(extraKeys)
+ valPtrs, valBacking := cStringArray(extraValues)
+ mp.NExtras = int32(len(extraKeys))
+ mp.ExtraKeys = uintptr(unsafe.Pointer(&keyPtrs[0])) // #nosec G103 -- borrowed by C for the load call only
+ mp.ExtraValues = uintptr(unsafe.Pointer(&valPtrs[0])) // #nosec G103 -- borrowed by C for the load call only
+ keep = append(keep, keyPtrs, valPtrs, keyBacking, valBacking)
+ }
+
xlog.Info("[vllm-cpp] Load (MiniMax-H3 video)", "dit", dit, "engine", vllmVersion(),
"encoder", vo.encoderPath, "tokenizer", vo.tokenizerPath,
"videoVae", vo.videoVaePath, "audioVae", vo.audioVaePath,
"partition", vo.partition, "device", videoDeviceName(vo.device),
- "dequantBf16", vo.dequantBf16 == 1, "fp4Resident", vo.fp4Resident == 1)
+ "dequantBf16", vo.dequantBf16 == 1, "fp4Resident", vo.fp4Resident == 1,
+ "loraAdapters", loraN, "loraDir", vo.loraDir)
var engine uintptr
rc := vllmVideoEngineLoad(unsafe.Pointer(&mp), unsafe.Pointer(&engine)) // #nosec G103 -- POD out-params
@@ -632,3 +660,41 @@ func siblingConfigJSON(weights string) string {
}
return candidate
}
+
+// buildLoraExtras converts the standard LocalAI lora_adapters/lora_scales
+// config fields into the indexed lora_path/lora_strength extras pairs that
+// vllm.cpp's ResolveDitLoraSpecs expects (row ROAD-V1-DIT-LORA).
+//
+// The singular lora_adapter/lora_scale fields are folded in as the first
+// adapter. Adapter paths are resolved against modelPath when relative.
+// Strength defaults to 1.0 when no scale is given for an adapter.
+//
+// Returns nil, nil when no adapters are configured.
+func buildLoraExtras(adapters []string, scales []float32, singularAdapter string, singularScale float32, modelPath string) (keys, values []string) {
+ if singularAdapter != "" {
+ adapters = append([]string{singularAdapter}, adapters...)
+ scales = append([]float32{singularScale}, scales...)
+ }
+ if len(adapters) == 0 {
+ return nil, nil
+ }
+ for i, adapter := range adapters {
+ path := adapter
+ if !filepath.IsAbs(path) && modelPath != "" {
+ path = filepath.Join(modelPath, path)
+ }
+ suffix := ""
+ if i > 0 {
+ suffix = "_" + strconv.Itoa(i + 1)
+ }
+ keys = append(keys, "lora_path"+suffix)
+ values = append(values, path)
+ strength := 1.0
+ if i < len(scales) {
+ strength = float64(scales[i])
+ }
+ keys = append(keys, "lora_strength"+suffix)
+ values = append(values, strconv.FormatFloat(strength, 'f', -1, 32))
+ }
+ return keys, values
+}
diff --git a/backend/go/vllm-cpp/video_test.go b/backend/go/vllm-cpp/video_test.go
index 547000542..418d2b356 100644
--- a/backend/go/vllm-cpp/video_test.go
+++ b/backend/go/vllm-cpp/video_test.go
@@ -282,6 +282,83 @@ var _ = Describe("GenerateVideo preconditions", func() {
})
})
+var _ = Describe("buildLoraExtras", func() {
+ It("returns nil when no adapters are configured", func() {
+ keys, vals := buildLoraExtras(nil, nil, "", 0, "/models")
+ Expect(keys).To(BeNil())
+ Expect(vals).To(BeNil())
+ })
+
+ It("builds indexed lora_path/lora_strength pairs for one adapter", func() {
+ keys, vals := buildLoraExtras(
+ []string{"/abs/lora.safetensors"}, []float32{0.7}, "", 0, "/models")
+ Expect(keys).To(Equal([]string{"lora_path", "lora_strength"}))
+ Expect(vals).To(Equal([]string{"/abs/lora.safetensors", "0.7"}))
+ })
+
+ It("appends _N suffix from the second adapter onward", func() {
+ keys, vals := buildLoraExtras(
+ []string{"/a.safetensors", "/b.safetensors"},
+ []float32{0.5, 0.3}, "", 0, "")
+ Expect(keys).To(Equal([]string{
+ "lora_path", "lora_strength",
+ "lora_path_2", "lora_strength_2",
+ }))
+ Expect(vals).To(Equal([]string{
+ "/a.safetensors", "0.5",
+ "/b.safetensors", "0.3",
+ }))
+ })
+
+ It("defaults strength to 1 when no scale is given", func() {
+ keys, vals := buildLoraExtras(
+ []string{"/a.safetensors"}, nil, "", 0, "")
+ Expect(keys).To(Equal([]string{"lora_path", "lora_strength"}))
+ Expect(vals).To(Equal([]string{"/a.safetensors", "1"}))
+ })
+
+ It("folds the singular lora_adapter/lora_scale in as the first adapter", func() {
+ keys, vals := buildLoraExtras(
+ []string{"/second.safetensors"}, []float32{0.2},
+ "/first.safetensors", 0.8, "")
+ Expect(keys).To(Equal([]string{
+ "lora_path", "lora_strength",
+ "lora_path_2", "lora_strength_2",
+ }))
+ Expect(vals).To(Equal([]string{
+ "/first.safetensors", "0.8",
+ "/second.safetensors", "0.2",
+ }))
+ })
+
+ It("resolves relative adapter paths against the models directory", func() {
+ keys, vals := buildLoraExtras(
+ []string{"loras/style.safetensors"}, nil, "", 0, "/models")
+ Expect(keys).To(Equal([]string{"lora_path", "lora_strength"}))
+ Expect(vals[0]).To(Equal(filepath.Join("/models", "loras/style.safetensors")))
+ })
+})
+
+var _ = Describe("video_lora_dir option", func() {
+ It("parses video_lora_dir into loraDir", func() {
+ vo := videoOptions{}
+ Expect(applyVideoOption(&vo, "video_lora_dir", "/data/loras")).To(BeTrue())
+ Expect(vo.loraDir).To(Equal("/data/loras"))
+ })
+
+ It("trims whitespace around the path", func() {
+ vo := videoOptions{}
+ Expect(applyVideoOption(&vo, "video_lora_dir", " /data/loras ")).To(BeTrue())
+ Expect(vo.loraDir).To(Equal("/data/loras"))
+ })
+
+ It("returns false for an unknown key", func() {
+ vo := videoOptions{}
+ Expect(applyVideoOption(&vo, "video_not_a_real_key", "x")).To(BeFalse())
+ Expect(vo.loraDir).To(BeEmpty())
+ })
+})
+
// writePPM writes a valid P6 header of the given geometry. Only the header is
// read by anything under test, so the pixel payload is left off.
func writePPM(width, height int) string {
diff --git a/backend/go/vllm-cpp/vllmcpp_test.go b/backend/go/vllm-cpp/vllmcpp_test.go
index 55f6e211a..201fd1692 100644
--- a/backend/go/vllm-cpp/vllmcpp_test.go
+++ b/backend/go/vllm-cpp/vllmcpp_test.go
@@ -16,7 +16,7 @@ func TestVllmCpp(t *testing.T) {
RunSpecs(t, "vllm-cpp suite")
}
-// The Go POD mirrors must match the C struct layout of vllm.h (ABI v23)
+// The Go POD mirrors must match the C struct layout of vllm.h (ABI v26)
// byte-for-byte: these offsets are the C offsets on LP64 (linux/darwin
// amd64+arm64). A failure here means govllmcpp.go drifted from vllm.h.
var _ = Describe("C ABI struct mirrors", func() {
@@ -24,7 +24,7 @@ var _ = Describe("C ABI struct mirrors", func() {
// VLLM_ABI_VERSION in the vllm.h of VLLM_CPP_VERSION (Makefile).
// Moving the pin past this without growing the mirrors below ships a
// backend that refuses every load at startup (issue #11379).
- Expect(abiVersion).To(Equal(23))
+ Expect(abiVersion).To(Equal(26))
})
It("cModelParams matches vllm_model_params", func() {
@@ -44,15 +44,17 @@ var _ = Describe("C ABI struct mirrors", func() {
Expect(unsafe.Offsetof(p.KVTransferConfig)).To(Equal(uintptr(72)))
Expect(unsafe.Offsetof(p.OffloadConfig)).To(Equal(uintptr(80)))
Expect(unsafe.Offsetof(p.EnableJumpForward)).To(Equal(uintptr(88)))
- Expect(unsafe.Offsetof(p.Device)).To(Equal(uintptr(92)))
- // 96: gpu_memory_utilization is a double, so it takes the next
+ Expect(unsafe.Offsetof(p.DisableSlidingWindow)).To(Equal(uintptr(92)))
+ Expect(unsafe.Offsetof(p.Device)).To(Equal(uintptr(96)))
+ // 104: gpu_memory_utilization is a double, so it takes the next
// 8-aligned slot after the int32 pair. Go pads identically.
- Expect(unsafe.Offsetof(p.GPUMemoryUtil)).To(Equal(uintptr(96)))
- Expect(unsafe.Offsetof(p.KVCacheMemoryBytes)).To(Equal(uintptr(104)))
- Expect(unsafe.Offsetof(p.LanguageModelOnly)).To(Equal(uintptr(112)))
- Expect(unsafe.Offsetof(p.LimitMMPerPrompt)).To(Equal(uintptr(120)))
- Expect(unsafe.Offsetof(p.MMProjPath)).To(Equal(uintptr(128)))
- Expect(unsafe.Sizeof(p)).To(Equal(uintptr(136)))
+ Expect(unsafe.Offsetof(p.GPUMemoryUtil)).To(Equal(uintptr(104)))
+ Expect(unsafe.Offsetof(p.KVCacheMemoryBytes)).To(Equal(uintptr(112)))
+ Expect(unsafe.Offsetof(p.LanguageModelOnly)).To(Equal(uintptr(120)))
+ Expect(unsafe.Offsetof(p.LimitMMPerPrompt)).To(Equal(uintptr(128)))
+ Expect(unsafe.Offsetof(p.MMProjPath)).To(Equal(uintptr(136)))
+ Expect(unsafe.Offsetof(p.KVCacheDType)).To(Equal(uintptr(144)))
+ Expect(unsafe.Sizeof(p)).To(Equal(uintptr(152)))
})
It("cSamplingParams matches vllm_sampling_params (ABI v8)", func() {
diff --git a/backend/go/whisper/Makefile b/backend/go/whisper/Makefile
index 49c47b8ae..dacb17d87 100644
--- a/backend/go/whisper/Makefile
+++ b/backend/go/whisper/Makefile
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# whisper.cpp version
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
-WHISPER_CPP_VERSION?=c44b60b8053bbf2a5c1e014f11323fb3f2485177
+WHISPER_CPP_VERSION?=5670d5c0bbcb148feabef84400a07cfca9aa3b30
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
diff --git a/backend/index.yaml b/backend/index.yaml
index 5c6468f32..8b2efd417 100644
--- a/backend/index.yaml
+++ b/backend/index.yaml
@@ -520,6 +520,25 @@
nvidia-cuda-12: "cuda12-stablediffusion-ggml"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-stablediffusion-ggml"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-stablediffusion-ggml"
+- &kimodocpp
+ name: "kimodocpp"
+ alias: "kimodocpp"
+ license: apache-2.0
+ description: |
+ kimodo.cpp text-to-motion: animated skeleton GLB on CPU and Vulkan.
+ Apple Silicon uses the CPU runtime.
+ urls:
+ - https://github.com/localai-org/kimodo.cpp
+ tags: [3d-animation, text-to-motion, CPU, Vulkan]
+ capabilities:
+ default: "cpu-kimodocpp"
+ vulkan: "vulkan-kimodocpp"
+ nvidia: "vulkan-kimodocpp"
+ nvidia-cuda-12: "vulkan-kimodocpp"
+ nvidia-cuda-13: "vulkan-kimodocpp"
+ amd: "vulkan-kimodocpp"
+ intel: "vulkan-kimodocpp"
+ metal: "cpu-darwin-arm64-kimodocpp"
- &trellis2cpp
name: "trellis2cpp"
alias: "trellis2cpp"
@@ -4045,6 +4064,49 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-stablediffusion-ggml"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-stablediffusion-ggml
+## kimodo.cpp
+- !!merge <<: *kimodocpp
+ name: "kimodocpp-development"
+ capabilities:
+ default: "cpu-kimodocpp-development"
+ vulkan: "vulkan-kimodocpp-development"
+ nvidia: "vulkan-kimodocpp-development"
+ nvidia-cuda-12: "vulkan-kimodocpp-development"
+ nvidia-cuda-13: "vulkan-kimodocpp-development"
+ amd: "vulkan-kimodocpp-development"
+ intel: "vulkan-kimodocpp-development"
+ metal: "cpu-darwin-arm64-kimodocpp-development"
+- !!merge <<: *kimodocpp
+ name: "cpu-kimodocpp"
+ uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-kimodocpp"
+ mirrors:
+ - localai/localai-backends:latest-cpu-kimodocpp
+- !!merge <<: *kimodocpp
+ name: "cpu-kimodocpp-development"
+ uri: "quay.io/go-skynet/local-ai-backends:master-cpu-kimodocpp"
+ mirrors:
+ - localai/localai-backends:master-cpu-kimodocpp
+- !!merge <<: *kimodocpp
+ name: "vulkan-kimodocpp"
+ uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-kimodocpp"
+ mirrors:
+ - localai/localai-backends:latest-gpu-vulkan-kimodocpp
+- !!merge <<: *kimodocpp
+ name: "vulkan-kimodocpp-development"
+ uri: "quay.io/go-skynet/local-ai-backends:master-gpu-vulkan-kimodocpp"
+ mirrors:
+ - localai/localai-backends:master-gpu-vulkan-kimodocpp
+- !!merge <<: *kimodocpp
+ name: "cpu-darwin-arm64-kimodocpp"
+ uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-darwin-arm64-kimodocpp"
+ mirrors:
+ - localai/localai-backends:latest-cpu-darwin-arm64-kimodocpp
+- !!merge <<: *kimodocpp
+ name: "cpu-darwin-arm64-kimodocpp-development"
+ uri: "quay.io/go-skynet/local-ai-backends:master-cpu-darwin-arm64-kimodocpp"
+ mirrors:
+ - localai/localai-backends:master-cpu-darwin-arm64-kimodocpp
+
## trellis2cpp
- !!merge <<: *trellis2cpp
name: "cpu-trellis2cpp"
diff --git a/backend/python/common/template/requirements.txt b/backend/python/common/template/requirements.txt
index 205cdbb75..67968538b 100644
--- a/backend/python/common/template/requirements.txt
+++ b/backend/python/common/template/requirements.txt
@@ -1,3 +1,3 @@
-grpcio==1.83.1
+grpcio==1.84.0
protobuf
grpcio-tools
\ No newline at end of file
diff --git a/backend/python/faster-whisper/install.sh b/backend/python/faster-whisper/install.sh
index d5350f700..3e5d97ad8 100755
--- a/backend/python/faster-whisper/install.sh
+++ b/backend/python/faster-whisper/install.sh
@@ -26,7 +26,7 @@ if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
USE_PIP=true
fi
-CTRANSLATE2_VERSION=${CTRANSLATE2_VERSION:-v4.7.1}
+CTRANSLATE2_VERSION=${CTRANSLATE2_VERSION:-v4.8.2}
CTRANSLATE2_ROCM_WHEEL_OS=${CTRANSLATE2_ROCM_WHEEL_OS:-Linux}
CTRANSLATE2_ROCM_WHEEL_ARCHIVE="rocm-python-wheels-${CTRANSLATE2_ROCM_WHEEL_OS}.zip"
diff --git a/backend/python/qwen-tts/install.sh b/backend/python/qwen-tts/install.sh
index 9c8bcf83f..b7d487873 100755
--- a/backend/python/qwen-tts/install.sh
+++ b/backend/python/qwen-tts/install.sh
@@ -10,11 +10,4 @@ else
source $backend_dir/../common/libbackend.sh
fi
-# CUDA 13 has no prebuilt FlashAttention wheel, so the fallback source build
-# exceeds the CI runner's memory when ninja compiles multiple units at once.
-if [ "x${BUILD_PROFILE}" = "xcublas13" ]; then
- export MAX_JOBS="${MAX_JOBS:-1}"
- export NVCC_THREADS="${NVCC_THREADS:-1}"
-fi
-
installRequirements
diff --git a/backend/python/qwen-tts/requirements-cublas13-after.txt b/backend/python/qwen-tts/requirements-cublas13-after.txt
deleted file mode 100644
index c8ba665b2..000000000
--- a/backend/python/qwen-tts/requirements-cublas13-after.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-ninja
-flash-attn
diff --git a/backend/python/qwen-tts/test.sh b/backend/python/qwen-tts/test.sh
index 97ea2faf9..eb59f2aaf 100755
--- a/backend/python/qwen-tts/test.sh
+++ b/backend/python/qwen-tts/test.sh
@@ -2,15 +2,6 @@
set -e
backend_dir=$(dirname $0)
-
-for cuda_version in 12 13; do
- grep -qx "flash-attn" "$backend_dir/requirements-cublas${cuda_version}-after.txt"
-done
-
-grep -q 'BUILD_PROFILE.*cublas13' "$backend_dir/install.sh"
-grep -q 'MAX_JOBS.*1' "$backend_dir/install.sh"
-grep -q 'NVCC_THREADS.*1' "$backend_dir/install.sh"
-
if [ -d $backend_dir/common ]; then
source $backend_dir/common/libbackend.sh
else
diff --git a/backend/python/rerankers/requirements.txt b/backend/python/rerankers/requirements.txt
index 5f1e5f0ae..568a99201 100644
--- a/backend/python/rerankers/requirements.txt
+++ b/backend/python/rerankers/requirements.txt
@@ -1,3 +1,3 @@
-grpcio==1.83.1
+grpcio==1.84.0
protobuf
certifi
\ No newline at end of file
diff --git a/backend/python/transformers/requirements-cpu.txt b/backend/python/transformers/requirements-cpu.txt
index 06d5fe878..3e3206912 100644
--- a/backend/python/transformers/requirements-cpu.txt
+++ b/backend/python/transformers/requirements-cpu.txt
@@ -7,4 +7,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
-protobuf==7.35.0
\ No newline at end of file
+protobuf==7.36.1
\ No newline at end of file
diff --git a/backend/python/transformers/requirements-cublas12.txt b/backend/python/transformers/requirements-cublas12.txt
index 6a40d2ca4..40bf331d4 100644
--- a/backend/python/transformers/requirements-cublas12.txt
+++ b/backend/python/transformers/requirements-cublas12.txt
@@ -7,4 +7,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
-protobuf==7.35.0
\ No newline at end of file
+protobuf==7.36.1
\ No newline at end of file
diff --git a/backend/python/transformers/requirements-cublas13.txt b/backend/python/transformers/requirements-cublas13.txt
index 649590009..f394f98b1 100644
--- a/backend/python/transformers/requirements-cublas13.txt
+++ b/backend/python/transformers/requirements-cublas13.txt
@@ -7,4 +7,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
-protobuf==7.35.0
\ No newline at end of file
+protobuf==7.36.1
\ No newline at end of file
diff --git a/backend/python/transformers/requirements-hipblas.txt b/backend/python/transformers/requirements-hipblas.txt
index 0632addc2..e4b1bba11 100644
--- a/backend/python/transformers/requirements-hipblas.txt
+++ b/backend/python/transformers/requirements-hipblas.txt
@@ -8,4 +8,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
-protobuf==7.35.0
\ No newline at end of file
+protobuf==7.36.1
\ No newline at end of file
diff --git a/backend/python/transformers/requirements-intel.txt b/backend/python/transformers/requirements-intel.txt
index 18eed5532..54ee6ce67 100644
--- a/backend/python/transformers/requirements-intel.txt
+++ b/backend/python/transformers/requirements-intel.txt
@@ -8,4 +8,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
-protobuf==7.35.0
\ No newline at end of file
+protobuf==7.36.1
\ No newline at end of file
diff --git a/backend/python/transformers/requirements-mps.txt b/backend/python/transformers/requirements-mps.txt
index 8938cbf8f..ea8ba5ab0 100644
--- a/backend/python/transformers/requirements-mps.txt
+++ b/backend/python/transformers/requirements-mps.txt
@@ -7,4 +7,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
-protobuf==7.35.0
+protobuf==7.36.1
diff --git a/backend/python/transformers/requirements.txt b/backend/python/transformers/requirements.txt
index 3fa4995d0..d85aca02a 100644
--- a/backend/python/transformers/requirements.txt
+++ b/backend/python/transformers/requirements.txt
@@ -1,5 +1,5 @@
grpcio==1.83.0
-protobuf==7.35.0
+protobuf==7.36.1
certifi
setuptools
scipy==1.18.0
diff --git a/backend/python/vllm/install.sh b/backend/python/vllm/install.sh
index a4124977c..2079a3b87 100755
--- a/backend/python/vllm/install.sh
+++ b/backend/python/vllm/install.sh
@@ -119,7 +119,7 @@ if [ "$(uname -s)" = "Darwin" ]; then
# can rewrite it. Darwin therefore follows vllm-metal and can lag the Linux
# vllm pin (requirements-cublas13-after.txt, bumped independently against
# vllm/vllm) until vllm-metal supports a newer vLLM.
- VLLM_METAL_VERSION="v0.28.0"
+ VLLM_METAL_VERSION="v0.29.0"
# The coupled vLLM source version is whatever this vllm-metal release builds
# against. Derive it from the PINNED tag rather than hardcoding a second value
diff --git a/backend/python/vllm/requirements.txt b/backend/python/vllm/requirements.txt
index ba7147632..64375de60 100644
--- a/backend/python/vllm/requirements.txt
+++ b/backend/python/vllm/requirements.txt
@@ -1,4 +1,4 @@
-grpcio==1.83.1
+grpcio==1.84.0
protobuf
certifi
setuptools
diff --git a/cmd/local-ai/main.go b/cmd/local-ai/main.go
index b1799dbb8..ff520b6d9 100644
--- a/cmd/local-ai/main.go
+++ b/cmd/local-ai/main.go
@@ -105,6 +105,12 @@ For documentation and support:
xlog.SetLogger(xlog.NewLogger(xlog.LogLevel(*cli.CLI.LogLevel), *cli.CLI.LogFormat, logOpts...))
+ // Loaded here rather than in RunCMD so `worker`, `backends install` and
+ // `models install` authenticate the same way the server does.
+ if err := cli.LoadCredentials(cli.CLI.CredentialsFile); err != nil {
+ xlog.Fatal("Error loading credentials", "error", err)
+ }
+
// Run the thing!
err = ctx.Run(&cli.CLI.Context)
if err != nil {
diff --git a/core/backend/animation.go b/core/backend/animation.go
new file mode 100644
index 000000000..b29986fa1
--- /dev/null
+++ b/core/backend/animation.go
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: MIT
+package backend
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/trace"
+ "github.com/mudler/LocalAI/pkg/grpc/proto"
+ "github.com/mudler/LocalAI/pkg/model"
+)
+
+func Model3DAnimation(ctx context.Context, request *proto.Animate3DRequest, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (err error) {
+ inferenceModel, err := loader.Load(ModelOptions(modelConfig, appConfig)...)
+ if err != nil {
+ recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
+ return err
+ }
+ release, err := AcquireGlobalBackendSlot()
+ if err != nil {
+ return err
+ }
+ defer release()
+ if appConfig.EnableTracing {
+ trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
+ start := time.Now()
+ entry := trace.BackendTrace{Timestamp: start, Type: trace.BackendTrace3DAnimation, ModelName: modelConfig.Name, Backend: modelConfig.Backend, Summary: "3d: animate"}
+ entry.ID = trace.BeginBackendTrace(entry)
+ defer trace.CancelBackendTrace(entry.ID)
+ defer func() {
+ entry.Duration = time.Since(start)
+ if err != nil {
+ entry.Error = err.Error()
+ }
+ trace.RecordBackendTrace(entry)
+ }()
+ }
+ request.ModelIdentity = modelConfig.Model
+ result, err := inferenceModel.Animate3D(ctx, request)
+ if err != nil {
+ return err
+ }
+ if result == nil || !result.Success {
+ return fmt.Errorf("animation backend failed: %s", result.GetMessage())
+ }
+ return nil
+}
diff --git a/core/cli/benchmark/benchmark.go b/core/cli/benchmark/benchmark.go
new file mode 100644
index 000000000..0ceef79f8
--- /dev/null
+++ b/core/cli/benchmark/benchmark.go
@@ -0,0 +1,257 @@
+// SPDX-License-Identifier: MIT
+// Package benchmark measures text inference through a running LocalAI server.
+package benchmark
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "text/tabwriter"
+ "time"
+
+ "github.com/mudler/LocalAI/pkg/httpclient"
+)
+
+type Command struct {
+ Models []string `arg:"" required:"" help:"Configured text model names to benchmark sequentially."`
+ Endpoint string `default:"http://127.0.0.1:8080" help:"LocalAI server URL, optionally ending in /v1."`
+ APIKey string `name:"api-key" env:"LOCALAI_API_KEY,API_KEY" help:"API key for the server."`
+ Prompt string `default:"Explain why the sky is blue." help:"User prompt sent with every request."`
+ MaxTokens int `default:"128" help:"Maximum completion tokens per request."`
+ Runs int `default:"3" help:"Measured requests per model."`
+ Warmup int `default:"1" help:"Unmeasured requests before each model's measured runs."`
+ Timeout time.Duration `default:"5m" help:"Timeout for each request."`
+ JSON bool `name:"json" help:"Write settings and raw samples as JSON."`
+}
+
+type settings struct {
+ Endpoint string `json:"endpoint"`
+ Prompt string `json:"prompt"`
+ MaxTokens int `json:"max_tokens"`
+ Runs int `json:"runs"`
+ Warmup int `json:"warmup"`
+ Timeout string `json:"timeout"`
+ Temperature float64 `json:"temperature"`
+ Stream bool `json:"stream"`
+}
+type sample struct {
+ LatencySeconds float64 `json:"latency_seconds"`
+ PromptTokens *int `json:"prompt_tokens"`
+ CompletionTokens *int `json:"completion_tokens"`
+}
+type modelResult struct {
+ Model string `json:"model"`
+ Samples []sample `json:"samples"`
+ MinSeconds float64 `json:"min_seconds"`
+ MeanSeconds float64 `json:"mean_seconds"`
+ MaxSeconds float64 `json:"max_seconds"`
+ CompletionTokensPerSecond *float64 `json:"completion_tokens_per_second"`
+}
+type report struct {
+ Settings settings `json:"settings"`
+ Results []modelResult `json:"results"`
+}
+
+func (c *Command) Run() error {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ return c.run(ctx, os.Stdout)
+}
+
+func completionURL(endpoint string) (string, error) {
+ u, err := url.Parse(endpoint)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(endpoint, "#") {
+ return "", errors.New("endpoint must be an HTTP(S) URL without credentials, query, or fragment")
+ }
+ path := strings.TrimRight(u.Path, "/")
+ if !strings.HasSuffix(path, "/v1") {
+ path += "/v1"
+ }
+ u.Path = path + "/chat/completions"
+ u.RawPath = ""
+ return u.String(), nil
+}
+
+func (c *Command) run(ctx context.Context, out io.Writer) error {
+ endpoint, err := completionURL(c.Endpoint)
+ if err != nil {
+ return err
+ }
+ if c.Runs <= 0 || c.Warmup < 0 || c.MaxTokens <= 0 || c.Timeout <= 0 {
+ return errors.New("runs, max-tokens, and timeout must be positive; warmup must be nonnegative")
+ }
+ if strings.TrimSpace(c.Prompt) == "" {
+ return errors.New("prompt must not be blank")
+ }
+ if len(c.Models) == 0 {
+ return errors.New("at least one model is required")
+ }
+ for _, model := range c.Models {
+ if strings.TrimSpace(model) == "" {
+ return errors.New("model names must not be blank")
+ }
+ }
+ client := httpclient.NewWithTimeout(c.Timeout)
+ defer client.CloseIdleConnections()
+ result := report{Settings: settings{Endpoint: endpoint, Prompt: c.Prompt, MaxTokens: c.MaxTokens, Runs: c.Runs, Warmup: c.Warmup, Timeout: c.Timeout.String()}}
+ for _, model := range c.Models {
+ measured := modelResult{Model: model}
+ for i := 0; i < c.Warmup; i++ {
+ if _, err := c.request(ctx, client, endpoint, model); err != nil {
+ return fmt.Errorf("model %q warmup %d: %w", model, i+1, err)
+ }
+ }
+ for i := 0; i < c.Runs; i++ {
+ s, err := c.request(ctx, client, endpoint, model)
+ if err != nil {
+ return fmt.Errorf("model %q run %d: %w", model, i+1, err)
+ }
+ measured.Samples = append(measured.Samples, s)
+ }
+ measured.summarize()
+ result.Results = append(result.Results, measured)
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ // Buffer the complete report so a failed model never leaves partial results.
+ var buffer bytes.Buffer
+ if c.JSON {
+ encoder := json.NewEncoder(&buffer)
+ encoder.SetIndent("", " ")
+ if err := encoder.Encode(result); err != nil {
+ return err
+ }
+ } else {
+ table := tabwriter.NewWriter(&buffer, 0, 4, 2, ' ', 0)
+ _, _ = fmt.Fprintln(table, "MODEL\tRUNS\tMIN (s)\tMEAN (s)\tMAX (s)\tEND-TO-END TOKENS/s")
+ for _, r := range result.Results {
+ throughput := "N/A"
+ if r.CompletionTokensPerSecond != nil {
+ throughput = fmt.Sprintf("%.2f", *r.CompletionTokensPerSecond)
+ }
+ _, _ = fmt.Fprintf(table, "%s\t%d\t%.4f\t%.4f\t%.4f\t%s\n", r.Model, len(r.Samples), r.MinSeconds, r.MeanSeconds, r.MaxSeconds, throughput)
+ }
+ if err := table.Flush(); err != nil {
+ return err
+ }
+ }
+ _, err = io.Copy(out, &buffer)
+ return err
+}
+
+func (c *Command) request(ctx context.Context, client *http.Client, endpoint, model string) (sample, error) {
+ var s sample
+ body, err := json.Marshal(struct {
+ Model string `json:"model"`
+ Messages []map[string]string `json:"messages"`
+ MaxTokens int `json:"max_tokens"`
+ Temperature float64 `json:"temperature"`
+ Stream bool `json:"stream"`
+ }{Model: model, Messages: []map[string]string{{"role": "user", "content": c.Prompt}}, MaxTokens: c.MaxTokens})
+ if err != nil {
+ return s, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return s, errors.New("cannot create benchmark request")
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if c.APIKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.APIKey)
+ }
+ start := time.Now()
+ resp, err := client.Do(req)
+ if err != nil {
+ if ctx.Err() != nil {
+ return s, ctx.Err()
+ }
+ if errors.Is(err, context.DeadlineExceeded) {
+ return s, fmt.Errorf("request timed out: %w", context.DeadlineExceeded)
+ }
+ if errors.Is(err, httpclient.ErrRedirectBlocked) {
+ return s, httpclient.ErrRedirectBlocked
+ }
+ // Transport errors and server responses can echo credentials.
+ return s, errors.New("HTTP request failed")
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return s, fmt.Errorf("HTTP status %d", resp.StatusCode)
+ }
+ var response struct {
+ Choices []json.RawMessage `json:"choices"`
+ Usage struct {
+ PromptTokens *int `json:"prompt_tokens"`
+ CompletionTokens *int `json:"completion_tokens"`
+ } `json:"usage"`
+ Error json.RawMessage `json:"error"`
+ }
+ decoder := json.NewDecoder(resp.Body)
+ if err := decoder.Decode(&response); err != nil {
+ if ctx.Err() != nil {
+ return s, ctx.Err()
+ }
+ if errors.Is(err, context.DeadlineExceeded) {
+ return s, fmt.Errorf("request timed out: %w", context.DeadlineExceeded)
+ }
+ return s, errors.New("invalid JSON response")
+ }
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ return s, errors.New("invalid trailing response data")
+ }
+ if len(response.Error) > 0 && string(response.Error) != "null" {
+ var detail struct {
+ Message string `json:"message"`
+ }
+ if json.Unmarshal(response.Error, &detail) == nil && detail.Message != "" {
+ message := detail.Message
+ if c.APIKey != "" {
+ message = strings.ReplaceAll(message, c.APIKey, "[redacted]")
+ }
+ return s, fmt.Errorf("server returned an API error: %s", message)
+ }
+ return s, errors.New("server returned an API error")
+ }
+ if len(response.Choices) == 0 {
+ return s, errors.New("response contains no choices")
+ }
+ s.LatencySeconds = time.Since(start).Seconds()
+ s.PromptTokens = response.Usage.PromptTokens
+ s.CompletionTokens = response.Usage.CompletionTokens
+ if (s.PromptTokens != nil && *s.PromptTokens < 0) || (s.CompletionTokens != nil && *s.CompletionTokens < 0) {
+ return s, errors.New("response contains negative token counts")
+ }
+ return s, nil
+}
+
+func (r *modelResult) summarize() {
+ r.MinSeconds = r.Samples[0].LatencySeconds
+ var seconds, tokens float64
+ available := true
+ for _, s := range r.Samples {
+ seconds += s.LatencySeconds
+ r.MinSeconds = min(r.MinSeconds, s.LatencySeconds)
+ r.MaxSeconds = max(r.MaxSeconds, s.LatencySeconds)
+ if s.CompletionTokens == nil {
+ available = false
+ } else {
+ tokens += float64(*s.CompletionTokens)
+ }
+ }
+ r.MeanSeconds = seconds / float64(len(r.Samples))
+ if available && seconds > 0 {
+ rate := tokens / seconds
+ r.CompletionTokensPerSecond = &rate
+ }
+}
diff --git a/core/cli/benchmark/benchmark_test.go b/core/cli/benchmark/benchmark_test.go
new file mode 100644
index 000000000..fcd007f49
--- /dev/null
+++ b/core/cli/benchmark/benchmark_test.go
@@ -0,0 +1,242 @@
+// SPDX-License-Identifier: MIT
+package benchmark
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/alecthomas/kong"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestBenchmark(t *testing.T) { RegisterFailHandler(Fail); RunSpecs(t, "Benchmark") }
+
+var _ = Describe("Benchmark command", func() {
+ var cmd Command
+ var output bytes.Buffer
+ BeforeEach(func() {
+ cmd = Command{Models: []string{"a"}, Endpoint: "http://127.0.0.1:8080", Prompt: "hello", MaxTokens: 128, Runs: 2, Warmup: 1, Timeout: time.Second, JSON: true}
+ output.Reset()
+ })
+ It("parses required models and defaults", func() {
+ var c Command
+ parser, err := kong.New(&c)
+ Expect(err).NotTo(HaveOccurred())
+ _, err = parser.Parse(nil)
+ Expect(err).To(HaveOccurred())
+ _, err = parser.Parse([]string{"a", "b"})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(c.Models).To(Equal([]string{"a", "b"}))
+ Expect(c.Endpoint).To(Equal("http://127.0.0.1:8080"))
+ Expect(c.Runs).To(Equal(3))
+ Expect(c.Warmup).To(Equal(1))
+ Expect(c.MaxTokens).To(Equal(128))
+ Expect(c.Timeout).To(Equal(5 * time.Minute))
+ Expect(c.Prompt).NotTo(BeEmpty())
+ })
+ It("reads API key environment variables in priority order", func() {
+ for _, key := range []string{"LOCALAI_API_KEY", "API_KEY"} {
+ value, present := os.LookupEnv(key)
+ DeferCleanup(func() {
+ if present {
+ Expect(os.Setenv(key, value)).To(Succeed())
+ } else {
+ Expect(os.Unsetenv(key)).To(Succeed())
+ }
+ })
+ }
+ Expect(os.Unsetenv("LOCALAI_API_KEY")).To(Succeed())
+ Expect(os.Setenv("API_KEY", "fallback")).To(Succeed())
+ var c Command
+ parser, err := kong.New(&c)
+ Expect(err).NotTo(HaveOccurred())
+ _, err = parser.Parse([]string{"a"})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(c.APIKey).To(Equal("fallback"))
+ Expect(os.Setenv("LOCALAI_API_KEY", "preferred")).To(Succeed())
+ _, err = parser.Parse([]string{"a"})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(c.APIKey).To(Equal("preferred"))
+ })
+ DescribeTable("normalizes endpoints", func(input, expected string) {
+ actual, err := completionURL(input)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(actual).To(Equal(expected))
+ },
+ Entry("root", "http://localhost:8080", "http://localhost:8080/v1/chat/completions"), Entry("slash", "http://localhost:8080/", "http://localhost:8080/v1/chat/completions"), Entry("v1", "https://example.org/v1/", "https://example.org/v1/chat/completions"), Entry("proxy", "https://example.org/proxy/", "https://example.org/proxy/v1/chat/completions"), Entry("proxy v1", "https://example.org/proxy/v1", "https://example.org/proxy/v1/chat/completions"))
+ It("posts authenticated requests sequentially and excludes each model's warmup", func() {
+ var models []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer GinkgoRecover()
+ Expect(r.Method).To(Equal("POST"))
+ Expect(r.URL.Path).To(Equal("/proxy/v1/chat/completions"))
+ Expect(r.Header.Get("Authorization")).To(Equal("Bearer secret"))
+ Expect(r.Header.Get("Content-Type")).To(Equal("application/json"))
+ var body map[string]any
+ Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
+ Expect(body["temperature"]).To(Equal(float64(0)))
+ Expect(body["stream"]).To(BeFalse())
+ Expect(body["max_tokens"]).To(Equal(float64(128)))
+ Expect(body["messages"]).To(Equal([]any{map[string]any{"role": "user", "content": "hello"}}))
+ models = append(models, body["model"].(string))
+ _, _ = fmt.Fprintf(w, `{"choices":[{}],"usage":{"prompt_tokens":5,"completion_tokens":%d}}`, len(models))
+ }))
+ defer server.Close()
+ cmd.Endpoint = server.URL + "/proxy"
+ cmd.APIKey = "secret"
+ cmd.Models = []string{"a", "b"}
+ Expect(cmd.run(context.Background(), &output)).To(Succeed())
+ Expect(models).To(Equal([]string{"a", "a", "a", "b", "b", "b"}))
+ Expect(output.String()).NotTo(ContainSubstring("secret"))
+ var result report
+ Expect(json.Unmarshal(output.Bytes(), &result)).To(Succeed())
+ Expect(result.Results).To(HaveLen(2))
+ Expect(result.Settings.Runs).To(Equal(2))
+ Expect(result.Settings.Warmup).To(Equal(1))
+ Expect(result.Settings.Prompt).To(Equal("hello"))
+ Expect(result.Settings.Temperature).To(BeZero())
+ Expect(result.Settings.Stream).To(BeFalse())
+ first := result.Results[0]
+ Expect(first.Samples).To(HaveLen(2))
+ Expect(*first.Samples[0].CompletionTokens).To(Equal(2))
+ Expect(*first.Samples[1].CompletionTokens).To(Equal(3))
+ Expect(*first.Samples[0].PromptTokens).To(Equal(5))
+ Expect(first.MinSeconds).To(BeNumerically(">", 0))
+ Expect(first.MeanSeconds).To(BeNumerically(">=", first.MinSeconds))
+ Expect(first.MaxSeconds).To(BeNumerically(">=", first.MeanSeconds))
+ Expect(*first.CompletionTokensPerSecond).To(BeNumerically("~", 5/(first.Samples[0].LatencySeconds+first.Samples[1].LatencySeconds), 0.001))
+ })
+ DescribeTable("preserves missing and zero usage", func(usage string, available bool) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprint(w, `{"choices":[{}]`+usage+`}`) }))
+ defer server.Close()
+ cmd.Endpoint = server.URL
+ cmd.Warmup = 0
+ Expect(cmd.run(context.Background(), &output)).To(Succeed())
+ var result report
+ Expect(json.Unmarshal(output.Bytes(), &result)).To(Succeed())
+ if available {
+ Expect(*result.Results[0].CompletionTokensPerSecond).To(BeZero())
+ } else {
+ Expect(result.Results[0].CompletionTokensPerSecond).To(BeNil())
+ }
+ cmd.JSON = false
+ output.Reset()
+ Expect(cmd.run(context.Background(), &output)).To(Succeed())
+ if !available {
+ Expect(output.String()).To(ContainSubstring("N/A"))
+ }
+ }, Entry("absent", "", false), Entry("empty", `,"usage":{}`, false), Entry("partial", `,"usage":{"prompt_tokens":0}`, false), Entry("zero", `,"usage":{"prompt_tokens":0,"completion_tokens":0}`, true))
+ It("retains API error details while redacting the key", func() {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = fmt.Fprint(w, `{"error":{"message":"model unavailable: secret"}}`)
+ }))
+ defer server.Close()
+ cmd.Endpoint = server.URL
+ cmd.APIKey = "secret"
+ cmd.Warmup = 0
+ err := cmd.run(context.Background(), &output)
+ Expect(err).To(MatchError(ContainSubstring(`model "a" run 1: server returned an API error: model unavailable: [redacted]`)))
+ Expect(output.Len()).To(BeZero())
+ })
+ It("marks throughput unavailable when one measured request omits usage", func() {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if requests == 1 {
+ _, _ = fmt.Fprint(w, `{"choices":[{}],"usage":{"completion_tokens":2}}`)
+ } else {
+ _, _ = fmt.Fprint(w, `{"choices":[{}]}`)
+ }
+ }))
+ defer server.Close()
+ cmd.Endpoint = server.URL
+ cmd.Warmup = 0
+ Expect(cmd.run(context.Background(), &output)).To(Succeed())
+ var result report
+ Expect(json.Unmarshal(output.Bytes(), &result)).To(Succeed())
+ Expect(result.Results[0].CompletionTokensPerSecond).To(BeNil())
+ Expect(result.Results[0].Samples[1].CompletionTokens).To(BeNil())
+ })
+ DescribeTable("fails without result output", func(status int, body string) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status); _, _ = fmt.Fprint(w, body) }))
+ defer server.Close()
+ cmd.Endpoint = server.URL
+ cmd.APIKey = "secret"
+ err := cmd.run(context.Background(), &output)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring(`model "a" warmup 1`))
+ Expect(err.Error()).NotTo(ContainSubstring("secret"))
+ Expect(output.Len()).To(BeZero())
+ }, Entry("HTTP", 500, `secret`), Entry("API", 200, `{"error":{"message":"secret"}}`), Entry("JSON", 200, `invalid`), Entry("empty choices", 200, `{"choices":[]}`), Entry("trailing JSON", 200, `{"choices":[{}]} {}`), Entry("negative tokens", 200, `{"choices":[{}],"usage":{"completion_tokens":-1}}`))
+ It("refuses redirects", func() {
+ reached := false
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
+ defer target.Close()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
+ }))
+ defer server.Close()
+ cmd.Endpoint = server.URL
+ Expect(cmd.run(context.Background(), &output)).NotTo(Succeed())
+ Expect(reached).To(BeFalse())
+ Expect(output.Len()).To(BeZero())
+ })
+ It("honors cancellation", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err := cmd.run(ctx, &output)
+ Expect(err).To(MatchError(ContainSubstring("context canceled")))
+ Expect(output.Len()).To(BeZero())
+ })
+ It("times out requests", func() {
+ release := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release }))
+ defer server.Close()
+ defer close(release)
+ cmd.Endpoint = server.URL
+ cmd.Timeout = 20 * time.Millisecond
+ Expect(cmd.run(context.Background(), &output)).NotTo(Succeed())
+ Expect(output.Len()).To(BeZero())
+ })
+ It("times out while reading a response body", func() {
+ release := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = fmt.Fprint(w, `{"choices":[`)
+ w.(http.Flusher).Flush()
+ <-release
+ }))
+ defer server.Close()
+ defer close(release)
+ cmd.Endpoint = server.URL
+ cmd.Timeout = 20 * time.Millisecond
+ Expect(cmd.run(context.Background(), &output)).To(MatchError(ContainSubstring("request timed out")))
+ Expect(output.Len()).To(BeZero())
+ })
+ It("cancels an active request", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cancel() }))
+ defer server.Close()
+ cmd.Endpoint = server.URL
+ Expect(cmd.run(ctx, &output)).To(MatchError(ContainSubstring("context canceled")))
+ Expect(output.Len()).To(BeZero())
+ })
+ DescribeTable("rejects invalid inputs before requests", func(change func(*Command)) {
+ reached := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
+ defer server.Close()
+ cmd.Endpoint = server.URL
+ change(&cmd)
+ Expect(cmd.run(context.Background(), &output)).NotTo(Succeed())
+ Expect(reached).To(BeFalse())
+ Expect(output.Len()).To(BeZero())
+ }, Entry("runs", func(c *Command) { c.Runs = 0 }), Entry("warmup", func(c *Command) { c.Warmup = -1 }), Entry("tokens", func(c *Command) { c.MaxTokens = 0 }), Entry("timeout", func(c *Command) { c.Timeout = 0 }), Entry("prompt", func(c *Command) { c.Prompt = " " }), Entry("models", func(c *Command) { c.Models = nil }), Entry("blank model", func(c *Command) { c.Models = []string{"a", " "} }), Entry("scheme", func(c *Command) { c.Endpoint = "file:///tmp" }), Entry("host", func(c *Command) { c.Endpoint = "http:///v1" }), Entry("userinfo", func(c *Command) { c.Endpoint = "http://secret@localhost" }), Entry("query", func(c *Command) { c.Endpoint += "?secret" }), Entry("fragment", func(c *Command) { c.Endpoint += "#secret" }))
+})
diff --git a/core/cli/cli.go b/core/cli/cli.go
index 77bf128cc..2b17ff96d 100644
--- a/core/cli/cli.go
+++ b/core/cli/cli.go
@@ -1,12 +1,14 @@
package cli
import (
+ "github.com/mudler/LocalAI/core/cli/benchmark"
cliContext "github.com/mudler/LocalAI/core/cli/context"
"github.com/mudler/LocalAI/core/cli/worker"
)
var CLI struct {
cliContext.Context `embed:""`
+ Benchmark benchmark.Command `cmd:"" help:"Benchmark configured text models against a running LocalAI server"`
Run RunCMD `cmd:"" help:"Run LocalAI, this the default command if no other command is specified. Run 'local-ai run --help' for more information" default:"withargs"`
Chat ChatCMD `cmd:"" help:"Run the built-in terminal agent against a LocalAI server"`
diff --git a/core/cli/context/context.go b/core/cli/context/context.go
index c6d3f1176..752d39a6b 100644
--- a/core/cli/context/context.go
+++ b/core/cli/context/context.go
@@ -1,8 +1,9 @@
package cliContext
type Context struct {
- Debug bool `env:"LOCALAI_DEBUG,DEBUG" default:"false" hidden:"" help:"DEPRECATED, use --log-level=debug instead. Enable debug logging"`
- LogLevel *string `env:"LOCALAI_LOG_LEVEL" enum:"error,warn,info,debug,trace" help:"Set the level of logs to output [${enum}]"`
- LogFormat *string `env:"LOCALAI_LOG_FORMAT" default:"default" enum:"default,text,json" help:"Set the format of logs to output [${enum}]"`
- LogDedupLogs *bool `env:"LOCALAI_LOG_DEDUP" negatable:"" help:"Deduplicate consecutive identical log lines (auto-detected for terminals, use --log-dedup-logs to force on or --no-log-dedup-logs to force off)"`
+ Debug bool `env:"LOCALAI_DEBUG,DEBUG" default:"false" hidden:"" help:"DEPRECATED, use --log-level=debug instead. Enable debug logging"`
+ LogLevel *string `env:"LOCALAI_LOG_LEVEL" enum:"error,warn,info,debug,trace" help:"Set the level of logs to output [${enum}]"`
+ LogFormat *string `env:"LOCALAI_LOG_FORMAT" default:"default" enum:"default,text,json" help:"Set the format of logs to output [${enum}]"`
+ LogDedupLogs *bool `env:"LOCALAI_LOG_DEDUP" negatable:"" help:"Deduplicate consecutive identical log lines (auto-detected for terminals, use --log-dedup-logs to force on or --no-log-dedup-logs to force off)"`
+ CredentialsFile string `env:"LOCALAI_CREDENTIALS_FILE" help:"YAML file with credentials for private registries, galleries and download hosts (see https://localai.io/advanced/private-sources/)"`
}
diff --git a/core/cli/credentials.go b/core/cli/credentials.go
new file mode 100644
index 000000000..7afce1e42
--- /dev/null
+++ b/core/cli/credentials.go
@@ -0,0 +1,44 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/mudler/xlog"
+
+ "github.com/mudler/LocalAI/pkg/credentials"
+)
+
+// credentialsFileName is looked up in the data path when no flag is given, so
+// an install that keeps all its state on one volume needs no extra setting.
+const credentialsFileName = "credentials.yaml"
+
+func resolveCredentialsFile(flag, dataPath string) string {
+ if flag != "" {
+ return flag
+ }
+ if dataPath == "" {
+ return ""
+ }
+ p := filepath.Join(dataPath, credentialsFileName)
+ if _, err := os.Stat(p); err != nil {
+ return ""
+ }
+ return p
+}
+
+// LoadCredentials installs the download credentials for this process. An empty
+// path leaves downloads anonymous; registries still honor docker config.
+func LoadCredentials(path string) error {
+ if path == "" {
+ return nil
+ }
+ store, err := credentials.Load(path, os.LookupEnv)
+ if err != nil {
+ return fmt.Errorf("loading credentials file %q: %w", path, err)
+ }
+ credentials.SetDefault(store)
+ xlog.Info("Loaded download credentials", "file", path, "rules", store.Len())
+ return nil
+}
diff --git a/core/cli/credentials_test.go b/core/cli/credentials_test.go
new file mode 100644
index 000000000..99964eab9
--- /dev/null
+++ b/core/cli/credentials_test.go
@@ -0,0 +1,67 @@
+package cli
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/pkg/credentials"
+)
+
+var _ = Describe("credentials file", Serial, func() {
+ Describe("resolveCredentialsFile", func() {
+ It("prefers the flag", func() {
+ dataPath := GinkgoT().TempDir()
+ Expect(os.WriteFile(filepath.Join(dataPath, "credentials.yaml"), nil, 0o600)).To(Succeed())
+ Expect(resolveCredentialsFile("/etc/localai/creds.yaml", dataPath)).To(Equal("/etc/localai/creds.yaml"))
+ })
+
+ It("falls back to credentials.yaml in the data path when present", func() {
+ dataPath := GinkgoT().TempDir()
+ p := filepath.Join(dataPath, "credentials.yaml")
+ Expect(os.WriteFile(p, nil, 0o600)).To(Succeed())
+ Expect(resolveCredentialsFile("", dataPath)).To(Equal(p))
+ })
+
+ It("returns nothing when neither is set", func() {
+ Expect(resolveCredentialsFile("", GinkgoT().TempDir())).To(BeEmpty())
+ Expect(resolveCredentialsFile("", "")).To(BeEmpty())
+ })
+ })
+
+ Describe("LoadCredentials", func() {
+ BeforeEach(func() {
+ prev := credentials.SetDefault(nil)
+ DeferCleanup(func() { credentials.SetDefault(prev) })
+ })
+
+ It("installs the store and resolves _env from the process environment", func() {
+ GinkgoT().Setenv("LOCALAI_TEST_REGISTRY_TOKEN", "from-env")
+ p := filepath.Join(GinkgoT().TempDir(), "credentials.yaml")
+ Expect(os.WriteFile(p, []byte("- match: ghcr.io/acme\n bearer_env: LOCALAI_TEST_REGISTRY_TOKEN\n"), 0o600)).To(Succeed())
+
+ Expect(LoadCredentials(p)).To(Succeed())
+ Expect(credentials.Default().Len()).To(Equal(1))
+ c, ok := credentials.Default().Match("https://ghcr.io/acme/img")
+ Expect(ok).To(BeTrue())
+ h := http.Header{}
+ Expect(c.ApplyHeaders(h)).To(Succeed())
+ Expect(h.Get("Authorization")).To(Equal("Bearer from-env"))
+ })
+
+ It("fails on an invalid file and names it", func() {
+ p := filepath.Join(GinkgoT().TempDir(), "credentials.yaml")
+ Expect(os.WriteFile(p, []byte("- match: ghcr.io\n"), 0o600)).To(Succeed())
+ Expect(LoadCredentials(p)).To(MatchError(ContainSubstring(p)))
+ Expect(credentials.Default()).To(BeNil())
+ })
+
+ It("does nothing for an empty path", func() {
+ Expect(LoadCredentials("")).To(Succeed())
+ Expect(credentials.Default()).To(BeNil())
+ })
+ })
+})
diff --git a/core/cli/run.go b/core/cli/run.go
index 6bf377682..9e663687c 100644
--- a/core/cli/run.go
+++ b/core/cli/run.go
@@ -250,6 +250,12 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
return nil
}
+ if ctx.CredentialsFile == "" {
+ if err := LoadCredentials(resolveCredentialsFile("", r.DataPath)); err != nil {
+ return err
+ }
+ }
+
activatedListeners, err := systemdActivatedListeners()
if err != nil {
return fmt.Errorf("loading systemd socket activation listeners: %w", err)
diff --git a/core/config/backend_capabilities.go b/core/config/backend_capabilities.go
index e9934b381..e97390645 100644
--- a/core/config/backend_capabilities.go
+++ b/core/config/backend_capabilities.go
@@ -19,6 +19,7 @@ const (
UsecaseImage = "image"
UsecaseVideo = "video"
Usecase3D = "3d"
+ Usecase3DAnimation = "3d_animation"
UsecaseTranscript = "transcript"
UsecaseTTS = "tts"
UsecaseSoundGeneration = "sound_generation"
@@ -47,6 +48,7 @@ const (
MethodUpscaleImage GRPCMethod = "UpscaleImage"
MethodGenerateVideo GRPCMethod = "GenerateVideo"
MethodGenerate3D GRPCMethod = "Generate3D"
+ MethodAnimate3D GRPCMethod = "Animate3D"
MethodAudioTranscription GRPCMethod = "AudioTranscription"
MethodTTS GRPCMethod = "TTS"
MethodTTSStream GRPCMethod = "TTSStream"
@@ -134,6 +136,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
GRPCMethod: MethodGenerate3D,
Description: "Image-conditioned 3D asset generation via the Generate3D RPC — a binary glTF (GLB) mesh with optional PBR material (TRELLIS.2).",
},
+ Usecase3DAnimation: {
+ Flag: FLAG_3D_ANIMATION,
+ GRPCMethod: MethodAnimate3D,
+ Description: "3D animation with model-specific conditioning inputs, exported as binary glTF (GLB).",
+ },
UsecaseTranscript: {
Flag: FLAG_TRANSCRIPT,
GRPCMethod: MethodAudioTranscription,
@@ -421,6 +428,12 @@ var BackendCapabilities = map[string]BackendCapability{
},
// --- 3D generation backends ---
+ "kimodocpp": {
+ GRPCMethods: []GRPCMethod{MethodAnimate3D},
+ PossibleUsecases: []string{Usecase3DAnimation},
+ DefaultUsecases: []string{Usecase3DAnimation},
+ Description: "kimodo.cpp — text-to-motion on CPU/Vulkan, exported as animated skeleton GLB",
+ },
"trellis2cpp": {
GRPCMethods: []GRPCMethod{MethodGenerate3D},
PossibleUsecases: []string{Usecase3D},
diff --git a/core/config/model_3d.go b/core/config/model_3d.go
new file mode 100644
index 000000000..5cbcd9090
--- /dev/null
+++ b/core/config/model_3d.go
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: MIT
+package config
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/schema"
+)
+
+// ThreeDOperations is shared by discovery and request validation so the form
+// cannot advertise inputs or parameters that its selected backend rejects.
+func (c *ModelConfig) ThreeDOperations() []schema.ThreeDOperation {
+ switch c.Backend {
+ case "kimodocpp":
+ if !c.HasUsecases(FLAG_3D_ANIMATION) {
+ return nil
+ }
+ params := []schema.ThreeDParameter{
+ {Name: "frames", Label: "Frames (30 FPS)", Type: "integer", Default: "150", Min: 60, Max: 150},
+ {Name: "steps", Label: "Sampling steps", Type: "integer", Default: "100", Min: 1, Max: 1000, Advanced: true},
+ {Name: "text_guidance", Label: "Text guidance", Type: "number", Default: "2", Min: 0, Max: 100, Advanced: true},
+ {Name: "seed", Label: "Seed", Type: "uint64", Default: "0", Advanced: true},
+ }
+ for i := range params {
+ for _, option := range c.Options {
+ if key, value, ok := strings.Cut(option, ":"); ok && key == params[i].Name {
+ params[i].Default = value
+ }
+ }
+ }
+ return []schema.ThreeDOperation{{
+ ID: "animate", Label: "Animate", Endpoint: "/3d/animate", Output: "skeleton_animation",
+ Inputs: []schema.ThreeDInput{{Name: "prompt", Type: "text", Label: "Motion prompt", Required: true, MaxBytes: 4096}},
+ Parameters: params,
+ }}
+ case "trellis2cpp":
+ if !c.HasUsecases(FLAG_3D) {
+ return nil
+ }
+ steps, guidance := "12", "7.5"
+ if c.Step > 0 {
+ steps = strconv.Itoa(c.Step)
+ }
+ if c.CFGScale > 0 {
+ guidance = strconv.FormatFloat(float64(c.CFGScale), 'g', -1, 32)
+ }
+ return []schema.ThreeDOperation{
+ {ID: "generate", Label: "Generate mesh", Endpoint: "/3d/generations", Output: "mesh",
+ Inputs: []schema.ThreeDInput{{Name: "image", Type: "image", Label: "Conditioning image", Required: true}},
+ Parameters: []schema.ThreeDParameter{
+ {Name: "quality", Label: "Quality", Type: "enum", Default: "auto", Options: []string{"auto", "coarse", "512", "1024"}},
+ {Name: "background", Label: "Background", Type: "enum", Default: "auto", Options: []string{"auto", "keep", "black", "white"}},
+ {Name: "step", Label: "Sampling steps", Type: "integer", Default: steps, Min: 1, Advanced: true},
+ {Name: "texture_steps", Label: "Texture steps", Type: "integer", Default: "12", Min: 1, Advanced: true},
+ {Name: "cfg_scale", Label: "Guidance", Type: "number", Default: guidance, Min: 0, Advanced: true},
+ {Name: "seed", Label: "Seed", Type: "integer", Min: 0, Max: 2147483647, Advanced: true},
+ }},
+ {ID: "remesh", Label: "Remesh", Endpoint: "/3d/remesh", Output: "mesh",
+ Inputs: []schema.ThreeDInput{{Name: "mesh", Type: "mesh", Label: "Source mesh", Required: true}},
+ Parameters: []schema.ThreeDParameter{{Name: "detail", Label: "Detail (%)", Type: "number", Default: "0.5", Min: 0.35, Max: 2.5}}},
+ }
+ }
+ return nil
+}
diff --git a/core/config/model_3d_test.go b/core/config/model_3d_test.go
new file mode 100644
index 000000000..97f52f1c0
--- /dev/null
+++ b/core/config/model_3d_test.go
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: MIT
+package config
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("3D animation capabilities", func() {
+ It("keeps animation distinct from image-conditioned mesh generation", func() {
+ cfg := &ModelConfig{Backend: "kimodocpp"}
+ Expect(cfg.HasUsecases(FLAG_3D_ANIMATION)).To(BeTrue())
+ Expect(cfg.HasUsecases(FLAG_3D)).To(BeFalse())
+ Expect(cfg.Capabilities()).To(ContainElement("3d_animation"))
+ Expect(cfg.InputModalities()).To(Equal([]string{"text"}))
+ Expect(cfg.OutputModalities()).To(Equal([]string{"3d"}))
+ Expect((&ModelConfig{Backend: "llama-cpp"}).HasUsecases(FLAG_3D_ANIMATION)).To(BeFalse())
+ })
+ It("advertises effective model defaults without sharing mutable descriptors", func() {
+ cfg := &ModelConfig{Backend: "kimodocpp", Options: []string{"steps:50"}}
+ operations := cfg.ThreeDOperations()
+ Expect(operations).To(HaveLen(1))
+ Expect(operations[0].Endpoint).To(Equal("/3d/animate"))
+ Expect(operations[0].Parameters[1].Default).To(Equal("50"))
+ operations[0].Parameters[1].Default = "1"
+ Expect(cfg.ThreeDOperations()[0].Parameters[1].Default).To(Equal("50"))
+ Expect((&ModelConfig{Backend: "trellis2cpp"}).ThreeDOperations()).To(HaveLen(2))
+ })
+ It("rejects invalid configured sampling defaults", func() {
+ cfg := &ModelConfig{Backend: "kimodocpp", Options: []string{"steps:NaN"}}
+ _, err := cfg.Validate()
+ Expect(err).To(MatchError(ContainSubstring("default")))
+ })
+})
diff --git a/core/config/model_capabilities.go b/core/config/model_capabilities.go
index 43981304a..2be83e0af 100644
--- a/core/config/model_capabilities.go
+++ b/core/config/model_capabilities.go
@@ -162,6 +162,7 @@ func (c *ModelConfig) Capabilities() []string {
add(c.HasUsecases(FLAG_IMAGE), UsecaseImage)
add(c.HasUsecases(FLAG_VIDEO), UsecaseVideo)
add(c.HasUsecases(FLAG_3D), Usecase3D)
+ add(c.HasUsecases(FLAG_3D_ANIMATION), Usecase3DAnimation)
add(c.HasUsecases(FLAG_VAD), UsecaseVAD)
add(c.HasUsecases(FLAG_DETECTION), UsecaseDetection)
add(c.HasUsecases(FLAG_DEPTH), UsecaseDepth)
@@ -180,6 +181,18 @@ func (c *ModelConfig) Capabilities() []string {
// handed to the active model directly.
func (c *ModelConfig) InputModalities() []string {
modalities := declaredModalities(c.KnownInputModalities)
+ for _, operation := range c.ThreeDOperations() {
+ if operation.ID != "animate" {
+ continue
+ }
+ for _, input := range operation.Inputs {
+ modality := input.Type
+ if modality == "mesh" {
+ modality = Modality3D
+ }
+ modalities[modality] = true
+ }
+ }
imageGen := c.HasUsecases(FLAG_IMAGE)
videoGen := c.HasUsecases(FLAG_VIDEO)
chatish := c.HasUsecases(FLAG_CHAT) || c.HasUsecases(FLAG_COMPLETION)
@@ -217,7 +230,7 @@ func (c *ModelConfig) OutputModalities() []string {
audioOut := c.HasUsecases(FLAG_TTS) || c.HasUsecases(FLAG_SOUND_GENERATION) ||
c.HasUsecases(FLAG_AUDIO_TRANSFORM) || c.HasUsecases(FLAG_REALTIME_AUDIO)
videoOut := c.HasUsecases(FLAG_VIDEO)
- threeDOut := c.HasUsecases(FLAG_3D)
+ threeDOut := c.HasUsecases(FLAG_3D) || c.HasUsecases(FLAG_3D_ANIMATION)
modalities[ModalityText] = modalities[ModalityText] || textOut
modalities[ModalityImage] = modalities[ModalityImage] || imageOut
diff --git a/core/config/model_config.go b/core/config/model_config.go
index 5d63cf2dd..9754ef6ac 100644
--- a/core/config/model_config.go
+++ b/core/config/model_config.go
@@ -1591,6 +1591,15 @@ func (cfg *ModelConfig) SetDefaults(opts ...ConfigLoaderOption) {
}
func (c *ModelConfig) Validate() (bool, error) {
+ for _, operation := range c.ThreeDOperations() {
+ for _, parameter := range operation.Parameters {
+ if parameter.Default != "" {
+ if err := parameter.Validate(parameter.Default); err != nil {
+ return false, fmt.Errorf("%s default: %w", operation.ID, err)
+ }
+ }
+ }
+ }
if c.Compression.Enabled {
if c.IsCloudProxyBackendPassthrough() {
return false, fmt.Errorf("compression: cloud-proxy passthrough is unsupported; configure proxy mode translate")
@@ -1987,7 +1996,8 @@ const (
// Marks a model as wired for the Generate3D gRPC primitive
// (image-conditioned 3D asset generation — a binary glTF mesh with
// optional PBR material, e.g. trellis2cpp).
- FLAG_3D ModelConfigUsecase = 0b100000000000000000000000
+ FLAG_3D ModelConfigUsecase = 0b100000000000000000000000
+ FLAG_3D_ANIMATION ModelConfigUsecase = 1 << 24
// Common Subsets
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
@@ -2002,7 +2012,7 @@ var ModalityGroups = []ModelConfigUsecase{
FLAG_TRANSCRIPT | FLAG_REALTIME_AUDIO | FLAG_SOUND_CLASSIFICATION, // audio input — realtime_audio is any-to-any, so it counts here too
FLAG_TTS | FLAG_SOUND_GENERATION | FLAG_REALTIME_AUDIO, // audio output — and here, so a lone realtime_audio flag still reads as multimodal
FLAG_AUDIO_TRANSFORM, // audio in/out transforms
- FLAG_IMAGE | FLAG_VIDEO | FLAG_3D, // visual generation
+ FLAG_IMAGE | FLAG_VIDEO | FLAG_3D | FLAG_3D_ANIMATION, // visual generation
}
// IsMultimodal returns true if the given usecases span two or more orthogonal
@@ -2050,6 +2060,7 @@ func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
"FLAG_DEPTH": FLAG_DEPTH,
"FLAG_TOKEN_CLASSIFY": FLAG_TOKEN_CLASSIFY,
"FLAG_3D": FLAG_3D,
+ "FLAG_3D_ANIMATION": FLAG_3D_ANIMATION,
}
}
@@ -2221,6 +2232,9 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
return false
}
}
+ if (u&FLAG_3D_ANIMATION) == FLAG_3D_ANIMATION && c.Backend != "kimodocpp" {
+ return false
+ }
if (u & FLAG_FACE_RECOGNITION) == FLAG_FACE_RECOGNITION {
faceBackends := []string{"insightface"}
diff --git a/core/gallery/importers/importers.go b/core/gallery/importers/importers.go
index 3fe0f107f..59ecf4fcc 100644
--- a/core/gallery/importers/importers.go
+++ b/core/gallery/importers/importers.go
@@ -149,6 +149,7 @@ var defaultImporters = []Importer{
// generic .gguf importer; matches only trellis-named URIs/repos or the
// distinctive component filenames, so arbitrary GGUFs are never claimed.
&Trellis2CppImporter{},
+ &KimodoCppImporter{},
&ACEStepImporter{},
// LongCat repositories carry generic Diffusers metadata, so this exact
// owner/repo matcher must run before DiffuserImporter.
diff --git a/core/gallery/importers/kimodo_models.json b/core/gallery/importers/kimodo_models.json
new file mode 100644
index 000000000..b0ec5ff1b
--- /dev/null
+++ b/core/gallery/importers/kimodo_models.json
@@ -0,0 +1,602 @@
+[
+ {
+ "name": "kimodo-soma-rp",
+ "label": "SOMA RP v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML/resolve/65f8b8ab34ee7524161cc87b20ae4cb4f5d3a12d/models/kimodo-soma-rp-v1.1-f32.gguf",
+ "sha256": "3bf1229f4c1eff1d28f5196a854113da2df9a11a5e21c60694630903bf948ee4"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q8_0.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q8_0.gguf",
+ "sha256": "b26d0e74b115b33a7f2df5aca04426548365e99217cd590986f8abc4172e4c5c"
+ }
+ ],
+ "text_quantization": "q8_0",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q8_0.gguf"
+ },
+ {
+ "name": "kimodo-soma-rp-q6_k",
+ "label": "SOMA RP v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML/resolve/65f8b8ab34ee7524161cc87b20ae4cb4f5d3a12d/models/kimodo-soma-rp-v1.1-f32.gguf",
+ "sha256": "3bf1229f4c1eff1d28f5196a854113da2df9a11a5e21c60694630903bf948ee4"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q6_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q6_K.gguf",
+ "sha256": "656b790e44f90f27d746c48342aee066e3cf078db0322f98667c1a89be3ad7a2"
+ }
+ ],
+ "text_quantization": "q6_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q6_K.gguf"
+ },
+ {
+ "name": "kimodo-soma-rp-q5_k",
+ "label": "SOMA RP v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML/resolve/65f8b8ab34ee7524161cc87b20ae4cb4f5d3a12d/models/kimodo-soma-rp-v1.1-f32.gguf",
+ "sha256": "3bf1229f4c1eff1d28f5196a854113da2df9a11a5e21c60694630903bf948ee4"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q5_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q5_K.gguf",
+ "sha256": "7b4d8524d2a9298d4087a684c0ea2042f8d6e86881bc7ecdb99fee920a784211"
+ }
+ ],
+ "text_quantization": "q5_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q5_K.gguf"
+ },
+ {
+ "name": "kimodo-soma-rp-q4_k_m",
+ "label": "SOMA RP v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML/resolve/65f8b8ab34ee7524161cc87b20ae4cb4f5d3a12d/models/kimodo-soma-rp-v1.1-f32.gguf",
+ "sha256": "3bf1229f4c1eff1d28f5196a854113da2df9a11a5e21c60694630903bf948ee4"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q4_K_M.gguf",
+ "sha256": "c06f2cb6a7615e949bcbb5b582ad35a191338b68b5a51abe17d259ebe574c005"
+ }
+ ],
+ "text_quantization": "q4_k_m",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf"
+ },
+ {
+ "name": "kimodo-soma-rp-q4_k",
+ "label": "SOMA RP v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML/resolve/65f8b8ab34ee7524161cc87b20ae4cb4f5d3a12d/models/kimodo-soma-rp-v1.1-f32.gguf",
+ "sha256": "3bf1229f4c1eff1d28f5196a854113da2df9a11a5e21c60694630903bf948ee4"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q4_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q4_K.gguf",
+ "sha256": "b47f3d57c72660df7faa18338749be945e725032f1db467fe3cabf580a80e012"
+ }
+ ],
+ "text_quantization": "q4_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q4_K.gguf"
+ },
+ {
+ "name": "kimodo-soma-rp-bf16",
+ "label": "SOMA RP v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-rp-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-RP-v1.1-GGML/resolve/65f8b8ab34ee7524161cc87b20ae4cb4f5d3a12d/models/kimodo-soma-rp-v1.1-f32.gguf",
+ "sha256": "3bf1229f4c1eff1d28f5196a854113da2df9a11a5e21c60694630903bf948ee4"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-BF16.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-BF16.gguf",
+ "sha256": "d9a60017b3981bac874c4d118fc7e34f05b41763a12f0c0c7ee1e3b84eebb20f"
+ }
+ ],
+ "text_quantization": "bf16",
+ "text_model": "kimodo/text/Llama-3-Kimodo-BF16.gguf"
+ },
+ {
+ "name": "kimodo-soma-seed",
+ "label": "SOMA SEED v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML/resolve/4d88f95fb21a1ff00001b495a330fbaab05cf7bb/models/kimodo-soma-seed-v1.1-f32.gguf",
+ "sha256": "14395a62d9c52f40fc63574e62395613760678ecfd0fc136fc73f57f1ff723dc"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q8_0.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q8_0.gguf",
+ "sha256": "b26d0e74b115b33a7f2df5aca04426548365e99217cd590986f8abc4172e4c5c"
+ }
+ ],
+ "text_quantization": "q8_0",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q8_0.gguf"
+ },
+ {
+ "name": "kimodo-soma-seed-q6_k",
+ "label": "SOMA SEED v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML/resolve/4d88f95fb21a1ff00001b495a330fbaab05cf7bb/models/kimodo-soma-seed-v1.1-f32.gguf",
+ "sha256": "14395a62d9c52f40fc63574e62395613760678ecfd0fc136fc73f57f1ff723dc"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q6_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q6_K.gguf",
+ "sha256": "656b790e44f90f27d746c48342aee066e3cf078db0322f98667c1a89be3ad7a2"
+ }
+ ],
+ "text_quantization": "q6_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q6_K.gguf"
+ },
+ {
+ "name": "kimodo-soma-seed-q5_k",
+ "label": "SOMA SEED v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML/resolve/4d88f95fb21a1ff00001b495a330fbaab05cf7bb/models/kimodo-soma-seed-v1.1-f32.gguf",
+ "sha256": "14395a62d9c52f40fc63574e62395613760678ecfd0fc136fc73f57f1ff723dc"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q5_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q5_K.gguf",
+ "sha256": "7b4d8524d2a9298d4087a684c0ea2042f8d6e86881bc7ecdb99fee920a784211"
+ }
+ ],
+ "text_quantization": "q5_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q5_K.gguf"
+ },
+ {
+ "name": "kimodo-soma-seed-q4_k_m",
+ "label": "SOMA SEED v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML/resolve/4d88f95fb21a1ff00001b495a330fbaab05cf7bb/models/kimodo-soma-seed-v1.1-f32.gguf",
+ "sha256": "14395a62d9c52f40fc63574e62395613760678ecfd0fc136fc73f57f1ff723dc"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q4_K_M.gguf",
+ "sha256": "c06f2cb6a7615e949bcbb5b582ad35a191338b68b5a51abe17d259ebe574c005"
+ }
+ ],
+ "text_quantization": "q4_k_m",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf"
+ },
+ {
+ "name": "kimodo-soma-seed-q4_k",
+ "label": "SOMA SEED v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML/resolve/4d88f95fb21a1ff00001b495a330fbaab05cf7bb/models/kimodo-soma-seed-v1.1-f32.gguf",
+ "sha256": "14395a62d9c52f40fc63574e62395613760678ecfd0fc136fc73f57f1ff723dc"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q4_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q4_K.gguf",
+ "sha256": "b47f3d57c72660df7faa18338749be945e725032f1db467fe3cabf580a80e012"
+ }
+ ],
+ "text_quantization": "q4_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q4_K.gguf"
+ },
+ {
+ "name": "kimodo-soma-seed-bf16",
+ "label": "SOMA SEED v1.1",
+ "repository": "LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML",
+ "model": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-soma-seed-v1.1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-SOMA-SEED-v1.1-GGML/resolve/4d88f95fb21a1ff00001b495a330fbaab05cf7bb/models/kimodo-soma-seed-v1.1-f32.gguf",
+ "sha256": "14395a62d9c52f40fc63574e62395613760678ecfd0fc136fc73f57f1ff723dc"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-BF16.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-BF16.gguf",
+ "sha256": "d9a60017b3981bac874c4d118fc7e34f05b41763a12f0c0c7ee1e3b84eebb20f"
+ }
+ ],
+ "text_quantization": "bf16",
+ "text_model": "kimodo/text/Llama-3-Kimodo-BF16.gguf"
+ },
+ {
+ "name": "kimodo-g1-rp",
+ "label": "G1 RP v1",
+ "repository": "LocalAI-io/Kimodo-G1-RP-v1-GGML",
+ "model": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML/resolve/b99647a706bcb60f4e0454be714af453448b223d/models/kimodo-g1-rp-v1-f32.gguf",
+ "sha256": "720c0c5fc33c76d6e7a39bc667af7b57c170d4c78ba509ae4df86e383c8985cb"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q8_0.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q8_0.gguf",
+ "sha256": "b26d0e74b115b33a7f2df5aca04426548365e99217cd590986f8abc4172e4c5c"
+ }
+ ],
+ "text_quantization": "q8_0",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q8_0.gguf"
+ },
+ {
+ "name": "kimodo-g1-rp-q6_k",
+ "label": "G1 RP v1",
+ "repository": "LocalAI-io/Kimodo-G1-RP-v1-GGML",
+ "model": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML/resolve/b99647a706bcb60f4e0454be714af453448b223d/models/kimodo-g1-rp-v1-f32.gguf",
+ "sha256": "720c0c5fc33c76d6e7a39bc667af7b57c170d4c78ba509ae4df86e383c8985cb"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q6_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q6_K.gguf",
+ "sha256": "656b790e44f90f27d746c48342aee066e3cf078db0322f98667c1a89be3ad7a2"
+ }
+ ],
+ "text_quantization": "q6_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q6_K.gguf"
+ },
+ {
+ "name": "kimodo-g1-rp-q5_k",
+ "label": "G1 RP v1",
+ "repository": "LocalAI-io/Kimodo-G1-RP-v1-GGML",
+ "model": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML/resolve/b99647a706bcb60f4e0454be714af453448b223d/models/kimodo-g1-rp-v1-f32.gguf",
+ "sha256": "720c0c5fc33c76d6e7a39bc667af7b57c170d4c78ba509ae4df86e383c8985cb"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q5_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q5_K.gguf",
+ "sha256": "7b4d8524d2a9298d4087a684c0ea2042f8d6e86881bc7ecdb99fee920a784211"
+ }
+ ],
+ "text_quantization": "q5_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q5_K.gguf"
+ },
+ {
+ "name": "kimodo-g1-rp-q4_k_m",
+ "label": "G1 RP v1",
+ "repository": "LocalAI-io/Kimodo-G1-RP-v1-GGML",
+ "model": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML/resolve/b99647a706bcb60f4e0454be714af453448b223d/models/kimodo-g1-rp-v1-f32.gguf",
+ "sha256": "720c0c5fc33c76d6e7a39bc667af7b57c170d4c78ba509ae4df86e383c8985cb"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q4_K_M.gguf",
+ "sha256": "c06f2cb6a7615e949bcbb5b582ad35a191338b68b5a51abe17d259ebe574c005"
+ }
+ ],
+ "text_quantization": "q4_k_m",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf"
+ },
+ {
+ "name": "kimodo-g1-rp-q4_k",
+ "label": "G1 RP v1",
+ "repository": "LocalAI-io/Kimodo-G1-RP-v1-GGML",
+ "model": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML/resolve/b99647a706bcb60f4e0454be714af453448b223d/models/kimodo-g1-rp-v1-f32.gguf",
+ "sha256": "720c0c5fc33c76d6e7a39bc667af7b57c170d4c78ba509ae4df86e383c8985cb"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q4_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q4_K.gguf",
+ "sha256": "b47f3d57c72660df7faa18338749be945e725032f1db467fe3cabf580a80e012"
+ }
+ ],
+ "text_quantization": "q4_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q4_K.gguf"
+ },
+ {
+ "name": "kimodo-g1-rp-bf16",
+ "label": "G1 RP v1",
+ "repository": "LocalAI-io/Kimodo-G1-RP-v1-GGML",
+ "model": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-rp-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML/resolve/b99647a706bcb60f4e0454be714af453448b223d/models/kimodo-g1-rp-v1-f32.gguf",
+ "sha256": "720c0c5fc33c76d6e7a39bc667af7b57c170d4c78ba509ae4df86e383c8985cb"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-BF16.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-BF16.gguf",
+ "sha256": "d9a60017b3981bac874c4d118fc7e34f05b41763a12f0c0c7ee1e3b84eebb20f"
+ }
+ ],
+ "text_quantization": "bf16",
+ "text_model": "kimodo/text/Llama-3-Kimodo-BF16.gguf"
+ },
+ {
+ "name": "kimodo-g1-seed",
+ "label": "G1 SEED v1",
+ "repository": "LocalAI-io/Kimodo-G1-SEED-v1-GGML",
+ "model": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-SEED-v1-GGML/resolve/a165fb253b93ce11541be1a546f4a75af1837e38/models/kimodo-g1-seed-v1-f32.gguf",
+ "sha256": "6740369ac6aa967e91cc0f526b31a7f93d5a215b537f987b035edc8bb58a4cbf"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q8_0.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q8_0.gguf",
+ "sha256": "b26d0e74b115b33a7f2df5aca04426548365e99217cd590986f8abc4172e4c5c"
+ }
+ ],
+ "text_quantization": "q8_0",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q8_0.gguf"
+ },
+ {
+ "name": "kimodo-g1-seed-q6_k",
+ "label": "G1 SEED v1",
+ "repository": "LocalAI-io/Kimodo-G1-SEED-v1-GGML",
+ "model": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-SEED-v1-GGML/resolve/a165fb253b93ce11541be1a546f4a75af1837e38/models/kimodo-g1-seed-v1-f32.gguf",
+ "sha256": "6740369ac6aa967e91cc0f526b31a7f93d5a215b537f987b035edc8bb58a4cbf"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q6_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q6_K.gguf",
+ "sha256": "656b790e44f90f27d746c48342aee066e3cf078db0322f98667c1a89be3ad7a2"
+ }
+ ],
+ "text_quantization": "q6_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q6_K.gguf"
+ },
+ {
+ "name": "kimodo-g1-seed-q5_k",
+ "label": "G1 SEED v1",
+ "repository": "LocalAI-io/Kimodo-G1-SEED-v1-GGML",
+ "model": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-SEED-v1-GGML/resolve/a165fb253b93ce11541be1a546f4a75af1837e38/models/kimodo-g1-seed-v1-f32.gguf",
+ "sha256": "6740369ac6aa967e91cc0f526b31a7f93d5a215b537f987b035edc8bb58a4cbf"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q5_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q5_K.gguf",
+ "sha256": "7b4d8524d2a9298d4087a684c0ea2042f8d6e86881bc7ecdb99fee920a784211"
+ }
+ ],
+ "text_quantization": "q5_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q5_K.gguf"
+ },
+ {
+ "name": "kimodo-g1-seed-q4_k_m",
+ "label": "G1 SEED v1",
+ "repository": "LocalAI-io/Kimodo-G1-SEED-v1-GGML",
+ "model": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-SEED-v1-GGML/resolve/a165fb253b93ce11541be1a546f4a75af1837e38/models/kimodo-g1-seed-v1-f32.gguf",
+ "sha256": "6740369ac6aa967e91cc0f526b31a7f93d5a215b537f987b035edc8bb58a4cbf"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q4_K_M.gguf",
+ "sha256": "c06f2cb6a7615e949bcbb5b582ad35a191338b68b5a51abe17d259ebe574c005"
+ }
+ ],
+ "text_quantization": "q4_k_m",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf"
+ },
+ {
+ "name": "kimodo-g1-seed-q4_k",
+ "label": "G1 SEED v1",
+ "repository": "LocalAI-io/Kimodo-G1-SEED-v1-GGML",
+ "model": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-SEED-v1-GGML/resolve/a165fb253b93ce11541be1a546f4a75af1837e38/models/kimodo-g1-seed-v1-f32.gguf",
+ "sha256": "6740369ac6aa967e91cc0f526b31a7f93d5a215b537f987b035edc8bb58a4cbf"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-Q4_K.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-Q4_K.gguf",
+ "sha256": "b47f3d57c72660df7faa18338749be945e725032f1db467fe3cabf580a80e012"
+ }
+ ],
+ "text_quantization": "q4_k",
+ "text_model": "kimodo/text/Llama-3-Kimodo-Q4_K.gguf"
+ },
+ {
+ "name": "kimodo-g1-seed-bf16",
+ "label": "G1 SEED v1",
+ "repository": "LocalAI-io/Kimodo-G1-SEED-v1-GGML",
+ "model": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "files": [
+ {
+ "filename": "kimodo/kimodo-g1-seed-v1-f32.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Kimodo-G1-SEED-v1-GGML/resolve/a165fb253b93ce11541be1a546f4a75af1837e38/models/kimodo-g1-seed-v1-f32.gguf",
+ "sha256": "6740369ac6aa967e91cc0f526b31a7f93d5a215b537f987b035edc8bb58a4cbf"
+ },
+ {
+ "filename": "kimodo/text/tokenizer.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/tokenizer.gguf",
+ "sha256": "81614aca62a98846c02b72cc2e5378e5bdce8b4d507b88f96eb1faa90dae607e"
+ },
+ {
+ "filename": "kimodo/text/Llama-3-Kimodo-BF16.gguf",
+ "uri": "https://huggingface.co/LocalAI-io/Llama-3-Kimodo-GGML/resolve/3e8d958803beaddb6011ac534f2be972e2710c7d/Llama-3-Kimodo-BF16.gguf",
+ "sha256": "d9a60017b3981bac874c4d118fc7e34f05b41763a12f0c0c7ee1e3b84eebb20f"
+ }
+ ],
+ "text_quantization": "bf16",
+ "text_model": "kimodo/text/Llama-3-Kimodo-BF16.gguf"
+ }
+]
diff --git a/core/gallery/importers/kimodocpp.go b/core/gallery/importers/kimodocpp.go
new file mode 100644
index 000000000..2dfc31a99
--- /dev/null
+++ b/core/gallery/importers/kimodocpp.go
@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: MIT
+package importers
+
+import (
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/gallery"
+ "github.com/mudler/LocalAI/core/schema"
+ "go.yaml.in/yaml/v2"
+)
+
+// Pinned published manifests, including the text bundle shared by all motion
+// models. Keep the gallery entries and this import inventory in sync.
+//
+//go:embed kimodo_models.json
+var kimodoModelsJSON []byte
+
+type kimodoModel struct {
+ Name string `json:"name"`
+ Label string `json:"label"`
+ Repository string `json:"repository"`
+ Model string `json:"model"`
+ TextQuantization string `json:"text_quantization"`
+ TextModel string `json:"text_model"`
+ Files []gallery.File `json:"files"`
+}
+
+type KimodoCppImporter struct{}
+
+func (*KimodoCppImporter) Name() string { return "kimodocpp" }
+func (*KimodoCppImporter) Modality() string { return "3d_animation" }
+func (*KimodoCppImporter) AutoDetects() bool { return true }
+
+func kimodoImportModel(uri, quantization string) (kimodoModel, bool) {
+ var models []kimodoModel
+ if err := json.Unmarshal(kimodoModelsJSON, &models); err != nil {
+ return kimodoModel{}, false
+ }
+ owner, repo, ok := HFOwnerRepoFromURI(uri)
+ if !ok {
+ return kimodoModel{}, false
+ }
+ for _, model := range models {
+ if strings.EqualFold(owner+"/"+repo, model.Repository) && model.TextQuantization == quantization {
+ return model, true
+ }
+ }
+ return kimodoModel{}, false
+}
+
+func (*KimodoCppImporter) Match(details Details) bool {
+ var preferences struct {
+ Backend string `json:"backend"`
+ }
+ if len(details.Preferences) > 0 {
+ if err := json.Unmarshal(details.Preferences, &preferences); err != nil {
+ return false
+ }
+ }
+ if preferences.Backend != "" {
+ return preferences.Backend == "kimodocpp"
+ }
+ _, found := kimodoImportModel(details.URI, "q8_0")
+ return found
+}
+
+func (*KimodoCppImporter) Import(details Details) (gallery.ModelConfig, error) {
+ var preferences struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ TextQuantization string `json:"text_quantization"`
+ }
+ if len(details.Preferences) > 0 {
+ if err := json.Unmarshal(details.Preferences, &preferences); err != nil {
+ return gallery.ModelConfig{}, err
+ }
+ }
+ quantization := strings.ToLower(preferences.TextQuantization)
+ if quantization == "" {
+ quantization = "q8_0"
+ }
+ switch quantization {
+ case "q8_0", "q6_k", "q5_k", "q4_k_m", "q4_k", "bf16":
+ default:
+ return gallery.ModelConfig{}, fmt.Errorf("kimodocpp: unsupported text_quantization %q", preferences.TextQuantization)
+ }
+ selected, found := kimodoImportModel(details.URI, quantization)
+ if !found {
+ return gallery.ModelConfig{}, fmt.Errorf("kimodocpp: choose a published LocalAI-io Kimodo SOMA or G1 GGML motion repository")
+ }
+ if preferences.Name == "" {
+ preferences.Name = selected.Name
+ }
+ if preferences.Description == "" {
+ preferences.Description = "Kimodo " + selected.Label + " text-to-motion with " + strings.ToUpper(quantization) + " text encoder (animated skeleton GLB)"
+ }
+ modelConfig := config.ModelConfig{
+ Name: preferences.Name, Description: preferences.Description, Backend: "kimodocpp",
+ KnownUsecaseStrings: []string{"FLAG_3D_ANIMATION"},
+ Options: []string{"text_bundle:" + selected.TextModel, "frames:150", "steps:100", "text_guidance:2"},
+ PredictionOptions: schema.PredictionOptions{BasicModelRequest: schema.BasicModelRequest{Model: selected.Model}},
+ }
+ data, err := yaml.Marshal(modelConfig)
+ if err != nil {
+ return gallery.ModelConfig{}, err
+ }
+ return gallery.ModelConfig{Name: preferences.Name, Description: preferences.Description, Files: selected.Files, ConfigFile: string(data)}, nil
+}
diff --git a/core/gallery/importers/kimodocpp_test.go b/core/gallery/importers/kimodocpp_test.go
new file mode 100644
index 000000000..5ee937638
--- /dev/null
+++ b/core/gallery/importers/kimodocpp_test.go
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: MIT
+package importers_test
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/gallery"
+ "github.com/mudler/LocalAI/core/gallery/importers"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "go.yaml.in/yaml/v2"
+)
+
+var _ = Describe("KimodoCppImporter", func() {
+ It("keeps the gallery and importer download inventories identical", func() {
+ data, err := os.ReadFile("../../../gallery/index.yaml")
+ Expect(err).NotTo(HaveOccurred())
+ var entries []struct {
+ Name string `yaml:"name"`
+ URLs []string `yaml:"urls"`
+ Files []gallery.File `yaml:"files"`
+ Tags []string `yaml:"tags"`
+ Overrides struct {
+ Options []string `yaml:"options"`
+ } `yaml:"overrides"`
+ }
+ Expect(yaml.Unmarshal(data, &entries)).To(Succeed())
+ count := 0
+ for _, entry := range entries {
+ if !strings.HasPrefix(entry.Name, "kimodo-") {
+ continue
+ }
+ count++
+ Expect(entry.URLs).NotTo(BeEmpty())
+ quantization := entry.Tags[len(entry.Tags)-1]
+ preferences, err := json.Marshal(map[string]string{"text_quantization": quantization})
+ Expect(err).NotTo(HaveOccurred())
+ model, err := (&importers.KimodoCppImporter{}).Import(importers.Details{URI: entry.URLs[0], Preferences: preferences})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(model.Name).To(Equal(entry.Name))
+ Expect(model.Files).To(Equal(entry.Files), entry.Name)
+ for _, option := range entry.Overrides.Options {
+ Expect(model.ConfigFile).To(ContainSubstring(option))
+ }
+ }
+ Expect(count).To(Equal(24))
+ })
+ DescribeTable("imports the complete published model and shared text bundle", func(repo string) {
+ details := importers.Details{URI: "https://huggingface.co/LocalAI-io/" + repo, Preferences: json.RawMessage(`{}`)}
+ importer := &importers.KimodoCppImporter{}
+ Expect(importer.Match(details)).To(BeTrue())
+ model, err := importer.Import(details)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(model.ConfigFile).To(ContainSubstring("backend: kimodocpp"))
+ Expect(model.ConfigFile).To(ContainSubstring("FLAG_3D_ANIMATION"))
+ Expect(model.Files).To(HaveLen(3))
+ Expect(model.ConfigFile).To(ContainSubstring("text_bundle:kimodo/text/Llama-3-Kimodo-Q8_0.gguf"))
+ for _, file := range model.Files {
+ Expect(file.SHA256).To(HaveLen(64))
+ Expect(file.URI).NotTo(ContainSubstring("/resolve/main/"))
+ Expect(file.Filename).To(HavePrefix("kimodo/"))
+ }
+ }, Entry("SOMA RP", "Kimodo-SOMA-RP-v1.1-GGML"), Entry("SOMA SEED", "Kimodo-SOMA-SEED-v1.1-GGML"), Entry("G1 RP", "Kimodo-G1-RP-v1-GGML"), Entry("G1 SEED", "Kimodo-G1-SEED-v1-GGML"))
+ It("respects explicit backend and model-name preferences", func() {
+ importer := &importers.KimodoCppImporter{}
+ details := importers.Details{URI: "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML", Preferences: json.RawMessage(`{"backend":"llama-cpp"}`)}
+ Expect(importer.Match(details)).To(BeFalse())
+ details.Preferences = json.RawMessage(`{"backend":"kimodocpp","name":"robot-motion"}`)
+ Expect(importer.Match(details)).To(BeTrue())
+ model, err := importer.Import(details)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(model.Name).To(Equal("robot-motion"))
+ })
+ It("does not classify arbitrary GGUF models as motion models", func() {
+ importer := &importers.KimodoCppImporter{}
+ Expect(importer.Match(importers.Details{URI: "https://huggingface.co/example/model/resolve/main/model.gguf"})).To(BeFalse())
+ })
+ It("rejects unknown text quantizations instead of silently downloading a different encoder", func() {
+ _, err := (&importers.KimodoCppImporter{}).Import(importers.Details{
+ URI: "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML", Preferences: json.RawMessage(`{"text_quantization":"q2_k"}`),
+ })
+ Expect(err).To(MatchError(ContainSubstring("unsupported text_quantization")))
+ })
+ It("accepts uppercase quantization preferences", func() {
+ model, err := (&importers.KimodoCppImporter{}).Import(importers.Details{
+ URI: "https://huggingface.co/LocalAI-io/Kimodo-G1-RP-v1-GGML", Preferences: json.RawMessage(`{"text_quantization":"Q4_K_M"}`),
+ })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(model.ConfigFile).To(ContainSubstring("text_bundle:kimodo/text/Llama-3-Kimodo-Q4_K_M.gguf"))
+ })
+})
diff --git a/core/gallery/upgrade.go b/core/gallery/upgrade.go
index 7abc65a43..4af56e256 100644
--- a/core/gallery/upgrade.go
+++ b/core/gallery/upgrade.go
@@ -170,7 +170,12 @@ func CheckUpgradesAgainst(ctx context.Context, galleries []config.Gallery, syste
// Fall back to OCI digest comparison when versions are unavailable.
if downloader.URI(galleryEntry.URI).LooksLikeOCI() {
- remoteDigest, err := oci.GetImageDigest(galleryEntry.URI, "", nil, nil)
+ // Strip the oci:// scheme — name.ParseReference (called by
+ // GetImageDigest) cannot parse it. Self-hosted registries must
+ // set the scheme to be recognised at all, so without stripping
+ // they silently lose upgrade detection.
+ remoteDigest, err := oci.GetImageDigest(
+ strings.TrimPrefix(galleryEntry.URI, downloader.OCIPrefix), "", nil, nil)
if err != nil {
xlog.Warn("Failed to get remote OCI digest for upgrade check", "backend", installed.Metadata.Name, "error", err)
continue
diff --git a/core/http/auth/features.go b/core/http/auth/features.go
index ca7d024df..45411f824 100644
--- a/core/http/auth/features.go
+++ b/core/http/auth/features.go
@@ -98,6 +98,7 @@ var RouteFeatureRegistry = []RouteFeature{
// 3D generation
{"POST", "/3d/generations", Feature3D},
{"POST", "/3d/remesh", Feature3D},
+ {"POST", "/3d/animate", Feature3D},
// Sound generation
{"POST", "/v1/sound-generation", FeatureSound},
diff --git a/core/http/endpoints/localai/api_instructions.go b/core/http/endpoints/localai/api_instructions.go
index daec9b6bf..f8af73de2 100644
--- a/core/http/endpoints/localai/api_instructions.go
+++ b/core/http/endpoints/localai/api_instructions.go
@@ -89,9 +89,9 @@ var instructionDefs = []instructionDef{
},
{
Name: "3d",
- Description: "Image-to-3D asset generation (binary glTF / GLB) via TRELLIS.2",
+ Description: "3D mesh generation, remeshing, and animation (binary glTF / GLB)",
Tags: []string{"3d"},
- Intro: "POST /3d/generations accepts a conditioning image as public URL, base64, or data URI (no text prompt) and returns one .glb asset as a URL under /generated-3d or as b64_json. quality selects the mesh pipeline (auto|coarse|512|1024); background controls solid-background removal (auto|keep|black|white); step, texture_steps, and cfg_scale tune the flow sampling. POST /3d/remesh accepts multipart model, mesh (GLB), and a single detail percentage to return a watertight print-ready GLB; the enclosing offset is derived automatically.",
+ Intro: "POST /3d/generations accepts a conditioning image as public URL, base64, or data URI (no text prompt) and returns one .glb asset as a URL under /generated-3d or as b64_json. quality selects the mesh pipeline (auto|coarse|512|1024); background controls solid-background removal (auto|keep|black|white); step, texture_steps, and cfg_scale tune the flow sampling. POST /3d/remesh accepts multipart model, mesh (GLB), and a single detail percentage to return a watertight print-ready GLB; the enclosing offset is derived automatically. POST /3d/animate accepts named inputs with type and data, plus string-valued params. Consult the selected model's three_d_operations from /v1/models/capabilities for its required inputs, parameters, and output type. Kimodo produces text-conditioned animated skeletons without a mesh or skin; animation inputs are not universally text-only.",
},
{
Name: "face-recognition",
diff --git a/core/http/endpoints/localai/model3d_animation.go b/core/http/endpoints/localai/model3d_animation.go
new file mode 100644
index 000000000..8eb46b327
--- /dev/null
+++ b/core/http/endpoints/localai/model3d_animation.go
@@ -0,0 +1,150 @@
+// SPDX-License-Identifier: MIT
+package localai
+
+import (
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "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"
+ pb "github.com/mudler/LocalAI/pkg/grpc/proto"
+ "github.com/mudler/LocalAI/pkg/model"
+)
+
+func validateAnimationRequest(input *schema.Model3DAnimationRequest, cfg *config.ModelConfig) error {
+ if input.ResponseFormat != "" && input.ResponseFormat != "url" && input.ResponseFormat != "b64_json" {
+ return fmt.Errorf("response_format must be url or b64_json")
+ }
+ for _, operation := range cfg.ThreeDOperations() {
+ if operation.Endpoint != "/3d/animate" || !animationInputsMatch(input.Inputs, operation.Inputs) {
+ continue
+ }
+ parameters := make(map[string]schema.ThreeDParameter, len(operation.Parameters))
+ for _, parameter := range operation.Parameters {
+ parameters[parameter.Name] = parameter
+ }
+ for name, value := range input.Params {
+ parameter, ok := parameters[name]
+ if !ok {
+ return fmt.Errorf("unsupported animation parameter %q", name)
+ }
+ if err := parameter.Validate(value); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ return fmt.Errorf("the selected model does not support these animation inputs; consult its three_d_operations capabilities")
+}
+
+func animationInputsMatch(inputs map[string]schema.AnimationInput, requirements []schema.ThreeDInput) bool {
+ known := make(map[string]bool, len(requirements))
+ for _, requirement := range requirements {
+ known[requirement.Name] = true
+ input, present := inputs[requirement.Name]
+ if !present && !requirement.Required {
+ continue
+ }
+ if !present || input.Type != requirement.Type || strings.TrimSpace(input.Data) == "" {
+ return false
+ }
+ if input.Type == "text" && (!utf8.ValidString(input.Data) || strings.ContainsRune(input.Data, 0) ||
+ (requirement.MaxBytes > 0 && len(input.Data) > requirement.MaxBytes)) {
+ return false
+ }
+ }
+ for name := range inputs {
+ if !known[name] {
+ return false
+ }
+ }
+ return true
+}
+
+// Model3DAnimationEndpoint creates an animation using the selected model's inputs.
+// @Summary Creates a 3D animation (binary glTF / GLB).
+// @Tags 3d
+// @Param request body schema.Model3DAnimationRequest true "Named conditioning inputs and model-specific parameters"
+// @Success 200 {object} schema.OpenAIResponse
+// @Router /3d/animate [post]
+func Model3DAnimationEndpoint(ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.Model3DAnimationRequest)
+ if !ok || input.Model == "" {
+ return echo.ErrBadRequest
+ }
+ cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
+ if !ok || cfg == nil {
+ return echo.ErrBadRequest
+ }
+ if err := validateAnimationRequest(input, cfg); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+ }
+ request := &pb.Animate3DRequest{Inputs: make(map[string]*pb.AnimationInput), Params: input.Params}
+ var staged []string
+ defer func() {
+ for _, path := range staged {
+ _ = os.Remove(path)
+ }
+ }()
+ for name, value := range input.Inputs {
+ data := value.Data
+ if value.Type != "text" {
+ path, err := stageVideoMediaWithLimit(c.Request().Context(), appConfig.GeneratedContentDir, data, 32<<20)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid input %q: %v", name, err))
+ }
+ staged = append(staged, path)
+ data = path
+ }
+ request.Inputs[name] = &pb.AnimationInput{Type: value.Type, Data: data}
+ }
+ directory := filepath.Join(appConfig.GeneratedContentDir, "3d")
+ if err := os.MkdirAll(directory, 0o750); err != nil {
+ return err
+ }
+ file, err := os.CreateTemp(directory, "animation-*.glb")
+ if err != nil {
+ return err
+ }
+ preserve := false
+ defer func() {
+ if !preserve {
+ _ = os.Remove(file.Name())
+ }
+ }()
+ if err := file.Close(); err != nil {
+ return err
+ }
+ request.Dst = file.Name()
+ if err := backend.Model3DAnimation(c.Request().Context(), request, ml, *cfg, appConfig); err != nil {
+ return mapBackendError(err)
+ }
+ item := schema.Item{}
+ if input.ResponseFormat == "b64_json" {
+ data, err := os.ReadFile(file.Name())
+ if err != nil {
+ return err
+ }
+ item.B64JSON = base64.StdEncoding.EncodeToString(data)
+ } else {
+ item.URL, err = url.JoinPath(middleware.BaseURL(c), "generated-3d", filepath.Base(file.Name()))
+ if err != nil {
+ return err
+ }
+ preserve = true
+ }
+ return c.JSON(http.StatusOK, schema.OpenAIResponse{ID: uuid.NewString(), Created: int(time.Now().Unix()), Data: []schema.Item{item}})
+ }
+}
diff --git a/core/http/endpoints/localai/model3d_animation_internal_test.go b/core/http/endpoints/localai/model3d_animation_internal_test.go
new file mode 100644
index 000000000..94332eeab
--- /dev/null
+++ b/core/http/endpoints/localai/model3d_animation_internal_test.go
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: MIT
+package localai
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/http/middleware"
+ "github.com/mudler/LocalAI/core/schema"
+ grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
+ pb "github.com/mudler/LocalAI/pkg/grpc/proto"
+ "github.com/mudler/LocalAI/pkg/model"
+ "github.com/mudler/LocalAI/pkg/system"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ ggrpc "google.golang.org/grpc"
+)
+
+type animationEndpointBackend struct {
+ grpcPkg.Backend
+ success bool
+ seen *pb.Animate3DRequest
+}
+
+func (*animationEndpointBackend) HealthCheck(context.Context) (bool, error) { return true, nil }
+func (*animationEndpointBackend) IsBusy() bool { return false }
+func (b *animationEndpointBackend) Animate3D(_ context.Context, request *pb.Animate3DRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
+ b.seen = request
+ if err := os.WriteFile(request.Dst, []byte("glTF fixture"), 0o600); err != nil {
+ return nil, err
+ }
+ return &pb.Result{Success: b.success, Message: "fixture failure"}, nil
+}
+
+var _ = Describe("3D animation HTTP output", func() {
+ DescribeTable("returns an asset and cleans temporary output on base64 or backend failure", func(format string, success bool) {
+ state := &system.SystemState{}
+ loader := model.NewModelLoader(state)
+ fixture := &animationEndpointBackend{success: success}
+ loader.SetModelRouter(func(_ context.Context, id string, _, _, _, _ string, _ *pb.ModelOptions, _ bool) (*model.Model, error) {
+ return model.NewModelWithClient(id, "test://animation", fixture), nil
+ })
+ cfg := &config.ModelConfig{Backend: "kimodocpp", Name: "motion"}
+ cfg.SetDefaults()
+ cfg.Model = "motion.gguf"
+ appConfig := config.NewApplicationConfig(config.WithSystemState(state))
+ appConfig.GeneratedContentDir = GinkgoT().TempDir()
+ appConfig.EnableTracing = true
+ request := &schema.Model3DAnimationRequest{BasicModelRequest: schema.BasicModelRequest{Model: "motion"}, ResponseFormat: format,
+ Inputs: map[string]schema.AnimationInput{"prompt": {Type: "text", Data: "Walk forward"}}}
+ e := echo.New()
+ recorder := httptest.NewRecorder()
+ c := e.NewContext(httptest.NewRequest(http.MethodPost, "/3d/animate", nil), recorder)
+ c.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, request)
+ c.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
+ err := Model3DAnimationEndpoint(loader, appConfig)(c)
+ Expect(fixture.seen).NotTo(BeNil())
+ Expect(fixture.seen.ModelIdentity).To(Equal("motion.gguf"))
+ files, readErr := filepath.Glob(filepath.Join(appConfig.GeneratedContentDir, "3d", "*"))
+ Expect(readErr).NotTo(HaveOccurred())
+ if !success {
+ Expect(err).To(HaveOccurred())
+ Expect(files).To(BeEmpty())
+ return
+ }
+ Expect(err).NotTo(HaveOccurred())
+ Expect(recorder.Code).To(Equal(http.StatusOK))
+ var response schema.OpenAIResponse
+ Expect(json.Unmarshal(recorder.Body.Bytes(), &response)).To(Succeed())
+ Expect(response.Data).To(HaveLen(1))
+ if format == "b64_json" {
+ Expect(response.Data[0].B64JSON).To(Equal(base64.StdEncoding.EncodeToString([]byte("glTF fixture"))))
+ Expect(files).To(BeEmpty())
+ } else {
+ Expect(response.Data[0].URL).To(ContainSubstring("/generated-3d/animation-"))
+ Expect(files).To(HaveLen(1))
+ }
+ }, Entry("URL", "url", true), Entry("base64", "b64_json", true), Entry("backend failure", "url", false))
+})
+
+var _ = Describe("3D animation validation", func() {
+ var request *schema.Model3DAnimationRequest
+ var cfg *config.ModelConfig
+ BeforeEach(func() {
+ cfg = &config.ModelConfig{Backend: "kimodocpp"}
+ request = &schema.Model3DAnimationRequest{Inputs: map[string]schema.AnimationInput{"prompt": {Type: "text", Data: "Walk forward"}}}
+ })
+ It("accepts text input without an image", func() {
+ Expect(validateAnimationRequest(request, cfg)).To(Succeed())
+ })
+ It("rejects malformed or oversized prompts before model loading", func() {
+ for _, text := range []string{strings.Repeat("x", 4097), "walk\x00run", "\xff", " "} {
+ request.Inputs["prompt"] = schema.AnimationInput{Type: "text", Data: text}
+ Expect(validateAnimationRequest(request, cfg)).NotTo(Succeed())
+ }
+ })
+ It("rejects unsupported models and input combinations", func() {
+ Expect(validateAnimationRequest(request, &config.ModelConfig{Backend: "trellis2cpp"})).NotTo(Succeed())
+ request.Inputs["mesh"] = schema.AnimationInput{Type: "mesh", Data: "asset.glb"}
+ Expect(validateAnimationRequest(request, cfg)).NotTo(Succeed())
+ })
+ It("validates parameters and response format before loading weights", func() {
+ for _, params := range []map[string]string{{"texture_steps": "12"}, {"steps": "NaN"}, {"frames": "151"}, {"seed": "-1"}} {
+ request.Params = params
+ Expect(validateAnimationRequest(request, cfg)).NotTo(Succeed())
+ }
+ request.Params = nil
+ request.ResponseFormat = "obj"
+ Expect(validateAnimationRequest(request, cfg)).NotTo(Succeed())
+ })
+ It("matches future compound media inputs without assuming text conditioning", func() {
+ requirements := []schema.ThreeDInput{{Name: "source", Type: "mesh", Required: true}, {Name: "motion", Type: "video", Required: true}}
+ inputs := map[string]schema.AnimationInput{"source": {Type: "mesh", Data: "mesh.glb"}, "motion": {Type: "video", Data: "motion.mp4"}}
+ Expect(animationInputsMatch(inputs, requirements)).To(BeTrue())
+ delete(inputs, "motion")
+ Expect(animationInputsMatch(inputs, requirements)).To(BeFalse())
+ })
+})
diff --git a/core/http/endpoints/localai/nodes.go b/core/http/endpoints/localai/nodes.go
index 220682b92..331c7203b 100644
--- a/core/http/endpoints/localai/nodes.go
+++ b/core/http/endpoints/localai/nodes.go
@@ -75,15 +75,18 @@ func GetNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
// RegisterNodeRequest is the request body for registering a new worker node.
type RegisterNodeRequest struct {
- Name string `json:"name"`
- NodeType string `json:"node_type,omitempty"` // "backend" (default) or "agent"
- Address string `json:"address"`
- HTTPAddress string `json:"http_address,omitempty"`
- Token string `json:"token,omitempty"`
- TotalVRAM uint64 `json:"total_vram,omitempty"`
- AvailableVRAM uint64 `json:"available_vram,omitempty"`
- TotalRAM uint64 `json:"total_ram,omitempty"`
- AvailableRAM uint64 `json:"available_ram,omitempty"`
+ Name string `json:"name"`
+ NodeType string `json:"node_type,omitempty"` // "backend" (default) or "agent"
+ Address string `json:"address"`
+ HTTPAddress string `json:"http_address,omitempty"`
+ Token string `json:"token,omitempty"`
+ TotalVRAM uint64 `json:"total_vram,omitempty"`
+ AvailableVRAM uint64 `json:"available_vram,omitempty"`
+ TotalRAM uint64 `json:"total_ram,omitempty"`
+ AvailableRAM uint64 `json:"available_ram,omitempty"`
+ CPULogicalCores uint64 `json:"cpu_logical_cores,omitempty"`
+ CPUUsagePercent float64 `json:"cpu_usage_percent,omitempty"`
+ CPULoad1 float64 `json:"cpu_load_1,omitempty"`
// TotalDisk / AvailableDisk describe the filesystem backing the worker's
// MODELS directory (where staged weights land), not the root filesystem.
// Omitted by workers that predate the fields; the scheduler treats
@@ -182,6 +185,9 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
AvailableVRAM: req.AvailableVRAM,
TotalRAM: req.TotalRAM,
AvailableRAM: req.AvailableRAM,
+ CPULogicalCores: req.CPULogicalCores,
+ CPUUsagePercent: req.CPUUsagePercent,
+ CPULoad1: req.CPULoad1,
TotalDisk: req.TotalDisk,
AvailableDisk: req.AvailableDisk,
GPUVendor: req.GPUVendor,
@@ -381,7 +387,8 @@ func HeartbeatEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
var updatePtr *nodes.HeartbeatUpdate
if update.AvailableVRAM != nil || update.TotalVRAM != nil || update.AvailableRAM != nil ||
- update.AvailableDisk != nil || update.TotalDisk != nil || update.GPUVendor != "" {
+ update.AvailableDisk != nil || update.TotalDisk != nil || update.GPUVendor != "" ||
+ update.CPUUsagePercent != nil || update.CPULoad1 != nil {
updatePtr = &update
}
@@ -431,6 +438,12 @@ func DrainNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
ctx := c.Request().Context()
id := c.Param("id")
if err := registry.MarkDraining(ctx, id); err != nil {
+ if errors.Is(err, nodes.ErrNodeNotFound) {
+ return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
+ }
+ if errors.Is(err, nodes.ErrNodeStatusConflict) {
+ return c.JSON(http.StatusConflict, nodeError(http.StatusConflict, "node must be healthy to drain"))
+ }
xlog.Error("Failed to drain node", "id", id, "error", err)
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to drain node"))
}
@@ -443,7 +456,13 @@ func ResumeNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
id := c.Param("id")
- if err := registry.MarkHealthy(ctx, id); err != nil {
+ if err := registry.ResumeNode(ctx, id); err != nil {
+ if errors.Is(err, nodes.ErrNodeNotFound) {
+ return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
+ }
+ if errors.Is(err, nodes.ErrNodeStatusConflict) {
+ return c.JSON(http.StatusConflict, nodeError(http.StatusConflict, "node must be draining to resume"))
+ }
xlog.Error("Failed to resume node", "id", id, "error", err)
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to resume node"))
}
diff --git a/core/http/endpoints/localai/nodes_test.go b/core/http/endpoints/localai/nodes_test.go
index 19e6a6b07..8390f1a48 100644
--- a/core/http/endpoints/localai/nodes_test.go
+++ b/core/http/endpoints/localai/nodes_test.go
@@ -58,6 +58,35 @@ var _ = Describe("Node HTTP handlers", func() {
})
Describe("RegisterNodeEndpoint", func() {
+ It("binds, persists, and lists CPU telemetry", func() {
+ e := echo.New()
+ body := `{"name":"cpu-worker","address":"10.0.0.9:50051","cpu_logical_cores":12,"cpu_usage_percent":145,"cpu_load_1":2.5}`
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ rec := httptest.NewRecorder()
+
+ Expect(RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})(e.NewContext(req, rec))).To(Succeed())
+ Expect(rec.Code).To(Equal(http.StatusCreated))
+
+ node, err := registry.GetByName(context.Background(), "cpu-worker")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(node.CPULogicalCores).To(Equal(uint64(12)))
+ Expect(node.CPUUsagePercent).To(Equal(float64(100)))
+ Expect(node.CPULoad1).To(Equal(2.5))
+
+ listRecorder := httptest.NewRecorder()
+ listContext := e.NewContext(httptest.NewRequest(http.MethodGet, "/api/nodes", nil), listRecorder)
+ Expect(ListNodesEndpoint(registry)(listContext)).To(Succeed())
+ var listed []map[string]any
+ Expect(json.Unmarshal(listRecorder.Body.Bytes(), &listed)).To(Succeed())
+ Expect(listed).To(HaveLen(1))
+ Expect(listed[0]).To(SatisfyAll(
+ HaveKeyWithValue("cpu_logical_cores", float64(12)),
+ HaveKeyWithValue("cpu_usage_percent", float64(100)),
+ HaveKeyWithValue("cpu_load_1", 2.5),
+ ))
+ })
+
It("registers a backend node and returns 201", func() {
e := echo.New()
body := `{"name":"worker-1","address":"10.0.0.1:50051"}`
@@ -449,6 +478,42 @@ var _ = Describe("Node HTTP handlers", func() {
})
})
+ Describe("Node lifecycle endpoints", func() {
+ request := func(handler echo.HandlerFunc, id string) *httptest.ResponseRecorder {
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodPost, "/api/nodes/"+id, nil)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+ c.SetParamNames("id")
+ c.SetParamValues(id)
+ Expect(handler(c)).To(Succeed())
+ return rec
+ }
+
+ It("accepts healthy drain followed by draining resume", func() {
+ Expect(registry.Register(context.Background(), &nodes.BackendNode{
+ ID: "lifecycle", Name: "lifecycle", Address: "10.0.0.10:50051",
+ }, true)).To(Succeed())
+
+ Expect(request(DrainNodeEndpoint(registry), "lifecycle").Code).To(Equal(http.StatusOK))
+ Expect(request(ResumeNodeEndpoint(registry), "lifecycle").Code).To(Equal(http.StatusOK))
+ })
+
+ It("returns conflict when a pending node is drained or resumed", func() {
+ Expect(registry.Register(context.Background(), &nodes.BackendNode{
+ ID: "pending-lifecycle", Name: "pending-lifecycle", Address: "10.0.0.11:50051",
+ }, false)).To(Succeed())
+
+ Expect(request(DrainNodeEndpoint(registry), "pending-lifecycle").Code).To(Equal(http.StatusConflict))
+ Expect(request(ResumeNodeEndpoint(registry), "pending-lifecycle").Code).To(Equal(http.StatusConflict))
+ })
+
+ It("returns not found for missing nodes", func() {
+ Expect(request(DrainNodeEndpoint(registry), "missing").Code).To(Equal(http.StatusNotFound))
+ Expect(request(ResumeNodeEndpoint(registry), "missing").Code).To(Equal(http.StatusNotFound))
+ })
+ })
+
Describe("GetNodeModelsEndpoint", func() {
It("returns revision and cleanup state without serialized model options", func() {
ctx := context.Background()
diff --git a/core/http/endpoints/localai/toggle_model.go b/core/http/endpoints/localai/toggle_model.go
index 8b988c77a..e45c4b3c1 100644
--- a/core/http/endpoints/localai/toggle_model.go
+++ b/core/http/endpoints/localai/toggle_model.go
@@ -11,7 +11,7 @@ import (
"github.com/mudler/LocalAI/core/services/modeladmin"
)
-// ToggleModelEndpoint handles enabling or disabling a model from being loaded on demand.
+// ToggleStateModelEndpoint handles enabling or disabling a model from being loaded on demand.
// When disabled, the model remains in the collection but will not be loaded when requested.
//
// @Summary Toggle model enabled/disabled status
diff --git a/core/http/endpoints/openai/image.go b/core/http/endpoints/openai/image.go
index 4009e923e..9d64e8e72 100644
--- a/core/http/endpoints/openai/image.go
+++ b/core/http/endpoints/openai/image.go
@@ -160,9 +160,12 @@ func ImageEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfi
for range n {
prompts := strings.Split(i, "|")
positive_prompt := prompts[0]
- negative_prompt := ""
+ negative_prompt := strings.TrimSpace(input.NegativePrompt)
if len(prompts) > 1 {
- negative_prompt = prompts[1]
+ if negative_prompt != "" {
+ negative_prompt += ", "
+ }
+ negative_prompt += strings.TrimSpace(prompts[1])
}
step := config.Step
diff --git a/core/http/endpoints/openai/list_capabilities.go b/core/http/endpoints/openai/list_capabilities.go
index 386f53e85..d947c2ade 100644
--- a/core/http/endpoints/openai/list_capabilities.go
+++ b/core/http/endpoints/openai/list_capabilities.go
@@ -2,6 +2,7 @@ package openai
import (
"github.com/labstack/echo/v4"
+ "github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
model "github.com/mudler/LocalAI/pkg/model"
@@ -36,8 +37,12 @@ func ListModelCapabilitiesEndpoint(bcl *config.ModelConfigLoader, ml *model.Mode
entry := schema.ModelCapabilities{ID: m, Object: "model"}
if cfg, ok := bcl.GetModelConfig(m); ok {
entry.Capabilities = cfg.Capabilities()
+ entry.ThreeDOperations = cfg.ThreeDOperations()
entry.InputModalities = cfg.InputModalities()
entry.OutputModalities = cfg.OutputModalities()
+ if ctx := backend.EffectiveContextSize(cfg); ctx > 0 {
+ entry.ContextSize = ctx
+ }
}
dataModels = append(dataModels, entry)
}
diff --git a/core/http/endpoints/openai/list_capabilities_test.go b/core/http/endpoints/openai/list_capabilities_test.go
index b1cc8a1bf..d384da1ad 100644
--- a/core/http/endpoints/openai/list_capabilities_test.go
+++ b/core/http/endpoints/openai/list_capabilities_test.go
@@ -8,6 +8,7 @@ import (
"path/filepath"
"github.com/labstack/echo/v4"
+ "github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/model"
@@ -116,4 +117,29 @@ parameters:
Expect(entry.OutputModalities).To(Equal([]string{"text"}))
Expect(entry.Capabilities).NotTo(ContainElement("chat"))
})
+
+ It("surfaces the configured context_size", func() {
+ writeConfig("llm", `
+name: llm
+backend: llama-cpp
+context_size: 32768
+parameters:
+ model: model.gguf
+`)
+ entry := entryFor(call(), "llm")
+ Expect(entry).NotTo(BeNil())
+ Expect(entry.ContextSize).To(Equal(32768))
+ })
+
+ It("falls back to the default context size when context_size is unset", func() {
+ writeConfig("llm", `
+name: llm
+backend: llama-cpp
+parameters:
+ model: model.gguf
+`)
+ entry := entryFor(call(), "llm")
+ Expect(entry).NotTo(BeNil())
+ Expect(entry.ContextSize).To(Equal(backend.DefaultContextSize))
+ })
})
diff --git a/core/http/react-ui/e2e/console-narrow.spec.js b/core/http/react-ui/e2e/console-narrow.spec.js
index a7f7bfc73..0e9780806 100644
--- a/core/http/react-ui/e2e/console-narrow.spec.js
+++ b/core/http/react-ui/e2e/console-narrow.spec.js
@@ -1,6 +1,18 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Operate console on a narrow screen', () => {
+ test('ignores the desktop collapsed preference', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 800 })
+ await page.addInitScript(() => localStorage.setItem('localai_console_rail_collapsed', 'true'))
+ await page.goto('/app/operate')
+
+ const rail = page.locator('.console-rail')
+ await expect(rail).toHaveCSS('width', '374px')
+ await expect(rail.getByText('Operate', { exact: true })).toBeVisible()
+ await expect(rail.getByRole('button', { name: 'Expand Operate navigation' })).toBeVisible()
+ await expect(rail.locator('.console-rail-collapse')).toBeHidden()
+ })
+
test('expanding the rail leaves the overview on screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/operate')
diff --git a/core/http/react-ui/e2e/installed-model-logs-link.spec.js b/core/http/react-ui/e2e/installed-model-logs-link.spec.js
index 62fcadf27..e43e16081 100644
--- a/core/http/react-ui/e2e/installed-model-logs-link.spec.js
+++ b/core/http/react-ui/e2e/installed-model-logs-link.spec.js
@@ -21,4 +21,24 @@ test.describe('Installed model backend logs link', () => {
await expect(page).toHaveURL(/\/app\/backend-logs\//)
})
+
+ test('arrow navigation announces the active action through the focused menu', async ({ page }) => {
+ await page.goto('/app/models?view=installed')
+ await page.locator('[data-testid="installed-models-rail-item"]').first().click()
+ const trigger = page.locator('button.action-menu__trigger').first()
+ await trigger.focus()
+ await trigger.press('Enter')
+
+ const menu = page.getByRole('menu')
+ await expect(menu).toBeFocused()
+ const firstItem = menu.getByRole('menuitem').first()
+ await expect(menu).toHaveAttribute('aria-activedescendant', await firstItem.getAttribute('id'))
+
+ await menu.press('ArrowDown')
+ const secondItem = menu.getByRole('menuitem').nth(1)
+ await expect(menu).toHaveAttribute('aria-activedescendant', await secondItem.getAttribute('id'))
+ await expect(menu).toBeFocused()
+ await expect(firstItem).toHaveAttribute('tabindex', '-1')
+ await expect(secondItem).toHaveAttribute('tabindex', '-1')
+ })
})
diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js
index 1a29c07e4..0f35c51aa 100644
--- a/core/http/react-ui/e2e/navigation.spec.js
+++ b/core/http/react-ui/e2e/navigation.spec.js
@@ -55,4 +55,26 @@ test.describe('Navigation', () => {
await expect(rail.locator('a.nav-item[href="/app/fine-tune"]')).toBeVisible()
await expect(rail.locator('a.nav-item[href="/app/face"]')).toBeVisible()
})
+
+ test('desktop console rail collapses to accessible icons and persists globally', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 })
+ await page.goto('/app/backends')
+
+ const rail = page.locator('.console-rail')
+ const collapse = rail.getByRole('button', { name: 'Collapse Operate navigation' })
+ await expect(collapse).toBeVisible()
+ await collapse.click()
+ await expect(rail).toHaveClass(/console-rail--collapsed/)
+ await expect(rail).toHaveCSS('width', '60px')
+ await expect(rail.getByRole('link', { name: 'Backends', exact: true })).toHaveClass(/active/)
+ await expect(rail.getByRole('link', { name: 'Overview', exact: true })).toHaveAttribute('title', 'Overview')
+ await expect.poll(() => page.evaluate(() => localStorage.getItem('localai_console_rail_collapsed'))).toBe('true')
+
+ await page.goto('/app/agents')
+ const buildRail = page.locator('.console-rail')
+ await expect(buildRail).toHaveClass(/console-rail--collapsed/)
+ await expect(buildRail.getByRole('button', { name: 'Expand Build navigation' })).toBeVisible()
+ await page.reload()
+ await expect(page.locator('.console-rail')).toHaveClass(/console-rail--collapsed/)
+ })
})
diff --git a/core/http/react-ui/e2e/nodes-detail.spec.js b/core/http/react-ui/e2e/nodes-detail.spec.js
index 65690ba49..6793b3b9d 100644
--- a/core/http/react-ui/e2e/nodes-detail.spec.js
+++ b/core/http/react-ui/e2e/nodes-detail.spec.js
@@ -1,9 +1,9 @@
import { test, expect } from './coverage-fixtures.js'
const ID = 'n1'
-async function mockNode(page) {
+async function mockNode(page, overrides = {}) {
await page.route(`**/api/nodes/${ID}`, r => r.fulfill({ status: 200, contentType: 'application/json',
- body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', total_vram: 24e9, available_vram: 12e9, max_replicas_per_model: 1, labels: { env: 'prod' } }) }))
+ body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', total_vram: 24e9, available_vram: 12e9, total_disk: 100e9, available_disk: 40e9, cpu_logical_cores: 16, cpu_usage_percent: 25, cpu_load_1: 2.5, max_replicas_per_model: 1, labels: { env: 'prod' }, ...overrides }) }))
await page.route(`**/api/nodes/${ID}/models`, r => r.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify([{ node_id: ID, model_name: 'llama-3.3', state: 'loaded', in_flight: 0, replica_index: 0 }]) }))
await page.route(`**/api/nodes/${ID}/backends`, r => r.fulfill({ status: 200, contentType: 'application/json',
@@ -15,20 +15,108 @@ test.describe('Node detail page', () => {
await mockNode(page)
await page.goto(`/app/nodes/${ID}`)
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
- await expect(page.getByText('alpha')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible()
await expect(page.getByText('llama-3.3')).toBeVisible()
await expect(page.getByText('llama-cpp')).toBeVisible()
await expect(page.getByText('env=prod')).toBeVisible()
+ await expect(page.getByText('25.0% of 16 cores')).toBeVisible()
+ await expect(page.getByText('2.50 load (1m)')).toBeVisible()
+ await expect(page.getByText('37.3 GB / 93.1 GB')).toBeVisible()
+ await expect(page.getByRole('link', { name: 'Nodes' })).toHaveAttribute('href', '/app/nodes')
+ await expect(page.getByRole('region', { name: 'Node resources' })).toBeVisible()
+ await expect(page.getByRole('region', { name: 'Running models' })).toHaveClass(/fleet-workbench/)
+ await expect(page.getByRole('region', { name: 'Installed backends' })).toHaveClass(/fleet-workbench/)
+
+ await page.getByRole('button', { name: 'Actions for llama-3.3 replica 1' }).click()
+ await page.getByRole('menuitem', { name: 'View logs' }).click()
+ await expect(page).toHaveURL(/\/app\/node-backend-logs\/n1\/llama-3.3%230$/)
})
test('is reachable by clicking a roster panel', async ({ page }) => {
await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify([{ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' }]) }))
- await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
- await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await mockNode(page)
await page.goto('/app/nodes')
- await page.locator('.node-panel').filter({ hasText: 'alpha' }).getByText('alpha').click()
+ await page.getByRole('button', { name: 'Inspect alpha' }).click()
+ await page.getByRole('link', { name: 'Open full node details' }).click()
await expect(page).toHaveURL(new RegExp(`/app/nodes/${ID}$`))
})
+
+ for (const [status, action] of [['healthy', 'Drain'], ['draining', 'Resume'], ['unhealthy', null], ['offline', null], ['unknown', null]]) {
+ test(`shows only the accepted lifecycle action for ${status} nodes`, async ({ page }) => {
+ await mockNode(page, { status })
+ await page.goto(`/app/nodes/${ID}`)
+ await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
+ await expect(page.getByRole('button', { name: /Approve/ })).toHaveCount(0)
+ await expect(page.getByRole('button', { name: /Drain/ })).toHaveCount(action === 'Drain' ? 1 : 0)
+ await expect(page.getByRole('button', { name: /Resume/ })).toHaveCount(action === 'Resume' ? 1 : 0)
+ await page.getByRole('button', { name: 'Actions for alpha' }).click()
+ await expect(page.getByRole('menuitem', { name: 'Remove node…' })).toBeVisible()
+ })
+ }
+
+ test('approves a pending node and refreshes its lifecycle controls', async ({ page }) => {
+ let status = 'pending'
+ let approvalRequests = 0
+ await page.route(`**/api/nodes/${ID}`, r => r.fulfill({ status: 200, contentType: 'application/json',
+ body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', status, labels: {} }) }))
+ await page.route(`**/api/nodes/${ID}/models`, r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.route(`**/api/nodes/${ID}/backends`, r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.route(`**/api/nodes/${ID}/approve`, async r => {
+ approvalRequests += 1
+ status = 'healthy'
+ await r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
+ })
+ await page.goto(`/app/nodes/${ID}`)
+
+ await page.getByRole('button', { name: 'Approve' }).click()
+ await expect.poll(() => approvalRequests).toBe(1)
+ await expect(page.getByText('Node approved')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Drain' })).toBeVisible()
+ await page.getByRole('button', { name: 'Actions for alpha' }).click()
+ await expect(page.getByRole('menuitem', { name: 'Remove node…' })).toBeVisible()
+ })
+
+ test('renders valid totals with missing available capacity as No data', async ({ page }) => {
+ await mockNode(page, {
+ total_vram: 24e9, available_vram: undefined,
+ total_ram: 32e9, available_ram: undefined,
+ total_disk: 100e9, available_disk: undefined,
+ })
+ await page.goto(`/app/nodes/${ID}`)
+ await expect(page.locator('.node-detail__metrics')).toContainText('VRAM')
+ await expect(page.locator('.node-detail__metrics')).toContainText('RAM')
+ await expect(page.locator('.node-detail__metrics')).toContainText('Models disk free')
+ await expect(page.locator('.node-detail__metrics').getByText('No data')).toHaveCount(3)
+ })
+
+ test('keeps model operations visible on a narrow screen', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 })
+ await mockNode(page)
+ await page.goto(`/app/nodes/${ID}`)
+
+ const action = page.getByRole('button', { name: 'Actions for llama-3.3 replica 1' })
+ await expect(action).toBeVisible()
+ const box = await action.boundingBox()
+ expect(box.x + box.width).toBeLessThanOrEqual(390)
+ })
+
+ test('distinguishes a load failure from a missing node and retries in place', async ({ page }) => {
+ let attempts = 0
+ await page.route(`**/api/nodes/${ID}`, route => {
+ attempts += 1
+ if (attempts === 1) return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"controller unavailable"}' })
+ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', status: 'healthy', labels: {} }) })
+ })
+ await page.route(`**/api/nodes/${ID}/models`, route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.route(`**/api/nodes/${ID}/backends`, route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+
+ await page.goto(`/app/nodes/${ID}`)
+ const error = page.getByRole('alert')
+ await expect(error).toContainText('Could not load this node')
+ await expect(page.getByText('Node not found')).toHaveCount(0)
+ await error.getByRole('button', { name: 'Retry' }).click()
+ await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible()
+ expect(attempts).toBe(2)
+ })
})
diff --git a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js
new file mode 100644
index 000000000..e7b0ef6e0
--- /dev/null
+++ b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js
@@ -0,0 +1,783 @@
+import { test, expect } from './coverage-fixtures.js'
+
+const baseNodes = [
+ { id: 'n1', name: 'atlas', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', labels: { zone: 'east' }, total_vram: 100, available_vram: 40, total_ram: 200, available_ram: 100, total_disk: 1000, available_disk: 600, cpu_logical_cores: 8, cpu_usage_percent: 25, cpu_load_1: 1.5, model_count: 3, in_flight_count: 2, last_heartbeat: '2026-09-14T00:00:00Z' },
+ { id: 'n2', name: 'borealis', node_type: 'backend', address: '10.0.0.2:50051', status: 'pending', labels: { zone: 'west' }, total_vram: 100, available_vram: 10, total_ram: 200, available_ram: 10, total_disk: 1000, available_disk: 100, cpu_logical_cores: 16, cpu_usage_percent: 50, cpu_load_1: 4, model_count: 1, in_flight_count: 0 },
+ { id: 'n3', name: 'legacy', node_type: 'agent', address: '10.0.0.3:50051', status: 'offline', labels: {} },
+]
+
+async function mockNodes(page, nodes = baseNodes) {
+ await page.route('**/api/nodes', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(nodes) }))
+}
+
+async function mockFullOperateNavigation(page) {
+ await page.route('**/api/features', route => route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ distributed: true }),
+ }))
+ await page.route('**/api/auth/status', route => route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ authEnabled: true,
+ staticApiKeyRequired: false,
+ providers: ['local'],
+ user: { id: 'admin', name: 'Admin', role: 'admin', provider: 'local' },
+ }),
+ }))
+}
+
+const baseModels = [
+ { id: 'r1', node_id: 'n1', model_name: 'Llama 3.2', replica_index: 0, address: '10.0.0.1:50101', state: 'loaded', in_flight: 2, backend_type: 'llama-cpp', last_used: '2026-09-14T10:00:00Z' },
+ { id: 'r2', node_id: 'n1', model_name: 'Llama 3.2', replica_index: 1, address: '10.0.0.1:50102', state: 'loaded', in_flight: 0, backend_type: 'llama-cpp', last_used: '2026-09-14T10:30:00Z' },
+ { id: 'r3', node_id: 'n2', model_name: 'Llama 3.2', replica_index: 0, address: '10.0.0.2:50101', state: 'loaded', in_flight: 1, backend_type: 'vllm', last_used: '2026-09-14T11:00:00Z' },
+ { id: 'r4', node_id: 'missing', model_name: 'Whisper large v3', replica_index: 0, address: '10.0.0.9:50101', state: 'loaded', in_flight: 0, backend_type: 'whisper', last_used: '2026-09-14T09:00:00Z' },
+]
+
+test.describe('Nodes fleet dashboard', () => {
+ test('uses the standard Operate navigation at a desktop viewport', async ({ page }) => {
+ await mockFullOperateNavigation(page)
+ await mockNodes(page, [baseNodes[0]])
+ await page.goto('/app/nodes')
+
+ const primaryOperate = page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })
+ await expect(primaryOperate).toBeVisible({ timeout: 15_000 })
+ await expect(primaryOperate).toHaveClass(/active/)
+
+ const rail = page.locator('.console-layout > .console-rail')
+ await expect(rail).toBeVisible()
+ await expect(rail.locator('a.nav-item')).toHaveCount(13)
+ await expect(rail.locator('a[href="/app/nodes"]')).toHaveClass(/active/)
+ await expect(rail.locator('a[href$="/swagger/index.html"]')).toHaveAttribute('target', '_blank')
+ })
+
+ test('uses the standard collapsible Operate rail on mobile', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 })
+ await mockFullOperateNavigation(page)
+ await mockNodes(page, [baseNodes[0]])
+ await page.goto('/app/nodes')
+
+ await page.getByRole('button', { name: 'Open menu' }).click()
+ await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toBeVisible()
+ await page.getByRole('button', { name: 'Close menu' }).click()
+
+ const rail = page.locator('.console-layout > .console-rail')
+ await expect(rail).toBeVisible()
+ await expect(rail.locator('.console-rail-groups')).toBeHidden()
+ await rail.getByRole('button', { name: 'Expand Operate navigation' }).click()
+ await expect(rail.locator('.console-rail-groups')).toBeVisible()
+ await expect(rail.locator('a.nav-item')).toHaveCount(13)
+ })
+
+ test('shows aggregate health, capacity, attention filtering, search, sorting, and grouping', async ({ page }) => {
+ await mockNodes(page)
+ await page.goto('/app/nodes')
+ await expect(page.getByLabel('Fleet health summary')).toContainText('3 nodes', { timeout: 15_000 })
+ await expect(page.getByLabel('VRAM capacity')).toContainText('150 B / 200 B')
+ await expect(page.getByLabel('CPU capacity')).toContainText('10 busy / 24 cores')
+ await expect(page.getByLabel('Models disk capacity')).toContainText('1.3 KB / 2 KB')
+ await expect(page.getByRole('button', { name: /Needs attention.*2/ })).toBeVisible()
+ await page.getByRole('button', { name: /Low VRAM/ }).click()
+ await expect(page.getByRole('row', { name: /borealis/ })).toBeVisible()
+ await expect(page.getByRole('row', { name: /atlas/ })).toHaveCount(0)
+ await page.getByRole('button', { name: /Low VRAM/ }).click()
+ await page.getByRole('searchbox', { name: 'Search nodes' }).fill('legacy')
+ await expect(page.getByRole('row', { name: /legacy/ })).toBeVisible()
+ await page.getByRole('searchbox', { name: 'Search nodes' }).fill('')
+ await page.getByRole('button', { name: /Sort by node/ }).click()
+ await expect(page.locator('tbody tr').first()).toContainText('legacy')
+ await page.getByLabel('Group nodes').selectOption('label:zone')
+ await expect(page.getByText('Unlabelled', { exact: true })).toBeVisible()
+ await expect(page.getByText('east', { exact: true })).toBeVisible()
+ })
+
+ test('mounts only 50 rows and clamps pagination for a 1,000-node fleet', async ({ page }) => {
+ const nodes = Array.from({ length: 1000 }, (_, index) => ({ id: `node-${index}`, name: `worker-${String(index).padStart(4, '0')}`, node_type: 'backend', address: `10.0.${Math.floor(index / 255)}.${index % 255}:50051`, status: 'healthy' }))
+ await mockNodes(page, nodes)
+ await page.goto('/app/nodes')
+ await expect(page.locator('tbody tr')).toHaveCount(50, { timeout: 15_000 })
+ await expect(page.getByText('Page 1 of 20')).toBeVisible()
+ await page.getByRole('button', { name: 'Next page' }).click()
+ await expect(page.getByText('Page 2 of 20')).toBeVisible()
+ await page.getByRole('searchbox', { name: 'Search nodes' }).fill('worker-0000')
+ await expect(page.getByText('Page 1 of 1')).toBeVisible()
+ })
+
+ test('filters bulk actions by lifecycle state, reports skipped nodes, and prevents overlapping batches', async ({ page }) => {
+ const nodes = Array.from({ length: 12 }, (_, index) => ({
+ id: `n${index}`,
+ name: `worker-${index}`,
+ node_type: 'backend',
+ status: index === 9 ? 'pending' : index === 10 ? 'draining' : index === 11 ? 'offline' : 'healthy',
+ }))
+ await mockNodes(page, nodes)
+ let active = 0
+ let peak = 0
+ const drainRequests = []
+ const resumeRequests = []
+ await page.route('**/api/nodes/*/drain', async route => {
+ active += 1
+ peak = Math.max(peak, active)
+ await new Promise(resolve => setTimeout(resolve, 30))
+ active -= 1
+ const id = route.request().url().split('/').at(-2)
+ drainRequests.push(id)
+ await route.fulfill({ status: id === 'n8' ? 500 : 200, contentType: 'application/json', body: id === 'n8' ? '{"error":"failed"}' : '{}' })
+ })
+ await page.route('**/api/nodes/*/resume', async route => {
+ const id = route.request().url().split('/').at(-2)
+ resumeRequests.push(id)
+ await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
+ })
+ await page.goto('/app/nodes')
+ await page.getByRole('checkbox', { name: 'Select page' }).check()
+ await page.getByRole('searchbox', { name: 'Search nodes' }).fill('worker-1')
+ await expect(page.getByText('12 selected')).toBeVisible()
+ await page.getByRole('button', { name: 'Drain selected' }).evaluate(button => {
+ button.click()
+ button.click()
+ })
+ await expect.poll(() => drainRequests.length).toBe(9)
+ expect(peak).toBeLessThanOrEqual(8)
+ expect(drainRequests.sort()).toEqual(Array.from({ length: 9 }, (_, index) => `n${index}`).sort())
+ await expect(page.getByText(/8 succeeded, 1 failed, 3 skipped/)).toBeVisible()
+
+ await page.getByRole('button', { name: 'Resume selected' }).click()
+ await expect.poll(() => resumeRequests).toEqual(['n10'])
+ await expect(page.getByText(/1 succeeded, 0 failed, 11 skipped/)).toBeVisible()
+ expect(drainRequests).not.toContain('n9')
+ expect(resumeRequests).not.toContain('n9')
+ })
+
+ test('disables bulk controls and remove confirmation while removal is running', async ({ page }) => {
+ await mockNodes(page, [baseNodes[0]])
+ let finishRemove
+ await page.route('**/api/nodes/n1', async route => {
+ if (route.request().method() !== 'DELETE') return route.fallback()
+ await new Promise(resolve => { finishRemove = resolve })
+ await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
+ })
+ await page.goto('/app/nodes')
+ await page.getByRole('checkbox', { name: 'Select atlas' }).check()
+ await page.getByRole('button', { name: 'Remove selected' }).click()
+ await page.getByRole('button', { name: 'Remove nodes' }).click()
+
+ await expect(page.getByRole('button', { name: 'Removing…' })).toBeDisabled()
+ await expect(page.getByRole('button', { name: 'Cancel' })).toBeDisabled()
+ finishRemove()
+ await expect(page.getByText(/1 succeeded, 0 failed, 0 skipped/)).toBeVisible()
+ })
+
+ test('fetches backends only when an inspector opens and shows unknown legacy metrics', async ({ page }) => {
+ await mockNodes(page)
+ let backendRequests = 0
+ await page.route('**/api/nodes/n3/backends', route => {
+ backendRequests += 1
+ return route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"tool-runner"}]' })
+ })
+ await page.goto('/app/nodes')
+ expect(backendRequests).toBe(0)
+ await page.getByRole('button', { name: 'Inspect legacy' }).click()
+ const inspector = page.getByRole('complementary', { name: 'Node inspector' })
+ await expect(inspector).toContainText('legacy')
+ await expect(inspector).toContainText('No data')
+ await expect(inspector).toContainText('1 backend')
+ expect(backendRequests).toBe(1)
+ await expect(page.getByRole('link', { name: 'Open full node details' })).toHaveAttribute('href', '/app/nodes/n3')
+ })
+
+ test('keeps pending approval visible', async ({ page }) => {
+ await mockNodes(page, [baseNodes[1]])
+ await page.route('**/api/nodes/n2/approve', route => route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
+ await page.goto('/app/nodes')
+ await expect(page.getByRole('button', { name: 'Approve borealis' })).toBeVisible({ timeout: 15_000 })
+ })
+
+ test('shows inspector lifecycle controls only for server-accepted states', async ({ page }) => {
+ const statuses = ['healthy', 'draining', 'pending', 'unhealthy', 'offline', 'unknown']
+ await mockNodes(page, statuses.map((status, index) => ({
+ id: `state-${index}`,
+ name: `node-${status}`,
+ node_type: 'backend',
+ status,
+ })))
+ await page.route('**/api/nodes/*/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.goto('/app/nodes')
+
+ for (const status of statuses) {
+ await page.getByRole('button', { name: `Inspect node-${status}` }).click()
+ const inspector = page.getByRole('complementary', { name: 'Node inspector' })
+ await expect(inspector.getByRole('button', { name: 'Approve', exact: true })).toHaveCount(status === 'pending' ? 1 : 0)
+ await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toHaveCount(status === 'healthy' ? 1 : 0)
+ await expect(inspector.getByRole('button', { name: 'Resume', exact: true })).toHaveCount(status === 'draining' ? 1 : 0)
+ await inspector.getByRole('button', { name: 'Close node inspector' }).click()
+ }
+ })
+
+ test('approves a pending node from the model-to-node drilldown', async ({ page }) => {
+ let status = 'pending'
+ let approvalRequests = 0
+ await page.route('**/api/nodes', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
+ { id: 'pending-node', name: 'pending-worker', node_type: 'backend', status },
+ ]) }))
+ await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
+ { id: 'replica', node_id: 'pending-node', model_name: 'Pending model', replica_index: 0, state: 'loaded' },
+ ]) }))
+ await page.route('**/api/nodes/pending-node/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.route('**/api/nodes/pending-node/approve', async route => {
+ approvalRequests += 1
+ status = 'healthy'
+ await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
+ })
+ await page.goto('/app/nodes')
+ await page.getByRole('tab', { name: 'Running models' }).click()
+ await page.getByRole('button', { name: 'Inspect Pending model' }).click()
+ await page.getByRole('button', { name: 'Open node pending-worker' }).click()
+
+ const inspector = page.getByRole('complementary', { name: 'Node inspector' })
+ await inspector.getByRole('button', { name: 'Approve', exact: true }).click()
+ await expect.poll(() => approvalRequests).toBe(1)
+ await expect(page.getByText('Node approved')).toBeVisible()
+ await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeVisible()
+ })
+
+ test('keeps incomplete capacity unknown throughout the fleet view', async ({ page }) => {
+ await mockNodes(page, [{
+ id: 'incomplete', name: 'incomplete-capacity', node_type: 'backend', status: 'healthy',
+ total_vram: 100, total_ram: 200, total_disk: 300,
+ }])
+ await page.route('**/api/nodes/incomplete/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.goto('/app/nodes')
+
+ await expect(page.getByLabel('VRAM capacity')).toContainText('No data')
+ await expect(page.getByLabel('VRAM capacity')).toContainText('1 node unavailable')
+ await expect(page.getByRole('button', { name: /Low VRAM.*0/ })).toBeVisible()
+ const row = page.getByRole('row', { name: /incomplete-capacity/ })
+ await expect(row.getByText('No data')).toHaveCount(3)
+ await page.getByRole('button', { name: 'Inspect incomplete-capacity' }).click()
+ const inspector = page.getByRole('complementary', { name: 'Node inspector' })
+ await expect(inspector.getByText('No data')).toHaveCount(4)
+ })
+
+ test('announces complete and partial capacity coverage without adding visible clutter', async ({ page }) => {
+ const partialNode = {
+ ...baseNodes[0],
+ id: 'n-partial',
+ name: 'partial-capacity',
+ total_vram: 100,
+ available_vram: 50,
+ total_ram: undefined,
+ available_ram: undefined,
+ }
+ await mockNodes(page, [baseNodes[0], partialNode])
+ await page.goto('/app/nodes')
+
+ const vram = page.getByLabel('VRAM capacity', { exact: true })
+ const ram = page.getByLabel('RAM capacity', { exact: true })
+ await expect(vram).toContainText('Capacity coverage: 2 of 2 nodes reporting; 0 unknown.', { timeout: 15_000 })
+ await expect(ram).toContainText('Capacity coverage: 1 of 2 nodes reporting; 1 unknown.')
+ await expect(vram.locator('.fleet-gauge__coverage')).toHaveCount(0)
+ await expect(ram.locator('.fleet-gauge__coverage')).toHaveText('1 node unavailable')
+ })
+
+ test('keeps checkbox keyboard activation from opening the inspector', async ({ page }) => {
+ await mockNodes(page, [baseNodes[0]])
+ await page.goto('/app/nodes')
+
+ const checkbox = page.getByRole('checkbox', { name: 'Select atlas' })
+ await checkbox.focus()
+ await checkbox.press('Space')
+
+ await expect(checkbox).toBeChecked()
+ await expect(page.getByRole('complementary', { name: 'Node inspector' })).toHaveCount(0)
+ })
+
+ test('styles fleet selection consistently and exposes the partial-page state', async ({ page }) => {
+ await mockNodes(page)
+ await page.goto('/app/nodes')
+
+ const selectPage = page.getByRole('checkbox', { name: 'Select page' })
+ const selectNode = page.getByRole('checkbox', { name: 'Select atlas' })
+ await expect(selectPage).toHaveCSS('width', '18px')
+ await expect(selectNode).toHaveCSS('height', '18px')
+ await expect(selectNode).toHaveCSS('cursor', 'pointer')
+
+ await selectNode.check()
+ await expect.poll(() => selectPage.evaluate(input => input.indeterminate)).toBe(true)
+ })
+
+ test('keeps the low-density composition while inspecting at a desktop viewport', async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 1050 })
+ await mockNodes(page)
+ await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"},{"name":"whisper"}]' }))
+ await page.goto('/app/nodes')
+
+ const overview = page.getByRole('region', { name: 'Fleet overview' })
+ const workbench = page.getByRole('region', { name: 'Fleet workbench' })
+ await expect(overview).toBeVisible({ timeout: 15_000 })
+ await expect(workbench).toBeVisible()
+ await expect(page.locator('.console-layout > .console-rail')).toBeVisible()
+ await expect(page.locator('.fleet-select-wrap')).toHaveCount(3)
+ await expect(page.getByLabel('Filter status')).toHaveCSS('appearance', 'none')
+ await expect(page.locator('.fleet-bulkbar')).toHaveCount(0)
+
+ const overviewBefore = await overview.boundingBox()
+ const fleetBefore = await page.locator('#fleet-nodes-panel').boundingBox()
+ const cells = overview.locator('.fleet-overview__cell')
+ await expect(cells).toHaveCount(5)
+ const cellTops = await cells.evaluateAll(items => items.map(cell => Math.round(cell.getBoundingClientRect().top)))
+ expect(new Set(cellTops).size).toBe(1)
+ const attention = page.getByRole('complementary', { name: 'Attention queue' })
+ await expect(attention).toBeVisible()
+ const overviewBox = await overview.boundingBox()
+ const attentionBox = await attention.boundingBox()
+ expect(attentionBox.y).toBeGreaterThanOrEqual(overviewBox.y + overviewBox.height)
+
+ const checkbox = page.getByRole('checkbox', { name: 'Select atlas' })
+ await checkbox.check()
+ await expect(page.getByRole('row', { name: /atlas/ })).toHaveClass(/is-selected/)
+ await expect(page.locator('.fleet-bulkbar')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Clear selection' })).toBeVisible()
+
+ const inspectNode = page.getByRole('button', { name: 'Inspect atlas' })
+ await inspectNode.focus()
+ await inspectNode.press('Enter')
+ const inspector = page.getByRole('complementary', { name: 'Node inspector' })
+ await expect(inspector).toBeVisible()
+ await expect(inspector.getByRole('heading', { name: 'Node' })).toBeVisible()
+ await expect(inspector.getByRole('heading', { name: 'Resources' })).toBeVisible()
+ await expect(inspector.getByRole('heading', { name: 'Workload' })).toBeVisible()
+ await expect(inspector.locator('.node-inspector__resource')).toHaveCount(2)
+
+ const overviewAfter = await overview.boundingBox()
+ const fleetAfter = await page.locator('#fleet-nodes-panel').boundingBox()
+ expect(Math.abs(overviewAfter.width - overviewBefore.width)).toBeLessThanOrEqual(1)
+ expect(Math.abs(fleetBefore.width - fleetAfter.width)).toBeLessThanOrEqual(1)
+ await expect(inspector).toHaveCSS('position', 'fixed')
+ await expect.poll(async () => {
+ const inspectorBox = await inspector.boundingBox()
+ return Math.max(
+ Math.abs(inspectorBox.x + inspectorBox.width - 1584),
+ Math.abs(inspectorBox.y - 16),
+ Math.abs(inspectorBox.height - 1018),
+ )
+ }).toBeLessThanOrEqual(1)
+ })
+
+ test('reflows the overview and presents a contained drawer at a narrow viewport', async ({ page }) => {
+ await page.setViewportSize({ width: 640, height: 900 })
+ await mockNodes(page, [baseNodes[0]])
+ await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.goto('/app/nodes')
+
+ const overview = page.getByRole('region', { name: 'Fleet overview' })
+ await expect(overview).toBeVisible({ timeout: 15_000 })
+ await expect(page.locator('.console-layout > .console-rail')).toBeVisible()
+ const overviewBox = await overview.boundingBox()
+ expect(overviewBox.width).toBeGreaterThan(500)
+ const cells = overview.locator('.fleet-overview__cell')
+ const tops = await cells.evaluateAll(items => items.map(item => Math.round(item.getBoundingClientRect().top)))
+ expect(new Set(tops).size).toBeGreaterThan(1)
+ await expect(overview.getByLabel('Fleet health summary')).toHaveCSS('grid-column-start', '1')
+ await expect(overview.getByLabel('Fleet health summary')).toHaveCSS('grid-column-end', '-1')
+
+ const narrowInspectNode = page.getByRole('button', { name: 'Inspect atlas' })
+ await narrowInspectNode.focus()
+ await narrowInspectNode.press('Enter')
+ const inspector = page.getByRole('dialog', { name: 'Node inspector' })
+ await expect(inspector).toBeVisible()
+ await expect(inspector).toHaveAttribute('aria-modal', 'true')
+ await expect(inspector).toHaveCSS('position', 'fixed')
+ await expect(page.locator('.node-inspector__scrim')).toBeVisible()
+ await expect(page.locator('body')).toHaveCSS('overflow', 'hidden')
+ await expect(page.getByRole('region', { name: 'Fleet workbench', includeHidden: true })).toHaveAttribute('inert', '')
+ await expect(page.getByRole('region', { name: 'Fleet workbench', includeHidden: true })).toHaveAttribute('aria-hidden', 'true')
+ const workbenchBox = await page.locator('.fleet-workbench').boundingBox()
+ expect(workbenchBox.width).toBeLessThanOrEqual(600)
+ await expect.poll(async () => (await inspector.boundingBox()).y).toBeLessThanOrEqual(1)
+ const inspectorBox = await inspector.boundingBox()
+ expect(inspectorBox.height).toBe(900)
+ const close = inspector.getByRole('button', { name: 'Close node inspector' })
+ await expect(close).toBeFocused()
+ await close.press('Shift+Tab')
+ await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeFocused()
+ await page.keyboard.press('Tab')
+ await expect(close).toBeFocused()
+ await page.keyboard.press('Escape')
+ await expect(inspector).toHaveCount(0)
+ await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden')
+ await expect(page.getByRole('region', { name: 'Fleet workbench' })).not.toHaveAttribute('inert', '')
+ await expect(page.getByRole('region', { name: 'Fleet workbench' })).not.toHaveAttribute('aria-hidden', 'true')
+ await expect(narrowInspectNode).toBeFocused()
+ })
+
+ test('loads running models once on activation and drills model to node and back', async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 1050 })
+ await mockNodes(page, baseNodes.map(node => node.id === 'n2' ? { ...node, status: 'healthy' } : node))
+ let modelRequests = 0
+ let backendRequests = 0
+ await page.route('**/api/nodes/models', route => {
+ modelRequests += 1
+ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) })
+ })
+ await page.route('**/api/nodes/n1/backends', route => {
+ backendRequests += 1
+ return route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"}]' })
+ })
+ await page.goto('/app/nodes')
+ await expect(page.getByRole('table', { name: 'Fleet nodes' })).toBeVisible({ timeout: 15_000 })
+ expect(modelRequests).toBe(0)
+ expect(backendRequests).toBe(0)
+
+ const nodesTab = page.getByRole('tab', { name: 'Nodes' })
+ const modelsTab = page.getByRole('tab', { name: 'Running models' })
+ const nodesPanel = page.locator('#fleet-nodes-panel')
+ const modelsPanel = page.locator('#fleet-models-panel')
+ await expect(nodesTab).toHaveAttribute('aria-controls', 'fleet-nodes-panel')
+ await expect(nodesTab).toHaveAttribute('tabindex', '0')
+ await expect(modelsTab).toHaveAttribute('aria-controls', 'fleet-models-panel')
+ await expect(modelsTab).toHaveAttribute('tabindex', '-1')
+ await expect(nodesPanel).toHaveAttribute('role', 'tabpanel')
+ await expect(nodesPanel).toHaveAttribute('aria-labelledby', 'fleet-nodes-tab')
+ await expect(nodesPanel).not.toHaveAttribute('hidden', '')
+ await expect(nodesPanel).toBeVisible()
+ await expect(modelsPanel).toHaveAttribute('role', 'tabpanel')
+ await expect(modelsPanel).toHaveAttribute('aria-labelledby', 'fleet-models-tab')
+ await expect(modelsPanel).toHaveAttribute('hidden', '')
+ await expect(modelsPanel).toBeHidden()
+ await expect(modelsPanel.getByRole('table', { name: 'Running models' })).toHaveCount(0)
+ await expect(nodesPanel.locator('tbody tr')).toHaveCount(3)
+
+ await nodesTab.focus()
+ await nodesTab.press('ArrowRight')
+ await expect(modelsTab).toBeFocused()
+ await expect(modelsTab).toHaveAttribute('aria-selected', 'true')
+ await expect(modelsTab).toHaveAttribute('tabindex', '0')
+ await expect(nodesTab).toHaveAttribute('tabindex', '-1')
+ await expect(nodesPanel).toHaveAttribute('hidden', '')
+ await expect(nodesPanel).toBeHidden()
+ await expect(modelsPanel).not.toHaveAttribute('hidden', '')
+ await expect(modelsPanel).toBeVisible()
+ await expect(page.getByText('Current loaded replicas on healthy nodes')).toBeVisible()
+ await expect(page.getByRole('table', { name: 'Running models' })).toBeVisible()
+ await expect(page.getByRole('row', { name: /Llama 3.2/ })).toContainText('3')
+ expect(modelRequests).toBe(1)
+ expect(backendRequests).toBe(0)
+
+ const modelControl = page.getByRole('button', { name: 'Inspect Llama 3.2' })
+ await expect(modelControl).not.toHaveAttribute('aria-selected')
+ await expect(modelControl).toHaveAttribute('aria-pressed', 'false')
+ await expect(modelControl).toHaveAttribute('aria-expanded', 'false')
+ await expect(modelControl).not.toHaveAttribute('aria-controls')
+ await modelControl.focus()
+ await modelControl.press('Enter')
+ const modelInspector = page.getByRole('complementary', { name: 'Model inspector' })
+ const closeModel = page.getByRole('button', { name: 'Close model inspector' })
+ await expect(closeModel).toBeFocused()
+ await expect(modelControl).not.toHaveAttribute('aria-selected')
+ await expect(modelControl).toHaveAttribute('aria-pressed', 'true')
+ await expect(modelControl).toHaveAttribute('aria-expanded', 'true')
+ await expect(modelControl).toHaveAttribute('aria-current', 'true')
+ await expect(modelControl).toHaveAttribute('aria-controls', 'model-inspector')
+ await expect(modelInspector).toContainText('2 replicas')
+ await expect(modelInspector).toContainText('borealis')
+ const atlasControl = modelInspector.getByRole('button', { name: /Open node atlas/ })
+ await atlasControl.focus()
+ await atlasControl.press('Enter')
+ await expect(page.getByRole('complementary', { name: 'Node inspector' })).toBeVisible()
+ await expect(modelControl).toHaveAttribute('aria-pressed', 'true')
+ await expect(modelControl).toHaveAttribute('aria-expanded', 'false')
+ await expect(modelControl).not.toHaveAttribute('aria-controls')
+ const backToModel = page.getByRole('button', { name: 'Back to Llama 3.2' })
+ await expect(backToModel).toBeFocused()
+ await expect.poll(() => backendRequests).toBe(1)
+ expect(modelRequests).toBe(1)
+ await backToModel.press('Enter')
+ await expect(page.getByRole('complementary', { name: 'Model inspector' })).toBeVisible()
+ await expect(page.getByRole('button', { name: /Open node atlas/ })).toBeFocused()
+ await closeModel.click()
+ await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0)
+ await expect(modelControl).toBeFocused()
+ await expect(modelControl).not.toHaveAttribute('aria-selected')
+ await expect(modelControl).toHaveAttribute('aria-pressed', 'false')
+ await expect(modelControl).toHaveAttribute('aria-expanded', 'false')
+ await expect(modelControl).not.toHaveAttribute('aria-controls')
+
+ await page.getByRole('button', { name: 'Inspect Whisper large v3' }).click()
+ await expect(page.getByRole('complementary', { name: 'Model inspector' })).toContainText('missing')
+ await expect(page.getByRole('complementary', { name: 'Model inspector' })).toContainText('Unknown')
+ await page.getByRole('button', { name: 'Close model inspector' }).click()
+
+ await modelsTab.focus()
+ await modelsTab.press('ArrowLeft')
+ await expect(nodesTab).toBeFocused()
+ await expect(nodesTab).toHaveAttribute('aria-selected', 'true')
+ await expect(nodesPanel).not.toHaveAttribute('hidden', '')
+ await expect(modelsPanel).toHaveAttribute('hidden', '')
+ await expect(modelsPanel.locator('tbody tr')).toHaveCount(2)
+ await nodesTab.press('ArrowRight')
+ await expect(modelsPanel.locator('tbody tr')).toHaveCount(2)
+ expect(modelRequests).toBe(1)
+ })
+
+ test('opens logs directly for one replica and asks for placement when a model has several', async ({ page }) => {
+ await mockNodes(page, baseNodes.map(node => node.id === 'n2' ? { ...node, status: 'healthy' } : node))
+ await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) }))
+ await page.goto('/app/nodes')
+ await page.getByRole('tab', { name: 'Running models' }).click()
+
+ await page.getByRole('button', { name: 'Actions for Whisper large v3' }).click()
+ await page.getByRole('menuitem', { name: 'View logs…' }).click()
+ await expect(page).toHaveURL(/\/app\/node-backend-logs\/missing\/Whisper%20large%20v3%230$/)
+
+ await page.goto('/app/nodes')
+ await page.getByRole('tab', { name: 'Running models' }).click()
+ await page.getByRole('button', { name: 'Actions for Llama 3.2' }).click()
+ await page.getByRole('menuitem', { name: 'View logs…' }).click()
+
+ const inspector = page.getByRole('complementary', { name: 'Model inspector' })
+ await expect(inspector).toBeVisible()
+ await expect(inspector.getByRole('button', { name: 'View all Llama 3.2 logs on atlas' })).toBeVisible()
+ await inspector.getByRole('button', { name: 'View logs for Llama 3.2 replica 1 on atlas' }).click()
+ await expect(page).toHaveURL(/\/app\/node-backend-logs\/n1\/Llama%203.2%230$/)
+ })
+
+ test('treats the model inspector as a modal drawer on mobile', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 })
+ await mockNodes(page)
+ await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) }))
+ await page.goto('/app/nodes')
+ await page.getByRole('tab', { name: 'Running models' }).click()
+ await page.getByRole('button', { name: 'Inspect Llama 3.2' }).click()
+
+ const inspector = page.getByRole('dialog', { name: 'Model inspector' })
+ await expect(inspector).toHaveAttribute('aria-modal', 'true')
+ await expect(inspector.getByRole('button', { name: 'Close model inspector' })).toBeFocused()
+ await expect(page.locator('.fleet-workbench')).toHaveAttribute('inert', '')
+ await inspector.getByRole('button', { name: 'Close model inspector' }).press('Shift+Tab')
+ await expect(inspector.getByRole('button', { name: 'Close', exact: true })).toBeFocused()
+ await page.keyboard.press('Tab')
+ await expect(inspector.getByRole('button', { name: 'Close model inspector' })).toBeFocused()
+ })
+
+ test('keeps the node inspector open when Escape dismisses its confirmation dialog', async ({ page }) => {
+ await mockNodes(page)
+ await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.goto('/app/nodes')
+ await page.getByRole('checkbox', { name: 'Select atlas' }).check()
+ await page.getByRole('button', { name: 'Inspect atlas' }).click()
+ const inspector = page.getByRole('complementary', { name: 'Node inspector' })
+ await expect(inspector).toBeVisible()
+
+ await page.getByRole('button', { name: 'Remove selected' }).click()
+ await expect(page.getByRole('alertdialog')).toBeVisible()
+ await page.keyboard.press('Escape')
+
+ await expect(page.getByRole('alertdialog')).toHaveCount(0)
+ await expect(inspector).toBeVisible()
+ })
+
+ test('stops a running model once from an accessible row menu and refreshes inventory', async ({ page }) => {
+ await mockNodes(page)
+ let modelRequests = 0
+ let stopRequests = 0
+ let stopBody
+ let finishStop
+ await page.route('**/api/nodes/models', route => {
+ modelRequests += 1
+ const rows = modelRequests === 1 ? baseModels : baseModels.filter(row => row.model_name !== 'Llama 3.2')
+ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rows) })
+ })
+ await page.route('**/backend/shutdown', async route => {
+ stopRequests += 1
+ stopBody = route.request().postDataJSON()
+ await new Promise(resolve => { finishStop = resolve })
+ await route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' })
+ })
+ await page.goto('/app/nodes')
+ const modelsTab = page.getByRole('tab', { name: 'Running models' })
+ await modelsTab.click()
+
+ const trigger = page.getByRole('button', { name: 'Actions for Llama 3.2' })
+ await expect(trigger).toBeVisible()
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
+ await expect(modelsTab).toBeFocused()
+
+ await trigger.focus()
+ await trigger.press('Enter')
+ const menu = page.getByRole('menu', { name: 'Llama 3.2 actions' })
+ await expect(menu).toBeVisible()
+ await expect(menu).toBeFocused()
+ await menu.press('Escape')
+ await expect(menu).toHaveCount(0)
+ await expect(trigger).toBeFocused()
+ await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0)
+
+ await trigger.click()
+ await page.locator('.model-workbench__scope').click()
+ await expect(menu).toHaveCount(0)
+ await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0)
+
+ await trigger.click()
+ await menu.getByRole('menuitem', { name: 'Stop model…' }).click()
+ const dialog = page.getByRole('alertdialog')
+ await expect(dialog).toContainText('Stop Llama 3.2?')
+ await expect(dialog).toContainText('Llama 3.2 has 3 loaded replicas across 2 unique nodes. This will stop all loaded placements on those nodes.')
+ await expect(dialog.getByRole('button', { name: 'Stop model' })).toBeFocused()
+ await page.keyboard.press('Tab')
+ await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused()
+ await page.keyboard.press('Shift+Tab')
+ await expect(dialog.getByRole('button', { name: 'Stop model' })).toBeFocused()
+ await dialog.getByRole('button', { name: 'Cancel' }).click()
+ await expect(dialog).toHaveCount(0)
+ await expect(trigger).toBeFocused()
+
+ await trigger.click()
+ await menu.getByRole('menuitem', { name: 'Stop model…' }).click()
+ await dialog.getByRole('button', { name: 'Stop model' }).evaluate(button => {
+ button.click()
+ button.click()
+ })
+
+ await expect(dialog.getByRole('button', { name: 'Stopping…' })).toBeDisabled()
+ await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeDisabled()
+ await expect.poll(() => stopRequests).toBe(1)
+ expect(stopBody).toEqual({ model: 'Llama 3.2' })
+ finishStop()
+ await expect.poll(() => modelRequests).toBe(2)
+ await expect(page.getByText('Stopped Llama 3.2: 3 replicas across 2 nodes.')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Inspect Llama 3.2' })).toHaveCount(0)
+ })
+
+ test('refreshes model inventory and warns about partial shutdown after a stop failure', async ({ page }) => {
+ await mockNodes(page)
+ let modelRequests = 0
+ let stopRequests = 0
+ await page.route('**/api/nodes/models', route => {
+ modelRequests += 1
+ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) })
+ })
+ await page.route('**/backend/shutdown', route => {
+ stopRequests += 1
+ return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"controller timed out"}' })
+ })
+ await page.goto('/app/nodes')
+ await page.getByRole('tab', { name: 'Running models' }).click()
+ await page.getByRole('button', { name: 'Actions for Whisper large v3' }).click()
+ await page.getByRole('menuitem', { name: 'Stop model…' }).click()
+ await page.getByRole('alertdialog').getByRole('button', { name: 'Stop model' }).click()
+
+ await expect.poll(() => stopRequests).toBe(1)
+ await expect.poll(() => modelRequests).toBe(2)
+ await expect(page.getByText(/Could not stop Whisper large v3:.*Some replicas may already have stopped\./)).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Inspect Whisper large v3' })).toBeVisible()
+ })
+
+ test('moves focus into and restores it from the node inspector', async ({ page }) => {
+ await mockNodes(page, [baseNodes[0]])
+ await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.goto('/app/nodes')
+
+ const nodeControl = page.getByRole('button', { name: 'Inspect atlas' })
+ await nodeControl.click()
+ const closeNode = page.getByRole('button', { name: 'Close node inspector' })
+ await expect(closeNode).toBeFocused()
+ await closeNode.click()
+ await expect(nodeControl).toBeFocused()
+ })
+
+ test('keeps drawer actions visible for a one-row filtered fleet', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 })
+ await mockNodes(page, baseNodes)
+ await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"}]' }))
+ await page.goto('/app/nodes')
+ await page.getByRole('searchbox', { name: 'Search nodes' }).fill('atlas')
+ await expect(page.getByRole('row', { name: /atlas/ })).toBeVisible()
+ await page.getByRole('button', { name: 'Inspect atlas' }).click()
+
+ const inspector = page.getByRole('complementary', { name: 'Node inspector' })
+ await expect(inspector.getByRole('heading', { name: 'Resources' })).toBeVisible()
+ await expect(inspector.getByRole('heading', { name: 'Workload' })).toBeVisible()
+ await expect(inspector.getByRole('link', { name: 'Open full node details' })).toBeVisible()
+ await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeVisible()
+
+ const inspectorBox = await inspector.boundingBox()
+ expect(Math.abs(inspectorBox.y - 16)).toBeLessThanOrEqual(1)
+ expect(Math.abs(inspectorBox.height - 868)).toBeLessThanOrEqual(1)
+ await expect(inspector.locator('.node-inspector__actions')).toHaveCSS('display', 'grid')
+ })
+
+ test('keeps a desktop drawer in the visible viewport after opening from a long roster', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 })
+ const nodes = Array.from({ length: 50 }, (_, index) => ({
+ id: `long-${index}`,
+ name: `long-worker-${String(index).padStart(2, '0')}`,
+ node_type: 'backend',
+ status: 'healthy',
+ }))
+ await mockNodes(page, nodes)
+ await page.route('**/api/nodes/long-49/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.goto('/app/nodes')
+
+ await page.getByRole('button', { name: 'Inspect long-worker-49' }).click()
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(1000)
+ const inspector = page.getByRole('complementary', { name: 'Node inspector' })
+ await expect(inspector).toHaveCSS('position', 'fixed')
+ await expect(inspector.getByRole('heading', { name: 'long-worker-49' })).toBeVisible()
+ await expect(inspector.getByRole('link', { name: 'Open full node details' })).toBeVisible()
+ const box = await inspector.boundingBox()
+ expect(Math.abs(box.y - 16)).toBeLessThanOrEqual(1)
+ expect(Math.abs(box.height - 768)).toBeLessThanOrEqual(1)
+ })
+
+ test('shows model loading, error, retry, and empty states', async ({ page }) => {
+ await mockNodes(page)
+ let finishFirst
+ let attempts = 0
+ await page.route('**/api/nodes/models', async route => {
+ attempts += 1
+ if (attempts === 1) {
+ await new Promise(resolve => { finishFirst = resolve })
+ return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"database unavailable"}' })
+ }
+ return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
+ })
+ await page.goto('/app/nodes')
+ await page.getByRole('tab', { name: 'Running models' }).click()
+ await expect(page.getByText('Loading running models…')).toBeVisible()
+ finishFirst()
+ await expect(page.getByText('Unable to load running models')).toBeVisible()
+ await page.getByRole('button', { name: 'Retry loading running models' }).click()
+ await expect(page.getByText('No running models')).toBeVisible()
+ expect(attempts).toBe(2)
+ })
+
+ test('mounts 50 of 1,000 running models and supports search and sorting', async ({ page }) => {
+ await mockNodes(page)
+ const models = Array.from({ length: 1000 }, (_, index) => ({
+ id: `replica-${index}`,
+ node_id: 'n1',
+ model_name: `model-${String(index).padStart(4, '0')}`,
+ replica_index: 0,
+ address: `10.0.0.1:${51000 + index}`,
+ state: 'loaded',
+ in_flight: index % 7,
+ backend_type: 'llama-cpp',
+ last_used: new Date(Date.UTC(2026, 8, 1, 0, index)).toISOString(),
+ }))
+ await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(models) }))
+ await page.goto('/app/nodes')
+ await page.getByRole('tab', { name: 'Running models' }).click()
+ await expect(page.getByRole('table', { name: 'Running models' }).locator('tbody tr')).toHaveCount(50)
+ await expect(page.getByText('Page 1 of 20')).toBeVisible()
+ await page.getByRole('button', { name: 'Next model page' }).click()
+ await expect(page.getByText('Page 2 of 20')).toBeVisible()
+ await page.getByRole('searchbox', { name: 'Search running models' }).fill('model-0000')
+ await expect(page.locator('#fleet-models-panel').getByText('Page 1 of 1')).toBeVisible()
+ await page.getByRole('searchbox', { name: 'Search running models' }).fill('')
+ await page.getByRole('button', { name: /Sort by model/ }).click()
+ await expect(page.getByRole('table', { name: 'Running models' }).locator('tbody tr').first()).toContainText('model-0999')
+ })
+
+})
diff --git a/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js b/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js
index 33d871442..3feef9693 100644
--- a/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js
+++ b/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js
@@ -97,6 +97,16 @@ async function openNodeDetail(page) {
await expect(page.getByRole('cell', { name: BACKEND_NAME, exact: true })).toBeVisible({ timeout: 10_000 })
}
+async function openBackendActions(page) {
+ const trigger = page.getByRole('button', { name: `Actions for backend ${BACKEND_NAME}` })
+ await expect(trigger).toBeVisible()
+ await trigger.click()
+
+ const menu = page.getByRole('menu', { name: `${BACKEND_NAME} backend actions` })
+ await expect(menu).toBeVisible()
+ return menu
+}
+
test.describe('Nodes page — per-node backend actions', () => {
test('upgrade affordance is self-explanatory (not "Reinstall backend" with a sync icon)', async ({ page }) => {
await mockDistributedNodes(page)
@@ -105,26 +115,25 @@ test.describe('Nodes page — per-node backend actions', () => {
await expect(page.locator('.node-detail__metrics')).toContainText('RAM')
await expect(page.locator('.node-detail__metrics')).toContainText('3.7 GB / 7.5 GB')
- // Negative: the old, ambiguous wording must not be used.
- await expect(page.locator('button[title="Reinstall backend"]')).toHaveCount(0)
- await expect(page.locator('button[title="Reinstall backend"] i.fa-sync-alt')).toHaveCount(0)
+ const menu = await openBackendActions(page)
- // Positive: a self-explanatory upgrade affordance is rendered next to the
- // backend row. We accept either an arrow-up or arrows-rotate glyph; both
- // map to "upgrade" semantics in FontAwesome 6 unambiguously.
- const upgradeBtn = page.locator('button[title="Upgrade backend on this node"]')
- await expect(upgradeBtn).toBeVisible()
- const iconClass = await upgradeBtn.locator('i').getAttribute('class')
- expect(iconClass).toMatch(/fa-(arrow-up|arrows-rotate|up-long)/)
+ // Negative: the old, ambiguous wording must not be used.
+ await expect(menu.getByRole('menuitem', { name: 'Reinstall backend' })).toHaveCount(0)
+
+ // Positive: the action menu names the operation and uses an upgrade icon.
+ const upgradeItem = menu.getByRole('menuitem', { name: 'Upgrade backend' })
+ await expect(upgradeItem).toBeVisible()
+ await expect(upgradeItem.locator('i.fa-arrow-up')).toBeVisible()
})
test('per-node backend row shows a delete (trash) button next to upgrade', async ({ page }) => {
await mockDistributedNodes(page)
await openNodeDetail(page)
- const deleteBtn = page.locator('button[title="Delete backend from this node"]')
- await expect(deleteBtn).toBeVisible()
- await expect(deleteBtn.locator('i.fa-trash')).toBeVisible()
+ const menu = await openBackendActions(page)
+ const deleteItem = menu.getByRole('menuitem', { name: 'Delete backend…' })
+ await expect(deleteItem).toBeVisible()
+ await expect(deleteItem.locator('i.fa-trash')).toBeVisible()
})
test('clicking delete opens the confirm dialog and POSTs to the per-node delete endpoint', async ({ page }) => {
@@ -136,7 +145,8 @@ test.describe('Nodes page — per-node backend actions', () => {
})
await openNodeDetail(page)
- await page.locator('button[title="Delete backend from this node"]').click()
+ const menu = await openBackendActions(page)
+ await menu.getByRole('menuitem', { name: 'Delete backend…' }).click()
// ConfirmDialog uses role="alertdialog" and a danger confirm button.
const dialog = page.getByRole('alertdialog')
@@ -158,7 +168,8 @@ test.describe('Nodes page — per-node backend actions', () => {
})
await openNodeDetail(page)
- await page.locator('button[title="Delete backend from this node"]').click()
+ const menu = await openBackendActions(page)
+ await menu.getByRole('menuitem', { name: 'Delete backend…' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toBeVisible()
diff --git a/core/http/react-ui/e2e/nodes-roster.spec.js b/core/http/react-ui/e2e/nodes-roster.spec.js
index c6396d5b3..721f99766 100644
--- a/core/http/react-ui/e2e/nodes-roster.spec.js
+++ b/core/http/react-ui/e2e/nodes-roster.spec.js
@@ -1,64 +1,35 @@
import { test, expect } from './coverage-fixtures.js'
async function mockCluster(page, nodes) {
- await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(nodes) }))
- await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
- await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
+ await page.route('**/api/nodes', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(nodes) }))
}
-test.describe('Nodes roster header', () => {
- test('shows a cluster pulse line and no stat-card grid', async ({ page }) => {
+test.describe('Nodes fleet roster', () => {
+ test('uses the fleet response without prefetching models or backends', async ({ page }) => {
+ const requests = []
+ page.on('request', request => requests.push(request.url()))
await mockCluster(page, [
- { id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' },
- { id: 'n2', name: 'beta', node_type: 'backend', address: '10.0.0.2:50051', status: 'draining' },
+ { id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', model_count: 3 },
+ { id: 'a1', name: 'agent-1', node_type: 'agent', address: '10.0.0.9:50051', status: 'draining', model_count: 0 },
])
await page.goto('/app/nodes')
- await expect(page.locator('.cluster-pulse')).toBeVisible({ timeout: 15_000 })
- await expect(page.locator('.cluster-pulse')).toContainText('2 nodes')
- await expect(page.locator('.stat-grid')).toHaveCount(0)
+ await expect(page.getByRole('table', { name: 'Fleet nodes' })).toBeVisible({ timeout: 15_000 })
+ await expect(page.getByRole('tab', { name: 'Nodes' })).toHaveAttribute('aria-selected', 'true')
+ await page.getByRole('tab', { name: 'Nodes' }).click()
+ await expect(page.getByRole('row', { name: /alpha/ })).toContainText('3')
+ expect(requests.some(url => url.includes('/api/nodes/models'))).toBe(false)
+ expect(requests.some(url => /\/api\/nodes\/[^/]+\/backends/.test(url))).toBe(false)
})
- test('shows an approval callout for pending nodes', async ({ page }) => {
- await mockCluster(page, [{ id: 'n3', name: 'gamma', node_type: 'backend', address: '10.0.0.3:50051', status: 'pending' }])
+ test('preserves the empty worker setup experience', async ({ page }) => {
+ await mockCluster(page, [])
await page.goto('/app/nodes')
- await expect(page.locator('.attention-callout')).toContainText('approval', { timeout: 15_000 })
- })
-})
-
-test.describe('Nodes roster panels', () => {
- test('shows used and total system RAM reported by a worker', async ({ page }) => {
- await mockCluster(page, [
- {
- id: 'n1',
- name: 'alpha',
- node_type: 'backend',
- address: '10.0.0.1:50051',
- status: 'healthy',
- total_ram: 8_000_000_000,
- available_ram: 3_000_000_000,
- },
- ])
-
- await page.goto('/app/nodes')
- await expect(page.locator('.node-panel').filter({ hasText: 'alpha' })).toContainText('RAM 4.7 GB / 7.5 GB', { timeout: 15_000 })
- })
-
- test('shows model chips without clicking and filters by type', async ({ page }) => {
- await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
- { id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' },
- { id: 'a1', name: 'agent-1', node_type: 'agent', address: '10.0.0.9:50051', status: 'healthy' },
- ]) }))
- await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
- { node_id: 'n1', model_name: 'llama-3.3', state: 'loaded', in_flight: 2, replica_index: 0 },
- ]) }))
- await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
-
- await page.goto('/app/nodes')
- // model chip visible without any expand click
- await expect(page.locator('.node-panel').filter({ hasText: 'alpha' }).getByText('llama-3.3')).toBeVisible({ timeout: 15_000 })
- // segmented filter: Agent shows the agent node, hides the backend node
- await page.getByRole('radio', { name: /Agent/ }).click()
- await expect(page.getByText('agent-1')).toBeVisible()
- await expect(page.getByText('alpha')).toHaveCount(0)
+ await expect(page.getByText('No workers registered yet')).toBeVisible({ timeout: 15_000 })
+ })
+
+ test('preserves the distributed-disabled setup experience', async ({ page }) => {
+ await page.route('**/api/nodes', route => route.fulfill({ status: 503, body: 'Service Unavailable' }))
+ await page.goto('/app/nodes')
+ await expect(page.getByText('Distributed Mode Not Enabled')).toBeVisible({ timeout: 15_000 })
})
})
diff --git a/core/http/react-ui/e2e/threed-animation.spec.js b/core/http/react-ui/e2e/threed-animation.spec.js
new file mode 100644
index 000000000..8b38a9320
--- /dev/null
+++ b/core/http/react-ui/e2e/threed-animation.spec.js
@@ -0,0 +1,185 @@
+import { test, expect } from './coverage-fixtures.js'
+
+const animationOperation = {
+ id: 'animate', endpoint: '/3d/animate', output: 'skeleton_animation',
+ inputs: [{ name: 'prompt', type: 'text', label: 'Motion prompt', required: true, max_bytes: 4096 }],
+ parameters: [
+ { name: 'frames', label: 'Frames (30 FPS)', type: 'integer', default: '150', min: 60, max: 150 },
+ { name: 'steps', label: 'Sampling steps', type: 'integer', default: '100', min: 1, max: 1000, advanced: true },
+ { name: 'seed', label: 'Seed', type: 'uint64', default: '0', advanced: true },
+ ],
+}
+
+function animationGlb(change = () => {}) {
+ const values = new Float32Array([0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0.70710678, 0, 0.70710678])
+ const document = {
+ asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }],
+ nodes: [{ name: 'root', children: [1] }, { name: 'joint', translation: [0, 1, 0] }],
+ buffers: [{ byteLength: values.byteLength }],
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 8 }, { buffer: 0, byteOffset: 8, byteLength: 24 }, { buffer: 0, byteOffset: 32, byteLength: 32 }],
+ accessors: [{ bufferView: 0, componentType: 5126, count: 2, type: 'SCALAR' }, { bufferView: 1, componentType: 5126, count: 2, type: 'VEC3' }, { bufferView: 2, componentType: 5126, count: 2, type: 'VEC4' }],
+ animations: [{ samplers: [{ input: 0, output: 1 }, { input: 0, output: 2 }], channels: [{ sampler: 0, target: { node: 0, path: 'translation' } }, { sampler: 1, target: { node: 0, path: 'rotation' } }] }],
+ }
+ change(document)
+ let json = JSON.stringify(document)
+ json += ' '.repeat((4-json.length%4)%4)
+ const output = Buffer.alloc(28+json.length+values.byteLength)
+ output.writeUInt32LE(0x46546c67, 0); output.writeUInt32LE(2, 4); output.writeUInt32LE(output.length, 8)
+ output.writeUInt32LE(json.length, 12); output.writeUInt32LE(0x4e4f534a, 16); output.write(json, 20)
+ output.writeUInt32LE(values.byteLength, 20+json.length); output.writeUInt32LE(0x004e4942, 24+json.length)
+ Buffer.from(values.buffer).copy(output, 28+json.length)
+ return output
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [
+ { id: 'trellis-test', capabilities: ['FLAG_3D'] },
+ { id: 'kimodo-test', capabilities: ['FLAG_3D_ANIMATION'], three_d_operations: [animationOperation] },
+ ] } }))
+ await page.route('**/generated-3d/animation.glb', route => route.fulfill({ contentType: 'model/gltf-binary', body: animationGlb() }))
+})
+
+test('switches inputs, generates animation, scrubs playback and restores history', async ({ page }) => {
+ let request
+ await page.route('**/3d/animate', route => {
+ request = route.request().postDataJSON()
+ return route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } })
+ })
+ await page.goto('/app/studio/threed')
+ await page.getByRole('button', { name: 'trellis-test', exact: true }).click()
+ await page.getByRole('option', { name: 'kimodo-test', exact: true }).click()
+ await expect(page.locator('#threed-image-file')).toHaveCount(0)
+ await page.getByLabel('Motion prompt').fill('Walk forward and wave')
+ await page.getByLabel('Frames (30 FPS)').fill('90')
+ await page.getByRole('button', { name: 'Advanced', exact: false }).click()
+ await page.getByLabel('Seed', { exact: true }).fill('18446744073709551615')
+ await page.locator('button[type="submit"]').click()
+ await expect(page.getByTestId('animation-viewer')).toBeVisible()
+ await expect(page.getByTestId('animation-time')).toBeVisible()
+ expect(request.inputs).toEqual({ prompt: { type: 'text', data: 'Walk forward and wave' } })
+ expect(request.params).toEqual({ frames: '90', seed: '18446744073709551615' })
+ expect(request).not.toHaveProperty('image')
+ expect(request).not.toHaveProperty('quality')
+ await expect(page.getByTestId('glb-remesh')).toHaveCount(0)
+ await page.getByRole('button', { name: 'Pause', exact: true }).click()
+ await page.getByLabel('Timeline', { exact: true }).fill('0.5')
+ await expect(page.getByTestId('animation-time')).toContainText('0.50')
+ await page.getByLabel('Zoom', { exact: true }).fill('1.5')
+ await page.getByRole('button', { name: 'Reset view', exact: true }).click()
+ await expect(page.getByLabel('Zoom', { exact: true })).toHaveValue('1')
+ await expect(page.getByTestId('glb-download')).toBeVisible()
+ await expect(page.getByTestId('media-history-item')).toContainText('Walk forward and wave')
+ await page.getByLabel('Motion prompt').fill('A different prompt')
+ await page.getByTestId('media-history-item').click()
+ await expect(page.getByLabel('Motion prompt')).toHaveValue('Walk forward and wave')
+ await page.reload()
+ await page.getByTestId('media-history-item').click()
+ await expect(page.getByTestId('animation-viewer')).toBeVisible()
+ await expect(page.getByLabel('Motion prompt')).toHaveValue('Walk forward and wave')
+ await page.getByRole('button', { name: 'kimodo-test', exact: true }).click()
+ await page.getByRole('option', { name: /^trellis-test/ }).click()
+ await expect(page.locator('#threed-image-file')).toBeAttached()
+ await expect(page.getByLabel('Motion prompt')).toHaveCount(0)
+})
+
+test('plays in real time across delayed frames, looping, pause and seek', async ({ page }) => {
+ await page.route('**/3d/animate', route => route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } }))
+ await page.goto('/app/studio/threed')
+ await page.getByRole('button', { name: 'trellis-test', exact: true }).click()
+ await page.getByRole('option', { name: 'kimodo-test', exact: true }).click()
+ await page.getByLabel('Motion prompt').fill('Walk.')
+ await page.getByRole('button', { name: /Generate$/ }).click()
+ await page.getByRole('button', { name: 'Pause', exact: true }).click()
+ await page.getByLabel('Timeline', { exact: true }).fill('0')
+
+ // Drive RAF timestamps independently of rendering so dropped frames and
+ // deferred React state updates cannot silently slow the playback clock.
+ await page.evaluate(() => {
+ let next = 0
+ const callbacks = new Map()
+ window.requestAnimationFrame = callback => { callbacks.set(++next, callback); return next }
+ window.cancelAnimationFrame = id => callbacks.delete(id)
+ window.advanceAnimationFrame = time => {
+ const pending = [...callbacks.values()]
+ callbacks.clear()
+ for (const callback of pending) callback(time)
+ }
+ })
+ const advance = time => page.evaluate(time => window.advanceAnimationFrame(time), time)
+ const toggle = name => page.getByRole('button', { name, exact: true }).evaluate(button => button.click())
+ const clock = page.getByTestId('animation-time')
+ await toggle('Play')
+ await expect(page.getByRole('button', { name: 'Pause', exact: true })).toBeVisible()
+ await advance(0)
+ await advance(250)
+ await expect(clock).toHaveText('0.25 / 1.00 s')
+ await advance(500)
+ await expect(clock).toHaveText('0.50 / 1.00 s')
+ await advance(1250)
+ await expect(clock).toHaveText('0.25 / 1.00 s')
+ await toggle('Pause')
+ await expect(page.getByRole('button', { name: 'Play', exact: true })).toBeVisible()
+ await advance(5000)
+ await expect(clock).toHaveText('0.25 / 1.00 s')
+ await page.getByLabel('Timeline', { exact: true }).fill('0.5')
+ await toggle('Play')
+ await expect(page.getByRole('button', { name: 'Pause', exact: true })).toBeVisible()
+ await advance(6000)
+ await advance(6250)
+ await expect(clock).toHaveText('0.75 / 1.00 s')
+})
+
+test('keeps the 3D Studio category available with only an animation model', async ({ page }) => {
+ await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [
+ { id: 'kimodo-test', capabilities: ['FLAG_3D_ANIMATION'], three_d_operations: [animationOperation] },
+ ] } }))
+ await page.goto('/app/studio/threed')
+ await expect(page.getByLabel('Motion prompt')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'kimodo-test', exact: true })).toBeVisible()
+})
+
+test('validates the prompt byte limit before submitting', async ({ page }) => {
+ await page.goto('/app/studio/threed?model=kimodo-test')
+ await page.getByRole('button', { name: 'trellis-test', exact: true }).click()
+ await page.getByRole('option', { name: 'kimodo-test', exact: true }).click()
+ await page.getByLabel('Motion prompt').fill('🦊'.repeat(1025))
+ await expect(page.getByRole('alert')).toContainText('4096 byte limit')
+ expect(await page.getByLabel('Motion prompt').evaluate(element => element.checkValidity())).toBe(false)
+ await page.getByLabel('Motion prompt').fill('Walk slowly.')
+ await expect(page.getByRole('alert')).toHaveCount(0)
+})
+
+test('renders media conditioning and enumerated parameters from model capabilities', async ({ page }) => {
+ const operation = { ...animationOperation, output: 'mesh', inputs: [
+ animationOperation.inputs[0], { name: 'mesh', type: 'mesh', label: 'Source mesh', required: true },
+ ], parameters: [{ name: 'mode', label: 'Motion mode', type: 'enum', default: 'loop', options: ['loop', 'once'] }] }
+ await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [
+ { id: 'future-model', capabilities: ['FLAG_3D_ANIMATION'], three_d_operations: [operation] },
+ ] } }))
+ let request
+ await page.route('**/3d/animate', route => {
+ request = route.request().postDataJSON()
+ return route.fulfill({ status: 400, json: { error: { message: 'Fixture stops after request validation' } } })
+ })
+ await page.goto('/app/studio/threed')
+ await page.getByLabel('Motion prompt').fill('Wave.')
+ await page.getByLabel('Source mesh').setInputFiles({ name: 'character.glb', mimeType: 'model/gltf-binary', buffer: animationGlb() })
+ await page.getByLabel('Motion mode').selectOption('once')
+ await page.getByRole('button', { name: /Generate$/ }).click()
+ await expect.poll(() => request?.params.mode).toBe('once')
+ expect(request.inputs.mesh.type).toBe('mesh')
+ expect(Buffer.from(request.inputs.mesh.data, 'base64').subarray(0, 4).toString()).toBe('glTF')
+ await expect(page.getByText('Generates an animated skeleton, without a mesh or skin.')).toHaveCount(0)
+})
+
+test('reports unsupported skeleton transforms without displaying a stale pose', async ({ page }) => {
+ await page.route('**/generated-3d/animation.glb', route => route.fulfill({ contentType: 'model/gltf-binary', body: animationGlb(document => { document.nodes[1].translation = [0, 1] }) }))
+ await page.route('**/3d/animate', route => route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } }))
+ await page.goto('/app/studio/threed')
+ await page.getByRole('button', { name: 'trellis-test', exact: true }).click()
+ await page.getByRole('option', { name: 'kimodo-test', exact: true }).click()
+ await page.getByLabel('Motion prompt').fill('Walk.')
+ await page.getByRole('button', { name: /Generate$/ }).click()
+ await expect(page.getByRole('alert')).toContainText('Invalid skeleton transform')
+ await expect(page.getByLabel('Timeline', { exact: true })).toHaveCount(0)
+})
diff --git a/core/http/react-ui/inline-style-baseline.txt b/core/http/react-ui/inline-style-baseline.txt
index a08796291..4d0e90cbc 100644
--- a/core/http/react-ui/inline-style-baseline.txt
+++ b/core/http/react-ui/inline-style-baseline.txt
@@ -1 +1 @@
-514
+512
diff --git a/core/http/react-ui/public/locales/en/importModel.json b/core/http/react-ui/public/locales/en/importModel.json
index e5857af31..4402873a8 100644
--- a/core/http/react-ui/public/locales/en/importModel.json
+++ b/core/http/react-ui/public/locales/en/importModel.json
@@ -78,6 +78,8 @@
"tts": "Text-to-speech",
"image": "Image / Video",
"video": "Video generation",
+ "3d": "3D mesh generation",
+ "3d_animation": "3D animation",
"embeddings": "Embeddings",
"reranker": "Rerankers",
"detection": "Object detection",
diff --git a/core/http/react-ui/public/locales/en/media.json b/core/http/react-ui/public/locales/en/media.json
index 421f6b5fb..cf63f7389 100644
--- a/core/http/react-ui/public/locales/en/media.json
+++ b/core/http/react-ui/public/locales/en/media.json
@@ -23,7 +23,7 @@
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
- "threed": "Image to mesh reconstruction",
+ "threed": "Mesh generation and animation",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
@@ -99,6 +99,18 @@
},
"threed": {
"title": "3D Generation",
+ "animation": {
+ "skeletonHint": "Generates an animated skeleton, without a mesh or skin.",
+ "preview": "Animated skeleton preview",
+ "pause": "Pause",
+ "play": "Play",
+ "reset": "Reset view",
+ "timeline": "Timeline",
+ "zoom": "Zoom",
+ "inputTooLong": "Input exceeds the {{limit}} byte limit.",
+ "fileTooLarge": "The selected file is too large.",
+ "readFailed": "Could not read the selected file."
+ },
"labels": {
"model": "Model",
"image": "Input image",
diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json
index ebadd3b3d..04e32e4fa 100644
--- a/core/http/react-ui/public/locales/en/models.json
+++ b/core/http/react-ui/public/locales/en/models.json
@@ -86,6 +86,7 @@
"image": "Image",
"video": "Video",
"threed": "3D",
+ "threedAnimation": "3D Animation",
"multimodal": "Multimodal",
"vision": "Vision",
"tts": "TTS",
diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css
index 679e16522..f5ac4cd18 100644
--- a/core/http/react-ui/src/App.css
+++ b/core/http/react-ui/src/App.css
@@ -382,7 +382,6 @@
padding-left: var(--spacing-md);
padding-right: var(--spacing-md);
}
-
.nav-external {
font-size: 0.55rem;
margin-left: auto;
@@ -8868,6 +8867,9 @@ button.collapsible-header:focus-visible {
height: 24px;
font-size: var(--text-xs);
}
+@media (hover: none) {
+ .action-menu__trigger { opacity: 1; }
+}
.action-menu {
display: flex;
@@ -9062,8 +9064,8 @@ button.collapsible-header:focus-visible {
}
.console-rail-header__title { display: inline-flex; align-items: center; gap: var(--spacing-sm); }
.console-rail-header__title i { color: var(--color-primary); font-size: 0.9rem; }
-.console-rail-toggle {
- display: none;
+.console-rail-toggle,
+.console-rail-collapse {
width: 34px;
height: 34px;
place-items: center;
@@ -9073,7 +9075,10 @@ button.collapsible-header:focus-visible {
color: var(--color-text-secondary);
cursor: pointer;
}
-.console-rail-toggle:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); }
+.console-rail-toggle { display: none; }
+.console-rail-collapse { display: grid; }
+.console-rail-toggle:hover,
+.console-rail-collapse:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); }
.console-rail-groups { display: flex; flex-direction: column; gap: var(--spacing-xs); }
.console-group { display: flex; flex-direction: column; gap: 1px; }
.console-group + .console-group {
@@ -9101,14 +9106,78 @@ button.collapsible-header:focus-visible {
.console-rail .nav-item:hover:not(.active) { transform: translateX(2px); }
.console-rail .nav-item.active { box-shadow: none; }
.console-rail .nav-item.active .nav-icon { color: var(--color-primary); }
+.console-rail--collapsed {
+ flex-basis: 60px;
+ width: 60px;
+ padding-inline: 6px;
+}
+.console-rail--collapsed .console-rail-header {
+ justify-content: center;
+ padding-inline: 0;
+}
+.console-rail--collapsed .console-rail-header__title,
+.console-rail--collapsed .console-group-title,
+.console-rail--collapsed .nav-label,
+.console-rail--collapsed .nav-external {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.console-rail--collapsed .console-group + .console-group {
+ margin-top: var(--spacing-xs);
+ padding-top: var(--spacing-xs);
+}
+.console-rail--collapsed .nav-item {
+ justify-content: center;
+ min-height: 38px;
+ padding: 7px;
+}
+.console-rail--collapsed .nav-icon { width: auto; }
+.console-rail--collapsed .nav-signal {
+ position: absolute;
+ top: 1px;
+ right: 1px;
+ min-width: 14px;
+ padding: 1px 3px;
+ font-size: 0.5rem;
+ line-height: 1.2;
+}
.console-body {
flex: 1 1 auto;
min-width: 0;
+ width: 100%;
}
@media (max-width: 768px) {
.console-layout { flex-direction: column; padding: var(--spacing-sm); }
- .console-rail { position: static; flex-basis: auto; width: 100%; }
+ .console-rail,
+ .console-rail--collapsed { position: static; flex-basis: auto; width: 100%; padding: var(--spacing-sm); }
+ .console-rail--collapsed .console-rail-header { justify-content: space-between; padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-xs); }
+ .console-rail--collapsed .console-rail-header__title,
+ .console-rail--collapsed .console-group-title,
+ .console-rail--collapsed .nav-label,
+ .console-rail--collapsed .nav-external {
+ position: static;
+ width: auto;
+ height: auto;
+ padding: revert;
+ margin: 0;
+ overflow: visible;
+ clip: auto;
+ white-space: nowrap;
+ border: 0;
+ }
+ .console-rail--collapsed .console-group-title { padding: var(--spacing-xs) var(--spacing-sm); }
+ .console-rail--collapsed .nav-item { justify-content: flex-start; min-height: 44px; padding: 7px var(--spacing-sm); }
+ .console-rail--collapsed .nav-icon { width: 18px; }
+ .console-rail--collapsed .nav-signal { position: static; min-width: 0; padding: 1px 6px; font-size: var(--text-xs); line-height: inherit; }
.console-rail-toggle { display: grid; }
+ .console-rail-collapse { display: none; }
.console-rail-groups { display: none; }
/* Thirteen destinations stacked in one column is taller than a phone, so
opening the menu used to push the page's own heading past the fold: the
@@ -9676,7 +9745,280 @@ button.collapsible-header:focus-visible {
.model-chip__dot { width: 6px; height: 6px; border-radius: 50%; }
.model-chip__state { opacity: 0.85; font-style: normal; }
.node-filter { margin-bottom: var(--spacing-lg); }
-.node-detail__metrics { display: flex; gap: var(--spacing-xl); margin: var(--spacing-md) 0 var(--spacing-lg); flex-wrap: wrap; }
+.node-detail__header .page-header__eyebrow { text-transform: none; }
+.node-detail__breadcrumb { align-items: center; display: inline-flex; gap: 7px; }
+.node-detail__breadcrumb a { color: var(--color-primary); text-decoration: none; }
+.node-detail__breadcrumb a:hover { color: var(--color-primary-hover); text-decoration: underline; text-underline-offset: 2px; }
+.node-detail__breadcrumb i { color: var(--color-text-muted); font-size: .5rem; }
+.node-detail__identity { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; }
+.node-detail__identity > span:last-child { color: var(--color-text-muted); }
+.node-detail__metrics {
+ background: var(--color-border-subtle);
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-lg);
+ display: grid;
+ gap: 1px;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ margin-bottom: 18px;
+ overflow: hidden;
+}
+.node-detail__metrics > div { background: var(--color-bg-secondary); min-width: 0; padding: 14px 16px; }
+.node-detail__metrics .drawer-eyebrow { margin-bottom: 5px; }
+.node-detail__metrics .cell-mono { font-size: var(--text-xs); overflow-wrap: anywhere; }
+.node-detail__metric-note { display: block; color: var(--color-text-muted); font-size: .625rem; margin-top: 2px; }
+.node-detail__layout { align-items: start; display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); }
+.node-detail__workloads { display: grid; gap: 18px; min-width: 0; }
+.node-detail__workbench { min-width: 0; }
+.node-detail__workbench .model-workbench__scope { background: var(--color-bg-secondary); }
+.node-detail__workbench .model-workbench__scope > span { white-space: nowrap; }
+.node-detail__table { min-width: 570px; }
+.node-detail__table tbody tr:last-child td { border-bottom: 0; }
+.node-detail__mobile-meta { color: var(--color-text-muted); display: none; font-family: var(--font-sans); font-size: .625rem; margin-top: 3px; }
+.node-detail__empty { align-items: center; color: var(--color-text-muted); display: flex; font-size: var(--text-xs); gap: 8px; min-height: 72px; padding: 16px; }
+.node-detail__text-action { background: transparent; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-weight: 600; padding: 0; }
+.node-detail__text-action:hover { color: var(--color-primary-hover); text-decoration: underline; text-underline-offset: 2px; }
+.node-detail__text-action:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--color-primary); outline-offset: 3px; }
+.node-detail__configuration { background: var(--color-bg-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-lg); overflow: hidden; }
+.node-detail__configuration section { padding: 17px 18px; }
+.node-detail__configuration section + section { border-top: 1px solid var(--color-border-subtle); }
+.node-detail__configuration p { color: var(--color-text-muted); font-size: var(--text-xs); line-height: var(--leading-normal); margin: 4px 0 12px; }
+.node-detail__load-error { align-items: center; background: var(--color-bg-secondary); border: 1px solid var(--color-error-border); border-radius: var(--radius-lg); display: flex; gap: 12px; padding: 16px; }
+.node-detail__load-error > i { color: var(--color-error); }
+.node-detail__load-error > div { display: grid; flex: 1; gap: 2px; }
+.node-detail__load-error strong { font-size: var(--text-sm); }
+.node-detail__load-error span { color: var(--color-text-muted); font-size: var(--text-xs); }
+
+/* Nodes fleet operations dashboard */
+.nodes-fleet-page { container-name: fleet-page; container-type: inline-size; isolation: isolate; position: relative; }
+.nodes-fleet-page--inspecting { container-type: normal; }
+.page-transition:has(.nodes-fleet-page--inspecting) { animation: none !important; transform: none !important; }
+.nodes-fleet-page__header { align-items: flex-start; margin-bottom: 18px; }
+.nodes-fleet-page__header .page-title { font-size: 1.75rem; letter-spacing: -.03em; }
+.nodes-fleet-page__header .page-header__supporting { font-size: var(--text-xs); margin-top: 3px; }
+.fleet-kicker { display: block; color: var(--color-text-muted); font-size: .625rem; font-weight: 650; letter-spacing: .08em; margin-bottom: 4px; text-transform: uppercase; }
+.fleet-overview {
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-lg);
+ display: grid;
+ grid-template-columns: minmax(265px, 1.45fr) repeat(4, minmax(145px, .8fr));
+ margin-bottom: 12px;
+ overflow: hidden;
+}
+.fleet-overview__cell { height: 204px; min-width: 0; padding: 22px 20px; }
+.fleet-overview__cell + .fleet-overview__cell { border-left: 1px solid var(--color-border-subtle); }
+.fleet-health__headline { align-items: baseline; display: flex; gap: 8px; margin: 10px 0 22px; white-space: nowrap; }
+.fleet-health__headline strong { font-size: 1.625rem; letter-spacing: -.025em; }
+.fleet-health__headline span { color: var(--color-text-muted); font-size: .75rem; }
+.fleet-health__bar { display: block; height: 11px; margin-bottom: 17px; width: 100%; }
+.fleet-health__segment--healthy { fill: var(--color-success); }
+.fleet-health__segment--draining, .fleet-health__segment--pending { fill: var(--color-warning); }
+.fleet-health__segment--unhealthy { fill: var(--color-error); }
+.fleet-health__legend { display: grid; gap: 8px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.fleet-health__legend > div { min-width: 0; }
+.fleet-health__legend span { align-items: center; color: var(--color-text-muted); display: flex; font-size: .625rem; gap: 6px; white-space: nowrap; }
+.fleet-health__legend strong { display: block; font-size: .75rem; margin-top: 4px; white-space: nowrap; }
+.fleet-health__dot { background: var(--color-error); border-radius: 50%; display: inline-block; height: 7px; width: 7px; }
+.fleet-health__dot--healthy { background: var(--color-success); }
+.fleet-health__dot--draining { background: var(--color-warning); }
+.fleet-gauge { align-content: start; display: grid; justify-items: center; }
+.fleet-gauge .fleet-kicker { justify-self: start; white-space: nowrap; }
+.fleet-gauge__graphic { height: 73px; margin-top: 18px; position: relative; width: 136px; }
+.fleet-gauge__graphic svg { height: 73px; overflow: visible; width: 136px; }
+.fleet-gauge__graphic path { fill: none; stroke-linecap: butt; stroke-width: 7; }
+.fleet-gauge__track { stroke: var(--color-bg-tertiary); }
+.fleet-gauge__value { stroke: var(--fleet-gauge-color); }
+.fleet-gauge--vram { --fleet-gauge-color: var(--color-success); }
+.fleet-gauge--ram { --fleet-gauge-color: var(--color-primary); }
+.fleet-gauge--cpu { --fleet-gauge-color: var(--color-info); }
+.fleet-gauge--disk { --fleet-gauge-color: var(--color-info); }
+.fleet-gauge__graphic strong { bottom: 0; font-size: 1rem; left: 0; position: absolute; text-align: center; width: 136px; }
+.fleet-gauge__value-text { font-family: var(--font-mono); font-size: .6875rem; font-weight: 600; margin-top: 4px; text-align: center; white-space: nowrap; }
+.fleet-gauge__detail, .fleet-gauge__coverage { color: var(--color-text-muted); font-size: .5625rem; line-height: 1.35; margin-top: 3px; text-align: center; }
+.fleet-gauge__coverage { margin-top: 1px; }
+.fleet-attention { align-items: center; background: var(--color-warning-light); border: 1px solid var(--color-warning-border); border-radius: var(--radius-md); display: flex; gap: 18px; margin-bottom: 24px; min-height: 52px; padding: 8px 15px; }
+.fleet-attention__title { align-items: center; display: flex; flex: 0 0 auto; font-size: var(--text-xs); gap: 9px; }
+.fleet-attention__title i { color: var(--color-warning); }
+.fleet-attention__filters { align-items: center; display: flex; flex: 1; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
+.fleet-attention__filter { align-items: center; background: transparent; border: 1px solid transparent; border-radius: var(--radius-full); color: var(--color-text-secondary); cursor: pointer; display: flex; font: inherit; font-size: .625rem; gap: 6px; min-height: 28px; padding: 3px 9px; text-align: left; }
+.fleet-attention__filter:hover, .fleet-attention__filter.is-active { color: var(--color-primary); }
+.fleet-attention__filter:hover { background: var(--color-bg-hover); }
+.fleet-attention__filter.is-active { background: var(--color-bg-secondary); border-color: var(--color-border-strong); box-shadow: 0 1px 2px rgba(0, 0, 0, .12); }
+.fleet-attention__filter strong { color: var(--color-text-primary); }
+.fleet-workbench { background: var(--color-bg-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-lg); container-name: fleet-workbench; container-type: inline-size; overflow: hidden; }
+.fleet-workbench__tabs { align-items: stretch; background: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border-subtle); display: flex; min-height: 43px; padding: 0 11px; }
+.fleet-workbench__tabs button { align-items: center; background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--color-text-muted); cursor: pointer; display: flex; font: inherit; font-size: var(--text-xs); font-weight: 600; gap: 7px; margin-bottom: -1px; padding: 0 11px; }
+.fleet-workbench__tabs button:hover { color: var(--color-text-primary); }
+.fleet-workbench__tabs button:focus-visible { border-radius: var(--radius-sm); box-shadow: inset 0 0 0 2px var(--color-primary); outline: none; }
+.fleet-workbench__tabs button.is-active { border-bottom-color: var(--color-primary); color: var(--color-text-primary); }
+.fleet-workbench__tabs button span { background: var(--color-bg-tertiary); border-radius: var(--radius-full); color: var(--color-text-muted); font-family: var(--font-mono); font-size: .5625rem; min-width: 19px; padding: 2px 6px; text-align: center; }
+.fleet-workbench__layout { min-width: 0; }
+.fleet-workbench__fleet { min-width: 0; }
+.fleet-toolbar { border-bottom: 1px solid var(--color-border-subtle); display: grid; grid-template-columns: minmax(190px, 1fr) repeat(3, minmax(112px, auto)); gap: 7px; padding: 10px 11px; }
+.fleet-toolbar__search { height: 34px; min-width: 0; }
+.fleet-select-wrap { display: block; min-width: 0; position: relative; }
+.fleet-select { appearance: none; background: var(--color-bg-primary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); color: var(--color-text-secondary); cursor: pointer; font: inherit; font-size: var(--text-xs); height: 34px; padding: 0 28px 0 10px; width: 100%; }
+.fleet-select:hover { border-color: var(--color-border-strong); }
+.fleet-select:focus-visible { border-color: var(--color-primary); box-shadow: 0 0 0 3px var(--color-primary-light); outline: none; }
+.fleet-select__chevron { color: var(--color-text-muted); font-size: .625rem; pointer-events: none; position: absolute; right: 10px; top: 12px; }
+.fleet-bulkbar { align-items: center; background: var(--color-primary-light); border-bottom: 1px solid var(--color-border-strong); display: flex; flex-wrap: wrap; gap: var(--spacing-xs); min-height: 42px; padding: 5px 11px; }
+.fleet-bulkbar strong { font-size: var(--text-xs); margin-right: var(--spacing-xs); }
+.fleet-bulkbar__clear { background: none; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-size: var(--text-xs); }
+.fleet-bulkbar__count { color: var(--color-text-muted); font-size: var(--text-xs); margin-left: auto; }
+.fleet-table-wrap { overflow-x: auto; }
+.fleet-table { border-collapse: collapse; font-size: var(--text-xs); min-width: 820px; width: 100%; }
+.fleet-table th, .fleet-table td { border-bottom: 1px solid var(--color-border-subtle); padding: 10px 12px; text-align: left; vertical-align: middle; }
+.fleet-table thead th { background: var(--color-bg-secondary); color: var(--color-text-muted); font-weight: 600; }
+.fleet-table__check { padding-left: 10px !important; padding-right: 6px !important; width: 38px; }
+.fleet-checkbox {
+ accent-color: var(--color-primary);
+ cursor: pointer;
+ display: block;
+ height: 18px;
+ margin: 0;
+ width: 18px;
+}
+.fleet-checkbox:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 3px;
+}
+.fleet-checkbox:disabled { cursor: not-allowed; opacity: .5; }
+.fleet-table__sort { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; font-weight: inherit; padding: 0; }
+.fleet-table__row { cursor: pointer; height: 67px; transition: background var(--duration-fast) var(--ease-out, ease-out); }
+.fleet-table__row:hover, .fleet-table__row:focus { background: var(--color-bg-hover); outline: none; }
+.fleet-table__row.is-selected { background: var(--color-primary-light); box-shadow: inset 2px 0 var(--color-primary); }
+.fleet-table__node { background: none; border: 0; color: var(--color-text-primary); cursor: pointer; display: block; font: inherit; font-weight: 600; padding: 0; text-align: left; }
+.fleet-table__node + span, .fleet-table__subvalue { color: var(--color-text-muted); display: block; font-size: .625rem; margin-top: 1px; }
+.fleet-table__approve { background: none; border: 0; color: var(--color-primary); cursor: pointer; display: block; font: inherit; font-size: .625rem; font-weight: 650; margin-top: 5px; padding: 0; }
+.fleet-table__unknown { color: var(--color-text-muted); }
+.fleet-table__resource { align-items: center; display: grid; gap: 5px; grid-template-columns: minmax(45px, 1fr) auto; min-width: 92px; }
+.fleet-table__resource > span:last-child { color: var(--color-text-muted); font-family: var(--font-mono); font-size: .5625rem; white-space: nowrap; }
+.fleet-table__resource-track { appearance: none; background: var(--color-bg-tertiary); border: 0; border-radius: var(--radius-full); display: block; height: 5px; overflow: hidden; width: 100%; }
+.fleet-table__resource-track::-webkit-progress-bar { background: var(--color-bg-tertiary); }
+.fleet-table__resource-track::-webkit-progress-value { background: var(--fleet-resource-color); border-radius: var(--radius-full); }
+.fleet-table__resource-track::-moz-progress-bar { background: var(--fleet-resource-color); border-radius: var(--radius-full); }
+.fleet-table__resource--vram { --fleet-resource-color: var(--color-success); }
+.fleet-table__resource--ram { --fleet-resource-color: var(--color-primary); }
+.fleet-table__capacity { display: grid; gap: 7px; min-width: 205px; }
+.fleet-table__capacity-row { align-items: center; display: grid; gap: 8px; grid-template-columns: 38px minmax(0, 1fr); }
+.fleet-table__capacity-row > b { color: var(--color-text-muted); font-size: .5625rem; font-weight: 500; }
+.fleet-table__group th { background: var(--color-bg-tertiary); color: var(--color-text-primary); padding: 7px 10px; }
+.fleet-table__group th, .fleet-table__group label { align-items: center; display: flex; gap: var(--spacing-xs); }
+.fleet-table__group span { color: var(--color-text-muted); font-weight: 400; margin-left: auto; }
+.fleet-table__empty { color: var(--color-text-muted); padding: var(--spacing-xl); text-align: center; }
+.fleet-pagination { align-items: center; display: flex; gap: var(--spacing-xs); justify-content: flex-end; min-height: 45px; padding: 5px 11px; }
+.fleet-pagination span { color: var(--color-text-muted); font-size: var(--text-xs); margin-right: var(--spacing-xs); }
+.model-workbench__scope { align-items: center; background: var(--color-bg-tertiary); border-bottom: 1px solid var(--color-border-subtle); display: flex; justify-content: space-between; min-height: 48px; padding: 7px 12px; }
+.model-workbench__scope > div { display: grid; gap: 1px; }
+.model-workbench__scope strong { color: var(--color-text-primary); font-size: var(--text-sm); }
+.model-workbench__scope span { color: var(--color-text-muted); font-size: .625rem; }
+.model-toolbar { border-bottom: 1px solid var(--color-border-subtle); padding: 9px 11px; }
+.model-toolbar .fleet-toolbar__search { max-width: 430px; width: 100%; }
+.model-workbench__state { align-items: center; color: var(--color-text-muted); display: flex; flex-direction: column; gap: 6px; justify-content: center; min-height: 300px; padding: var(--spacing-xl); text-align: center; }
+.model-workbench__state > i { color: var(--color-text-muted); font-size: var(--text-xl); }
+.model-workbench__state strong { color: var(--color-text-primary); font-size: var(--text-base); }
+.model-workbench__state span { font-size: var(--text-xs); max-width: 420px; }
+.model-workbench__state .btn { margin-top: 5px; }
+.model-workbench__state--error > i { color: var(--color-error); }
+.model-fleet-table { min-width: 720px; }
+.model-fleet-table th:first-child, .model-fleet-table td:first-child { width: 37%; }
+.model-fleet-table .model-fleet-table__actions { padding-left: 4px; padding-right: 8px; text-align: right; width: 38px; }
+.model-fleet-table .fleet-table__row:hover .action-menu__trigger,
+.model-fleet-table .action-menu__trigger:focus-visible,
+.model-fleet-table .action-menu__trigger.is-open { opacity: 1; }
+.model-backend-list { display: flex; flex-wrap: wrap; gap: 4px; }
+.model-backend-list > span:not(.fleet-table__unknown, .text-muted) { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-full); color: var(--color-text-secondary); font-family: var(--font-mono); font-size: .5625rem; padding: 2px 6px; }
+.node-inspector__scrim { display: none; }
+.node-inspector { animation: node-inspector-in 180ms var(--ease-out, ease-out) both; background: var(--color-bg-primary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-xl) 0 0 var(--radius-xl); bottom: var(--spacing-md); box-shadow: -18px 0 42px rgba(0, 0, 0, .24); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; position: fixed; right: var(--spacing-md); top: var(--spacing-md); width: 360px; z-index: 80; }
+@keyframes node-inspector-in { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: translateX(0); } }
+.node-inspector__header { background: linear-gradient(180deg, var(--color-bg-secondary), var(--color-bg-primary)); border-bottom: 1px solid var(--color-border-subtle); padding: 18px 22px 17px; }
+.node-inspector__topbar { align-items: center; display: flex; justify-content: space-between; min-height: 30px; }
+.node-inspector__topbar .fleet-kicker { margin: 0; }
+.node-inspector__header h2 { font-size: 1.25rem; letter-spacing: -.025em; line-height: 1.2; margin: 9px 0 0; overflow-wrap: anywhere; }
+.node-inspector__header > p, .node-inspector__identity > p { color: var(--color-text-muted); font-size: .6875rem; margin: 4px 0 9px; }
+.node-inspector__identity .status-pill { margin: 0; }
+.node-inspector__body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 22px; scrollbar-gutter: stable; }
+.node-inspector__section { border-bottom: 1px solid var(--color-border-subtle); padding: 15px 0; }
+.node-inspector__section:last-of-type { border-bottom: 0; }
+.node-inspector__section h3 { color: var(--color-text-muted); font-size: .625rem; font-weight: 650; letter-spacing: .08em; margin: 0 0 9px; text-transform: uppercase; }
+.node-inspector__address { color: var(--color-text-secondary); font-family: var(--font-mono); font-size: .625rem; overflow-wrap: anywhere; }
+.node-inspector__metrics { display: grid; gap: 0; margin: 0; }
+.node-inspector__metrics > div { display: grid; font-size: var(--text-xs); grid-template-columns: 92px 1fr; padding: 5px 0; }
+.node-inspector__metrics dt { color: var(--color-text-muted); }
+.node-inspector__metrics dd { margin: 0; text-align: right; }
+.node-inspector__labels { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
+.node-inspector__labels > span:not(.text-muted) { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-full); color: var(--color-text-secondary); font-family: var(--font-mono); font-size: .5625rem; padding: 2px 7px; }
+.node-inspector__resource { margin: 10px 0 13px; }
+.node-inspector__resource-label { align-items: baseline; display: flex; font-size: var(--text-xs); justify-content: space-between; margin-bottom: 6px; }
+.node-inspector__resource-label strong span, .node-inspector__resource-label > span { color: var(--color-text-muted); font-size: .625rem; font-weight: 400; }
+.node-inspector__resource-track { appearance: none; background: var(--color-bg-tertiary); border: 0; border-radius: var(--radius-full); display: block; height: 7px; overflow: hidden; width: 100%; }
+.node-inspector__resource-track::-webkit-progress-bar { background: var(--color-bg-tertiary); }
+.node-inspector__resource-track::-webkit-progress-value { background: var(--fleet-resource-color); border-radius: var(--radius-full); }
+.node-inspector__resource-track::-moz-progress-bar { background: var(--fleet-resource-color); border-radius: var(--radius-full); }
+.node-inspector__resource--vram { --fleet-resource-color: var(--color-success); }
+.node-inspector__resource--ram { --fleet-resource-color: var(--color-primary); }
+.node-inspector__actions { background: var(--color-bg-secondary); border-top: 1px solid var(--color-border-subtle); box-shadow: 0 -8px 18px rgba(0, 0, 0, .06); display: grid; gap: var(--spacing-xs); grid-template-columns: 1fr 1fr; padding: 14px 22px; }
+.node-inspector__actions--single { grid-template-columns: 1fr; }
+.node-inspector__actions .btn { justify-content: center; min-width: 0; }
+.node-inspector__back { align-items: center; background: transparent; border: 0; color: var(--color-primary); cursor: pointer; display: flex; font: inherit; font-size: var(--text-xs); gap: 6px; max-width: 285px; overflow: hidden; padding: 3px 0; text-overflow: ellipsis; white-space: nowrap; }
+.node-inspector__back:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--color-primary); outline-offset: 3px; }
+.model-inspector__backends { margin-top: 10px; }
+.model-inspector__nodes { display: grid; gap: 9px; }
+.model-inspector__node { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: 10px; }
+.model-inspector__node-heading { align-items: center; display: flex; gap: 7px; justify-content: space-between; }
+.model-inspector__node-heading button { background: transparent; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-size: var(--text-sm); font-weight: 650; overflow: hidden; padding: 0; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
+.model-inspector__node-heading strong { font-size: var(--text-sm); overflow-wrap: anywhere; }
+.model-inspector__node-actions { align-items: center; display: flex; gap: 8px; }
+.model-inspector__logs,
+.model-inspector__replica-logs { background: transparent; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-size: .625rem; font-weight: 600; padding: 3px; white-space: nowrap; }
+.model-inspector__logs:hover,
+.model-inspector__replica-logs:hover { color: var(--color-primary-hover); text-decoration: underline; text-underline-offset: 2px; }
+.model-inspector__logs:focus-visible,
+.model-inspector__replica-logs:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--color-primary); outline-offset: 2px; }
+.model-inspector__node > p { color: var(--color-text-muted); font-size: .625rem; margin: 5px 0 8px; }
+.model-inspector__node ul { border-top: 1px solid var(--color-border-subtle); list-style: none; margin: 0; padding: 5px 0 0; }
+.model-inspector__node li { align-items: baseline; display: grid; font-size: .625rem; gap: 7px; grid-template-columns: auto minmax(0, 1fr) auto; padding-top: 4px; }
+.model-inspector__node li span { color: var(--color-text-muted); }
+.model-inspector__node code { color: var(--color-text-secondary); font-size: .5625rem; overflow: hidden; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
+
+@container fleet-page (max-width: 760px) {
+ .fleet-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .fleet-overview__cell { min-height: 146px; }
+ .fleet-overview__cell + .fleet-overview__cell { border-left: 0; }
+ .fleet-overview__cell:nth-child(even) { border-left: 1px solid var(--color-border-subtle); }
+ .fleet-overview__cell:nth-child(n + 3) { border-top: 1px solid var(--color-border-subtle); }
+ .fleet-health { grid-column: 1 / -1; }
+ .fleet-health { min-height: 126px; }
+ .fleet-attention { align-items: flex-start; flex-direction: column; gap: 7px; }
+ .fleet-attention__filters { justify-content: flex-start; }
+ .fleet-toolbar { grid-template-columns: 1fr 1fr; }
+ .fleet-toolbar__search { grid-column: 1 / -1; }
+}
+@container fleet-page (max-width: 980px) {
+ .node-detail__metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .node-detail__layout { grid-template-columns: minmax(0, 1fr); }
+}
+@container fleet-page (max-width: 540px) {
+ .node-detail__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .node-detail__workbench .model-workbench__scope { align-items: flex-start; flex-direction: column; gap: 8px; padding: 10px 12px; }
+ .node-detail__table { min-width: 0; }
+ .node-detail__table--models th:nth-child(2),
+ .node-detail__table--models th:nth-child(3),
+ .node-detail__table--models td:nth-child(2),
+ .node-detail__table--models td:nth-child(3),
+ .node-detail__table--backends th:nth-child(3),
+ .node-detail__table--backends td:nth-child(3) { display: none; }
+ .node-detail__table .model-fleet-table__actions { padding-left: 4px; padding-right: 8px; text-align: right; width: 38px; }
+ .node-detail__mobile-meta { display: block; }
+}
+@media (max-width: 768px) {
+ .node-inspector__scrim { animation: node-inspector-scrim-in 180ms ease-out both; background: rgba(7, 10, 18, .52); display: block; inset: 0; position: fixed; z-index: 79; }
+ .node-inspector { border-bottom: 0; border-radius: 0; border-top: 0; bottom: 0; max-width: 100vw; position: fixed; top: 0; width: min(390px, 100vw); z-index: 80; }
+ .node-inspector__header { padding-left: 18px; padding-right: 18px; padding-top: max(16px, env(safe-area-inset-top)); }
+ .node-inspector__body { padding-left: 18px; padding-right: 18px; }
+ .node-inspector__actions { padding-bottom: max(14px, env(safe-area-inset-bottom)); padding-left: 18px; padding-right: 18px; }
+}
+@keyframes node-inspector-scrim-in { from { opacity: 0; } to { opacity: 1; } }
/* Rendered Markdown ---------------------------------------------------------
Gallery descriptions, backend descriptions and voice notes are all
@@ -13443,3 +13785,9 @@ button.lane:hover {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
+.animation-viewer canvas {
+ width: 100%;
+ height: 420px;
+ touch-action: none;
+ cursor: grab;
+}
diff --git a/core/http/react-ui/src/components/ActionMenu.jsx b/core/http/react-ui/src/components/ActionMenu.jsx
index 55010102c..e2aea8ddd 100644
--- a/core/http/react-ui/src/components/ActionMenu.jsx
+++ b/core/http/react-ui/src/components/ActionMenu.jsx
@@ -1,4 +1,4 @@
-import { useRef, useState, useEffect, useCallback } from 'react'
+import { useRef, useState, useEffect, useCallback, useId } from 'react'
import Popover from './Popover'
// ActionMenu renders a kebab (three-dot) button that opens a popover with a
@@ -20,6 +20,7 @@ import Popover from './Popover'
// Escape — close, return focus to trigger
export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, compact = false }) {
const triggerRef = useRef(null)
+ const menuId = useId()
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
@@ -48,7 +49,12 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel,
}
const handleMenuKeyDown = (e) => {
- if (e.key === 'ArrowDown') {
+ if (e.key === 'Escape') {
+ // Keep the same Escape press from also closing a surrounding inspector.
+ e.preventDefault()
+ e.stopPropagation()
+ close()
+ } else if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx(i => Math.min(interactive.length - 1, (i < 0 ? -1 : i) + 1))
} else if (e.key === 'ArrowUp') {
@@ -65,7 +71,7 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel,
const item = interactive[activeIdx]
if (item && !item.disabled) {
close()
- item.onClick?.()
+ item.onClick?.(triggerRef.current)
}
}
}
@@ -92,6 +98,7 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel,
= 0 ? `${menuId}-item-${activeIdx}` : undefined}
className="action-menu"
onKeyDown={handleMenuKeyDown}
// Capture focus when the menu opens so arrow keys work without the
@@ -118,8 +125,10 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel,
return (