mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-26 08:05:00 -04:00
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:
commit
1f564a26a6
211 files changed
+13126
-1039
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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_ | | |
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=3bb386eb68ffee0a5dc7db21da0735d594929eeb
|
||||
IK_LLAMA_VERSION?=2ae132fa601ea06818ed3584f50f7eb4f72d4967
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/sources/
|
||||
/build-native/
|
||||
/build-cpu/
|
||||
/build-vulkan/
|
||||
/build-native-avx2/
|
||||
/build-cpu-avx2/
|
||||
/build-vulkan-avx2/
|
||||
/package/
|
||||
/kimodocpp
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,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,5 +1,5 @@
|
||||
grpcio==1.83.0
|
||||
protobuf==7.35.0
|
||||
protobuf==7.36.1
|
||||
certifi
|
||||
setuptools
|
||||
scipy==1.18.0
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
grpcio==1.83.1
|
||||
grpcio==1.84.0
|
||||
protobuf
|
||||
certifi
|
||||
setuptools
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" }))
|
||||
})
|
||||
@@ -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"`
|
||||
|
||||
@@ -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/)"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 @@
|
||||
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",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user