diff --git a/.dockerignore b/.dockerignore index 4de42deef..5a71590bd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,8 @@ volumes examples/chatbot-ui/models backend/go/image/stablediffusion-ggml/build/ backend/go/*/build +backend/go/kimodocpp/build-* +backend/go/kimodocpp/kimodocpp backend/go/*/.cache backend/go/*/sources backend/go/*/package diff --git a/.github/backend-matrix.yml b/.github/backend-matrix.yml index 53cb07d8d..fa61fa514 100644 --- a/.github/backend-matrix.yml +++ b/.github/backend-matrix.yml @@ -3559,6 +3559,63 @@ include: dockerfile: "./backend/Dockerfile.golang" context: "./" ubuntu-version: '2404' + # kimodo.cpp: CPU and Vulkan, with native runners for each architecture. + - build-type: '' + cuda-major-version: "" + cuda-minor-version: "" + platforms: 'linux/amd64' + platform-tag: 'amd64' + tag-latest: 'auto' + tag-suffix: '-cpu-kimodocpp' + runs-on: 'ubuntu-latest' + base-image: "ubuntu:24.04" + skip-drivers: 'false' + backend: "kimodocpp" + dockerfile: "./backend/Dockerfile.golang" + context: "./" + ubuntu-version: '2404' + - build-type: '' + cuda-major-version: "" + cuda-minor-version: "" + platforms: 'linux/arm64' + platform-tag: 'arm64' + tag-latest: 'auto' + tag-suffix: '-cpu-kimodocpp' + runs-on: 'ubuntu-24.04-arm' + base-image: "ubuntu:24.04" + skip-drivers: 'false' + backend: "kimodocpp" + dockerfile: "./backend/Dockerfile.golang" + context: "./" + ubuntu-version: '2404' + - build-type: 'vulkan' + cuda-major-version: "" + cuda-minor-version: "" + platforms: 'linux/amd64' + platform-tag: 'amd64' + tag-latest: 'auto' + tag-suffix: '-gpu-vulkan-kimodocpp' + runs-on: 'ubuntu-latest' + base-image: "ubuntu:24.04" + skip-drivers: 'false' + backend: "kimodocpp" + dockerfile: "./backend/Dockerfile.golang" + context: "./" + ubuntu-version: '2404' + - build-type: 'vulkan' + cuda-major-version: "" + cuda-minor-version: "" + platforms: 'linux/arm64' + platform-tag: 'arm64' + tag-latest: 'auto' + tag-suffix: '-gpu-vulkan-kimodocpp' + runs-on: 'ubuntu-24.04-arm' + base-image: "ubuntu:24.04" + skip-drivers: 'false' + backend: "kimodocpp" + dockerfile: "./backend/Dockerfile.golang" + context: "./" + ubuntu-version: '2404' # trellis2cpp - build-type: '' cuda-major-version: "" @@ -6461,6 +6518,10 @@ include: # Darwin matrix (consumed by backend-jobs-darwin). includeDarwin: + - backend: "kimodocpp" + tag-suffix: "-cpu-darwin-arm64-kimodocpp" + build-type: "cpu" + lang: "go" - backend: "diffusers" tag-suffix: "-metal-darwin-arm64-diffusers" build-type: "mps" diff --git a/.github/workflows/bump_deps.yaml b/.github/workflows/bump_deps.yaml index d320ab509..e8569473a 100644 --- a/.github/workflows/bump_deps.yaml +++ b/.github/workflows/bump_deps.yaml @@ -90,6 +90,10 @@ jobs: variable: "TRELLIS2CPP_VERSION" branch: "pbr-textures" file: "backend/go/trellis2cpp/Makefile" + - repository: "localai-org/kimodo.cpp" + variable: "KIMODO_VERSION" + branch: "main" + file: "backend/go/kimodocpp/Makefile" - repository: "mudler/go-piper" variable: "PIPER_VERSION" branch: "master" @@ -176,7 +180,7 @@ jobs: if: github.repository == 'mudler/LocalAI' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Bump CTranslate2 ROCm wheel pin 🔧 id: bump run: | diff --git a/.github/workflows/test-extra.yml b/.github/workflows/test-extra.yml index 8087a9413..4ca43d986 100644 --- a/.github/workflows/test-extra.yml +++ b/.github/workflows/test-extra.yml @@ -39,6 +39,7 @@ jobs: qwen3-tts-cpp: ${{ steps.detect.outputs.qwen3-tts-cpp }} magpie-tts-cpp: ${{ steps.detect.outputs.magpie-tts-cpp }} trellis2cpp: ${{ steps.detect.outputs.trellis2cpp }} + kimodocpp: ${{ steps.detect.outputs.kimodocpp }} rfdetr-cpp: ${{ steps.detect.outputs.rfdetr-cpp }} locate-anything-cpp: ${{ steps.detect.outputs.locate-anything-cpp }} vibevoice-cpp: ${{ steps.detect.outputs.vibevoice-cpp }} @@ -989,9 +990,31 @@ jobs: - name: Test rfdetr-cpp run: | make --jobs=5 --output-sync=target -C backend/go/rfdetr-cpp test - # Weight-free packaged-backend smoke for trellis2cpp. Starting run.sh loads - # libtrellis2 + ggml, resolves the complete C ABI (including remeshing), and - # answers gRPC Health without downloading or loading the multi-GB model set. + # Load the packaged Kimodo C ABI and answer gRPC Health without model downloads. + tests-kimodocpp: + needs: detect-changes + if: needs.detect-changes.outputs.kimodocpp == 'true' || needs.detect-changes.outputs.run-all == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y g++-14 cmake curl unzip + - name: Generate protocol bindings + run: make protogen-go + - name: Build and test CPU backend without model downloads + env: + CXX: g++-14 + CC: gcc-14 + run: | + make -C backend/go/kimodocpp BUILD_TYPE=cpu JOBS=4 + KIMODO_TEST_PACKAGE="$PWD/backend/go/kimodocpp/package" make -C backend/go/kimodocpp test + tests-trellis2cpp: needs: detect-changes if: needs.detect-changes.outputs.trellis2cpp == 'true' || needs.detect-changes.outputs.run-all == 'true' diff --git a/ADOPTERS.md b/ADOPTERS.md index a8edc0281..9226256cd 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -27,6 +27,7 @@ To be removed, open a pull request deleting your row, or email | Organisation | What they use it for | Status | |---|---|---| +| [solstone](https://solstone.app) | Runs [parakeet.cpp](https://github.com/mudler/parakeet.cpp) on-device as our speech-to-text server. | Production | | [walcz.de](https://walcz.de) | Self-hosted appliance for a German B2B consultancy: local-only inference on AMD Strix Halo (gfx1151/ROCm), agents with MCP tools, RAG over an internal knowledge base, and a document/bookkeeping pipeline. | Production | | _Your organisation here_ | | | diff --git a/Makefile b/Makefile index 51f61fc63..b2034aaa6 100644 --- a/Makefile +++ b/Makefile @@ -641,6 +641,7 @@ prepare-test-extra: protogen-python $(MAKE) -C backend/go/rfdetr-cpp $(MAKE) -C backend/go/locate-anything-cpp $(MAKE) -C backend/go/trellis2cpp + $(MAKE) -C backend/go/kimodocpp $(MAKE) -C backend/go/valkey-store test-extra: prepare-test-extra @@ -679,6 +680,7 @@ test-extra: prepare-test-extra $(MAKE) -C backend/go/vllm-cpp test $(MAKE) -C backend/go/nemo-speech-cpp test $(MAKE) -C backend/go/trellis2cpp test + $(MAKE) -C backend/go/kimodocpp test $(MAKE) -C backend/go/valkey-store test ## @@ -1334,6 +1336,7 @@ BACKEND_HUGGINGFACE = huggingface|golang|.|false|true BACKEND_SILERO_VAD = silero-vad|golang|.|false|true BACKEND_STABLEDIFFUSION_GGML = stablediffusion-ggml|golang|.|--progress=plain|true BACKEND_TRELLIS2CPP = trellis2cpp|golang|.|--progress=plain|true +BACKEND_KIMODOCPP = kimodocpp|golang|.|--progress=plain|true BACKEND_WHISPER = whisper|golang|.|false|true BACKEND_CRISPASR = crispasr|golang|.|false|true BACKEND_PARAKEET_CPP = parakeet-cpp|golang|.|false|true @@ -1439,6 +1442,14 @@ $(eval $(call generate-docker-build-target,$(BACKEND_HUGGINGFACE))) $(eval $(call generate-docker-build-target,$(BACKEND_SILERO_VAD))) $(eval $(call generate-docker-build-target,$(BACKEND_STABLEDIFFUSION_GGML))) $(eval $(call generate-docker-build-target,$(BACKEND_TRELLIS2CPP))) +$(eval $(call generate-docker-build-target,$(BACKEND_KIMODOCPP))) +.NOTPARALLEL: backends/kimodocpp backends/kimodocpp-darwin +docker-build-backends: docker-build-kimodocpp + +backends/kimodocpp-darwin: + BACKEND=kimodocpp BUILD_TYPE=cpu $(MAKE) build-darwin-go-backend + ./local-ai backends install "ocifile://$(abspath ./backend-images/kimodocpp.tar)" + $(eval $(call generate-docker-build-target,$(BACKEND_WHISPER))) $(eval $(call generate-docker-build-target,$(BACKEND_CRISPASR))) $(eval $(call generate-docker-build-target,$(BACKEND_PARAKEET_CPP))) diff --git a/README.md b/README.md index 60c788c50..e4c1da1e3 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ Most backends wrap a best-in-class upstream engine. A handful of them are native | [face-detect.cpp](https://github.com/mudler/face-detect.cpp) | Face detection, recognition, demographics and anti-spoofing (SCRFD/ArcFace, YuNet/SFace), replacing the Python insightface backend | | [free-splatter.cpp](https://github.com/localai-org/free-splatter.cpp) | Pose-free 3D reconstruction (FreeSplatter): turns a handful of plain photos into 3D Gaussians, no camera poses or GPU required | | [trellis2.cpp](https://github.com/localai-org/trellis2cpp) | C++/GGML port of Microsoft TRELLIS.2: single-image to textured 3D mesh (GLB with PBR materials) | +| [kimodo.cpp](https://github.com/localai-org/kimodo.cpp) | C++/GGML text-to-motion on CPU and Vulkan, exported as animated skeleton GLB | | [privacy-filter.cpp](https://github.com/localai-org/privacy-filter.cpp) | Standalone GGML PII/NER token-classification engine powering LocalAI's PII redaction tier | | [LocalVQE](https://github.com/localai-org/LocalVQE) | Joint acoustic echo cancellation, noise suppression, and dereverberation | | [local-store](https://github.com/mudler/LocalAI) | Local-first vector database for embeddings (shipped in-tree) | @@ -295,6 +296,9 @@ A huge thank you to our generous sponsors who support this project covering CI e + + +

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