Merge master into vLLM-Omni install fix

Preserve the pinned installer, which already uses a regular package installation as required by master.

Assisted-by: Codex:gpt-6
This commit is contained in:
localai-org-maint-bot committed 2026-09-19 22:03:05 +00:00
commit 1f564a26a6
211 files changed
+13126 -1039

No files matched your search

+2
View File
@@ -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
+61
View File
@@ -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"
+5 -1
View File
@@ -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: |
+26 -3
View File
@@ -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'
+1
View File
@@ -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_ | | |
+11
View File
@@ -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)))
+4
View File
@@ -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
<a href="https://www.spectrocloud.com/" target="blank">
<img height="200" src="https://github.com/user-attachments/assets/72eab1dd-8b93-4fc0-9ade-84db49f24962">
</a>
<a href="https://solstone.app/" target="blank" >
<img height="200" src="https://solpbc.org/brands/assets/solstone/png/lockup-solstone-app-512.png">
</a>
</p>
<details>
+15
View File
@@ -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<string, AnimationInput> inputs = 1;
string dst = 2;
map<string, string> 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
+1 -1
View File
@@ -9,7 +9,7 @@
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
# rebuild and so the bump bot can see the pin.
AUDIO_CPP_VERSION?=efb04233dab73aeee4b2912042a90e7b36329061
AUDIO_CPP_VERSION?=a074d6b8cdb16b89cd028876e83629a538d49b9a
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+3 -5
View File
@@ -1,7 +1,7 @@
# Pinned to the HEAD of the `prism` branch on https://github.com/PrismML-Eng/llama.cpp.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
BONSAI_VERSION?=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
-24
View File
@@ -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 <grpc-server.cpp>" >&2
exit 2
fi
SRC=$1
if [[ ! -f "$SRC" ]]; then
echo "grpc-server.cpp not found at $SRC" >&2
exit 2
fi
if grep -q '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
+6 -2
View File
@@ -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")
+8 -8
View File
@@ -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
+1 -1
View File
@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=3bb386eb68ffee0a5dc7db21da0735d594929eeb
IK_LLAMA_VERSION?=2ae132fa601ea06818ed3584f50f7eb4f72d4967
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=
+3 -1
View File
@@ -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
+3 -3
View File
@@ -1,7 +1,7 @@
# Pinned to the HEAD of feature/turboquant-kv-cache on https://github.com/TheTom/llama-cpp-turboquant.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
TURBOQUANT_VERSION?=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
+1 -1
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
/sources/
/build-native/
/build-cpu/
/build-vulkan/
/build-native-avx2/
/build-cpu-avx2/
/build-vulkan-avx2/
/package/
/kimodocpp
+8
View File
@@ -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)
+61
View File
@@ -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
+35
View File
@@ -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.
+109
View File
@@ -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)
}
+45
View File
@@ -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"))
}
})
})
+227
View File
@@ -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)
}
+155
View File
@@ -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())
})
})
+53
View File
@@ -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
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MIT
#include <kimodo/kimodo_capi.h>
#include "../sources/kimodo.cpp/src/skeleton.hpp"
#include <cstdlib>
#include <cstdio>
#include <cstring>
#ifdef KIMODO_HAVE_GGML_VULKAN
#include <ggml-vulkan.h>
#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;
}
}
+42
View File
@@ -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
+39
View File
@@ -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())
})
})
+22
View File
@@ -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" "$@"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# stablediffusion.cpp (ggml)
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
STABLEDIFFUSION_GGML_VERSION?=b68d58624d227682eb4b95ef8bcf569cd1311eb5
STABLEDIFFUSION_GGML_VERSION?=2ea8aff7ef603977dc2ece7856bf9736dba96652
CMAKE_ARGS+=-DGGML_MAX_NAME=128
+3 -2
View File
@@ -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);
+1 -1
View File
@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
# vllm.cpp version
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
VLLM_CPP_VERSION?=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
+24 -23
View File
@@ -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 = <model_dir>/... (ABI v9)
BlockSize int32
NumBlocks int32
MaxModelLen int32
MaxNumSeqs int32
ToolParser uintptr // const char*; NULL = auto-detect (ABI v4)
ReasoningParser uintptr // const char*; NULL = auto-detect (ABI v5)
SpeculativeConfig uintptr // const char* JSON; NULL = no speculation (ABI v6)
EnablePrefixCaching int32 // tri-state 0/1/2 (ABI v7)
MaxNumBatchedTokens int32 // <= 0 = per-arch default (ABI v9)
SchedulingPolicy uintptr // const char*; NULL = "fcfs" (ABI v9)
KVTransferConfig uintptr // const char* JSON; NULL = no connector (ABI v9)
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 = <model_dir>/... (ABI v9)
BlockSize int32
NumBlocks int32
MaxModelLen int32
MaxNumSeqs int32
ToolParser uintptr // const char*; NULL = auto-detect (ABI v4)
ReasoningParser uintptr // const char*; NULL = auto-detect (ABI v5)
SpeculativeConfig uintptr // const char* JSON; NULL = no speculation (ABI v6)
EnablePrefixCaching int32 // tri-state 0/1/2 (ABI v7)
MaxNumBatchedTokens int32 // <= 0 = per-arch default (ABI v9)
SchedulingPolicy uintptr // const char*; NULL = "fcfs" (ABI v9)
KVTransferConfig uintptr // const char* JSON; NULL = no connector (ABI v9)
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).
+8
View File
@@ -103,6 +103,12 @@ type videoOptions struct {
height int32
numFrames int32
steps int32
// Directory for runtime prompt-activated LoRA. The engine resolves
// <lora:name:strength> 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:
+68 -2
View File
@@ -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 <lora:name:strength> 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
}
+77
View File
@@ -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 {
+12 -10
View File
@@ -16,7 +16,7 @@ func TestVllmCpp(t *testing.T) {
RunSpecs(t, "vllm-cpp suite")
}
// The Go POD mirrors must match the C struct layout of vllm.h (ABI 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() {
+1 -1
View File
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# whisper.cpp version
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
WHISPER_CPP_VERSION?=c44b60b8053bbf2a5c1e014f11323fb3f2485177
WHISPER_CPP_VERSION?=5670d5c0bbcb148feabef84400a07cfca9aa3b30
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
+62
View File
@@ -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"
@@ -1,3 +1,3 @@
grpcio==1.83.1
grpcio==1.84.0
protobuf
grpcio-tools
+1 -1
View File
@@ -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"
-7
View File
@@ -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
@@ -1,2 +0,0 @@
ninja
flash-attn
-9
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
grpcio==1.83.1
grpcio==1.84.0
protobuf
certifi
@@ -7,4 +7,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
protobuf==7.35.0
protobuf==7.36.1
@@ -7,4 +7,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
protobuf==7.35.0
protobuf==7.36.1
@@ -7,4 +7,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
protobuf==7.35.0
protobuf==7.36.1
@@ -8,4 +8,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
protobuf==7.35.0
protobuf==7.36.1
@@ -8,4 +8,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
protobuf==7.35.0
protobuf==7.36.1
@@ -7,4 +7,4 @@ bitsandbytes
sentence-transformers==5.7.0
diffusers
soundfile
protobuf==7.35.0
protobuf==7.36.1
+1 -1
View File
@@ -1,5 +1,5 @@
grpcio==1.83.0
protobuf==7.35.0
protobuf==7.36.1
certifi
setuptools
scipy==1.18.0
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
grpcio==1.83.1
grpcio==1.84.0
protobuf
certifi
setuptools
+6
View File
@@ -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 {
+49
View File
@@ -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
}
+257
View File
@@ -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
}
}
+242
View File
@@ -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" }))
})
+2
View File
@@ -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"`
+5 -4
View File
@@ -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/)"`
}
+44
View File
@@ -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
}
+67
View File
@@ -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())
})
})
})
+6
View File
@@ -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)
+13
View File
@@ -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},
+65
View File
@@ -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
}
+34
View File
@@ -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")))
})
})
+14 -1
View File
@@ -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
+16 -2
View File
@@ -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"}
+1
View File
@@ -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.
+602
View File
@@ -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"
}
]
+112
View File
@@ -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
}
+93
View File
@@ -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"))
})
})
+6 -1
View File
@@ -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
+1
View File
@@ -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},
@@ -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",
@@ -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}})
}
}
@@ -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())
})
})
+30 -11
View File
@@ -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"))
}
+65
View File
@@ -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()
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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
@@ -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)
}
@@ -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))
})
})
@@ -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')
@@ -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')
})
})
+22
View File
@@ -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/)
})
})
+94 -6
View File
@@ -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)
})
})
@@ -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')
})
})
@@ -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()
+22 -51
View File
@@ -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 })
})
})
@@ -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)
})
+1 -1
View File
@@ -1 +1 @@
514
512
@@ -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",
@@ -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",
@@ -86,6 +86,7 @@
"image": "Image",
"video": "Video",
"threed": "3D",
"threedAnimation": "3D Animation",
"multimodal": "Multimodal",
"vision": "Vision",
"tts": "TTS",
+354 -6
View File
@@ -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;
}
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more