diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6d0e30e7a..a7ae060fb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,7 +5,7 @@ This PR fixes # **Notes for Reviewers** -**[Signed commits](../CONTRIBUTING.md#signing-off-on-commits-developer-certificate-of-origin)** +**[Signed commits](../CONTRIBUTING.md#commit-messages)** - [ ] Yes, I signed my commits. - [ ] Documentation updated (docs/content/) for user-facing changes, or not applicable diff --git a/Makefile b/Makefile index cf3248c3b..7db3ea11e 100644 --- a/Makefile +++ b/Makefile @@ -240,7 +240,7 @@ test-ci-scripts: ## pure stdlib on purpose so they run without any backend venv; the list is ## explicit because their siblings (model_identity_test) import grpc and the ## generated protobufs, which only exist inside a built backend. -PYTHON_HELPER_TESTS?=python_utils_test vllm_utils_test model_utils_test mlx_utils_test parent_watch_test +PYTHON_HELPER_TESTS?=python_utils_test vllm_utils_test model_utils_test mlx_utils_test parent_watch_test temp_utils_test test-python-helpers: cd backend/python/common && python3 -m unittest $(PYTHON_HELPER_TESTS) diff --git a/backend/cpp/audio-cpp/Makefile b/backend/cpp/audio-cpp/Makefile index 572667f60..aa33c0bb7 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?=c18b7f737aac0a2855e9f963a427498739ad40fe +AUDIO_CPP_VERSION?=9c6a282337cc83f227cc10428867a478947706ad AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) diff --git a/backend/cpp/ds4/CMakeLists.txt b/backend/cpp/ds4/CMakeLists.txt index 0535a8a44..5783db942 100644 --- a/backend/cpp/ds4/CMakeLists.txt +++ b/backend/cpp/ds4/CMakeLists.txt @@ -84,9 +84,10 @@ elseif(DS4_GPU STREQUAL "cpu") set(DS4_OBJS "${DS4_DIR}/ds4_cpu.o") endif() -# Upstream splits distributed inference, tensor-parallel transport, the SSD -# expert cache, and layer placement into GPU-agnostic translation units. Link -# them regardless of DS4_GPU. +# 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. +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") list(APPEND DS4_OBJS "${DS4_DIR}/ds4_ssd.o") diff --git a/backend/cpp/ds4/Makefile b/backend/cpp/ds4/Makefile index 807a01a09..c2e69c82a 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?=8db89fe083ae4d17c9a2428ccd29803d3ae8f577 +# Upstream pin lives below as DS4_VERSION?=f62ca29a308724cde5bc99134ede19104b2a3260 # (.github/bump_deps.sh) can find and update it - matches the # llama-cpp / ik-llama-cpp / turboquant convention. -DS4_VERSION?=8db89fe083ae4d17c9a2428ccd29803d3ae8f577 +DS4_VERSION?=f62ca29a308724cde5bc99134ede19104b2a3260 DS4_REPO?=https://github.com/antirez/ds4 CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -79,21 +79,22 @@ else endif endif -# Upstream splits 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. +# 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. ifeq ($(BUILD_TYPE),cublas) CMAKE_ARGS += -DDS4_GPU=cuda - DS4_OBJ_TARGET := ds4.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 \ 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_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 else # CPU reference path (Linux only - macOS CPU path is broken by VM bug per ds4 README). CMAKE_ARGS += -DDS4_GPU=cpu - DS4_OBJ_TARGET := ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o + DS4_OBJ_TARGET := ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o endif ifneq ($(NATIVE),true) @@ -120,9 +121,9 @@ 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_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 else - +$(MAKE) -C ds4 ds4_cpu.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o + +$(MAKE) -C ds4 ds4_cpu.o ds4_image.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o endif grpc-server: ds4/ds4.o diff --git a/backend/cpp/ik-llama-cpp/Makefile b/backend/cpp/ik-llama-cpp/Makefile index ca4c45114..1d0304874 100644 --- a/backend/cpp/ik-llama-cpp/Makefile +++ b/backend/cpp/ik-llama-cpp/Makefile @@ -1,5 +1,5 @@ -IK_LLAMA_VERSION?=caf7eae5282d840d77e9f91a56df7d2ef28fa612 +IK_LLAMA_VERSION?=fe215a8ccdce6b844d2a3a3bbde08ae76a6284bf 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 f6d8bd16f..b1c3d2604 100644 --- a/backend/cpp/llama-cpp/Makefile +++ b/backend/cpp/llama-cpp/Makefile @@ -1,5 +1,5 @@ -LLAMA_VERSION?=9cffdcc801582616250520966699cb5b25d28243 +LLAMA_VERSION?=67672dc5b76f8bc17785a19d3dc6d1463fc2902c LLAMA_REPO?=https://github.com/ggerganov/llama.cpp CMAKE_ARGS?= diff --git a/backend/go/crispasr/Makefile b/backend/go/crispasr/Makefile index 50293f642..3a3538346 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?=ff3945c94cab9191199a5d531a32c4e9535c094b +CRISPASR_VERSION?=301acd87b036764973b8bfba71e0a21818036d33 SO_TARGET?=libgocrispasr.so CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF diff --git a/backend/go/crispasr/gocrispasr.go b/backend/go/crispasr/gocrispasr.go index be431165d..4f5b91d6a 100644 --- a/backend/go/crispasr/gocrispasr.go +++ b/backend/go/crispasr/gocrispasr.go @@ -615,10 +615,10 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error { return fmt.Errorf("crispasr: tempfile: %w", err) } dst := tmp.Name() + defer func() { _ = os.Remove(dst) }() if err := tmp.Close(); err != nil { return fmt.Errorf("crispasr: close tempfile: %w", err) } - defer func() { _ = os.Remove(dst) }() if err := writeWAV(dst, pcm, w.sampleRate); err != nil { return err diff --git a/backend/go/depth-anything-cpp/Makefile b/backend/go/depth-anything-cpp/Makefile index 267955618..b2574603c 100644 --- a/backend/go/depth-anything-cpp/Makefile +++ b/backend/go/depth-anything-cpp/Makefile @@ -14,7 +14,7 @@ JOBS?=$(shell nproc --ignore=1) # It is kept alive by the upstream tag da2-support (survives a squash-merge); # repoint to the master merge commit once mudler/depth-anything.cpp PR #1 lands. DEPTHANYTHING_REPO?=https://github.com/mudler/depth-anything.cpp.git -DEPTHANYTHING_VERSION?=02ba082274e001a63e50de5a1eb0ccc50c6af4b1 +DEPTHANYTHING_VERSION?=14f7461d1f704761a038ac9f50dbde8fdb7275e2 ifeq ($(NATIVE),false) CMAKE_ARGS+=-DGGML_NATIVE=OFF diff --git a/backend/go/locate-anything-cpp/golocateanythingcpp.go b/backend/go/locate-anything-cpp/golocateanythingcpp.go index 25c7b80c5..678ed30aa 100644 --- a/backend/go/locate-anything-cpp/golocateanythingcpp.go +++ b/backend/go/locate-anything-cpp/golocateanythingcpp.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "unsafe" "github.com/mudler/LocalAI/pkg/grpc/base" @@ -109,30 +110,25 @@ func (r *LocateAnythingCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, e return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: a text prompt is required (open-vocabulary detection)") } - // Decode base64 image and write to temp file. imgData, err := base64.StdEncoding.DecodeString(opts.Src) if err != nil { return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to decode base64 image: %w", err) } - - tmpFile, err := os.CreateTemp("", "locate-anything-*.img") - if err != nil { - return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to create temp file: %w", err) - } - defer func() { _ = os.Remove(tmpFile.Name()) }() - - if _, err := tmpFile.Write(imgData); err != nil { - _ = tmpFile.Close() - return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to write temp file: %w", err) - } - if err := tmpFile.Close(); err != nil { - return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: failed to close temp file: %w", err) + if len(imgData) == 0 { + return pb.DetectResponse{}, fmt.Errorf("locate-anything-cpp: decoded image is empty") } // mode 0 = hybrid (Parallel Box Decoding). The JSON return value is unused: // structured detections are read via the accessor functions. Still must // free the returned string. - jsonPtr := CapiLocatePath(r.handle, tmpFile.Name(), prompt, 0) + jsonPtr := CapiLocateBuffer( + r.handle, + uintptr(unsafe.Pointer(unsafe.SliceData(imgData))), + uintptr(len(imgData)), + prompt, + 0, + ) + runtime.KeepAlive(imgData) if jsonPtr != 0 { CapiFreeString(jsonPtr) } diff --git a/backend/go/locate-anything-cpp/golocateanythingcpp_unit_test.go b/backend/go/locate-anything-cpp/golocateanythingcpp_unit_test.go new file mode 100644 index 000000000..3fa7d09df --- /dev/null +++ b/backend/go/locate-anything-cpp/golocateanythingcpp_unit_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/base64" + "path/filepath" + + pb "github.com/mudler/LocalAI/pkg/grpc/proto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LocateAnythingCpp detection input", func() { + It("detects from memory when the temporary directory is unavailable", func() { + originalLocateBuffer := CapiLocateBuffer + originalLocatePath := CapiLocatePath + originalGetNDetections := CapiGetNDetections + defer func() { + CapiLocateBuffer = originalLocateBuffer + CapiLocatePath = originalLocatePath + CapiGetNDetections = originalGetNDetections + }() + + image := []byte("encoded-image") + var receivedData uintptr + var receivedLength uintptr + CapiLocateBuffer = func(_ uintptr, data uintptr, length uintptr, _ string, _ int32) uintptr { + receivedData = data + receivedLength = length + return 0 + } + CapiLocatePath = func(_ uintptr, _ string, _ string, _ int32) uintptr { + Fail("path-based detection must not be called") + return 0 + } + CapiGetNDetections = func(uintptr) int32 { return 0 } + GinkgoT().Setenv("TMPDIR", filepath.Join(GinkgoT().TempDir(), "missing")) + + result, err := (&LocateAnythingCpp{handle: 1}).Detect(&pb.DetectOptions{ + Src: base64.StdEncoding.EncodeToString(image), + Prompt: "the object", + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.Detections).To(BeEmpty()) + Expect(receivedData).NotTo(BeZero()) + Expect(receivedLength).To(Equal(uintptr(len(image)))) + }) + + It("rejects an empty decoded image", func() { + _, err := (&LocateAnythingCpp{handle: 1}).Detect(&pb.DetectOptions{Prompt: "the object"}) + + Expect(err).To(MatchError("locate-anything-cpp: decoded image is empty")) + }) +}) diff --git a/backend/go/nemo-speech-cpp/Makefile b/backend/go/nemo-speech-cpp/Makefile index 24b944e11..1496b4244 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?=56b60d432f1731d6d5b28a4c5a31cbaf871daba1 +NEMO_SPEECH_VERSION?=ffa38cb2408f1e832a36d46fef5e3e1e80d07e6c 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 4cb089dab..92810a5ba 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?=4f33af825d66e6ef1cb185e87b4589cacf747291 +OMNIVOICE_VERSION?=040c8b344d8c670ce1475194751d119b5ef82c78 SO_TARGET?=libgomnivoicecpp.so CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF diff --git a/backend/go/rfdetr-cpp/gorfdetrcpp.go b/backend/go/rfdetr-cpp/gorfdetrcpp.go index ae45319e1..7e507780b 100644 --- a/backend/go/rfdetr-cpp/gorfdetrcpp.go +++ b/backend/go/rfdetr-cpp/gorfdetrcpp.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strconv" "unsafe" @@ -102,24 +103,12 @@ func (r *RFDetrCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, error) { return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: model not loaded") } - // Decode base64 image and write to temp file. imgData, err := base64.StdEncoding.DecodeString(opts.Src) if err != nil { return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to decode base64 image: %w", err) } - - tmpFile, err := os.CreateTemp("", "rfdetr-*.img") - if err != nil { - return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to create temp file: %w", err) - } - defer func() { _ = os.Remove(tmpFile.Name()) }() - - if _, err := tmpFile.Write(imgData); err != nil { - _ = tmpFile.Close() - return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to write temp file: %w", err) - } - if err := tmpFile.Close(); err != nil { - return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: failed to close temp file: %w", err) + if len(imgData) == 0 { + return pb.DetectResponse{}, fmt.Errorf("rfdetr-cpp: decoded image is empty") } threshold := opts.Threshold @@ -127,10 +116,18 @@ func (r *RFDetrCpp) Detect(opts *pb.DetectOptions) (pb.DetectResponse, error) { threshold = 0.5 } - // JSON output from detect_path is unused: we read structured detections via + // JSON output from the detection ABI is unused: we read structured detections via // the accessor functions. Still must free the returned string. var jsonPtr uintptr - rc := CapiDetectPath(r.handle, tmpFile.Name(), threshold, uint32(defaultTopK), &jsonPtr) + rc := CapiDetectBuffer( + r.handle, + uintptr(unsafe.Pointer(unsafe.SliceData(imgData))), + uintptr(len(imgData)), + threshold, + uint32(defaultTopK), + &jsonPtr, + ) + runtime.KeepAlive(imgData) if jsonPtr != 0 { CapiFreeString(jsonPtr) } diff --git a/backend/go/rfdetr-cpp/gorfdetrcpp_unit_test.go b/backend/go/rfdetr-cpp/gorfdetrcpp_unit_test.go new file mode 100644 index 000000000..266fa44b9 --- /dev/null +++ b/backend/go/rfdetr-cpp/gorfdetrcpp_unit_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/base64" + "path/filepath" + + pb "github.com/mudler/LocalAI/pkg/grpc/proto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("RFDetrCpp detection input", func() { + It("detects from memory when the temporary directory is unavailable", func() { + originalDetectBuffer := CapiDetectBuffer + originalDetectPath := CapiDetectPath + originalFreeString := CapiFreeString + originalGetNDetections := CapiGetNDetections + defer func() { + CapiDetectBuffer = originalDetectBuffer + CapiDetectPath = originalDetectPath + CapiFreeString = originalFreeString + CapiGetNDetections = originalGetNDetections + }() + + image := []byte("encoded-image") + var receivedData uintptr + var receivedLength uintptr + CapiDetectBuffer = func(_ uintptr, data uintptr, length uintptr, _ float32, _ uint32, _ *uintptr) int32 { + receivedData = data + receivedLength = length + return 0 + } + CapiDetectPath = func(_ uintptr, _ string, _ float32, _ uint32, _ *uintptr) int32 { + Fail("path-based detection must not be called") + return -1 + } + CapiFreeString = func(uintptr) {} + CapiGetNDetections = func(uintptr) int32 { return 0 } + GinkgoT().Setenv("TMPDIR", filepath.Join(GinkgoT().TempDir(), "missing")) + + result, err := (&RFDetrCpp{handle: 1}).Detect(&pb.DetectOptions{ + Src: base64.StdEncoding.EncodeToString(image), + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.Detections).To(BeEmpty()) + Expect(receivedData).NotTo(BeZero()) + Expect(receivedLength).To(Equal(uintptr(len(image)))) + }) + + It("rejects an empty decoded image", func() { + _, err := (&RFDetrCpp{handle: 1}).Detect(&pb.DetectOptions{}) + + Expect(err).To(MatchError("rfdetr-cpp: decoded image is empty")) + }) +}) diff --git a/backend/go/stablediffusion-ggml/Makefile b/backend/go/stablediffusion-ggml/Makefile index 1f8e70402..bf3c2dfb2 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?=6b3edaaf32cc19e5bb2d819c788bd557eddc8eba +STABLEDIFFUSION_GGML_VERSION?=d04e8950c1ec8d30248cbe996682b3182fb1adf6 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 12cc4a83e..b876df256 100644 --- a/backend/go/stablediffusion-ggml/cpp/gosd.cpp +++ b/backend/go/stablediffusion-ggml/cpp/gosd.cpp @@ -401,7 +401,6 @@ int load_model(const char *model, char *model_path, char* options[], int threads const char *params_backend_arg = ""; const char *rpc_servers_arg = ""; const char *max_vram_arg = ""; - bool stream_layers = false; int n_threads = threads; enum sd_type_t wtype = SD_TYPE_COUNT; @@ -510,7 +509,10 @@ int load_model(const char *model, char *model_path, char* options[], int threads if (!strcmp(optname, "params_backend")) params_backend_arg = strdup(optval); if (!strcmp(optname, "rpc_servers")) rpc_servers_arg = strdup(optval); if (!strcmp(optname, "max_vram")) max_vram_arg = strdup(optval); - if (!strcmp(optname, "stream_layers")) stream_layers = (strcmp(optval, "true") == 0 || strcmp(optval, "1") == 0); + if (!strcmp(optname, "stream_layers")) { + // Retained as a no-op for existing configurations. Upstream now + // selects segmented weight streaming automatically. + } // vae_decode_only is still accepted for backwards compatibility with // existing gallery configs, but upstream dropped the option (the model @@ -650,11 +652,9 @@ int load_model(const char *model, char *model_path, char* options[], int threads ctx_params.rpc_servers = env_rpc_servers; } } - // max_vram: GiB budget or per-backend spec for graph-cut segmented param - // offload ("0" = disabled, "-1" = auto). stream_layers only has effect when - // max_vram is set. + // max_vram is an optional GiB budget or per-backend spec for automatic + // graph-cut execution. A zero value uses the live free-VRAM budget. if (strlen(max_vram_arg) > 0) ctx_params.max_vram = max_vram_arg; - ctx_params.stream_layers = stream_layers; ctx_params.diffusion_flash_attn = diffusion_flash_attn; ctx_params.tae_preview_only = tae_preview_only; ctx_params.diffusion_conv_direct = diffusion_conv_direct; @@ -1144,17 +1144,25 @@ static uint8_t* load_and_resize_image(const char* path, int target_width, int ta // Write sd.cpp's audio buffer to a temp WAV file (IEEE float, interleaved). // sd_audio_t.data is planar (all channel 0 samples, then channel 1, etc.) — we // interleave on the fly so ffmpeg's standard wav demuxer can read it directly. -// Returns 0 on success and fills wav_path (must be at least 64 bytes). +// Returns 0 on success and fills wav_path. static int write_planar_float_wav(const sd_audio_t* a, char* wav_path, size_t wav_path_sz) { if (!a || !a->data || a->sample_count == 0 || a->channels == 0 || a->sample_rate == 0) { return -1; } - snprintf(wav_path, wav_path_sz, "/tmp/gosd-audio-XXXXXX.wav"); + const char* temp_dir = getenv("TMPDIR"); + if (!temp_dir || temp_dir[0] == '\0') { + temp_dir = "/tmp"; + } + int path_len = snprintf(wav_path, wav_path_sz, "%s/gosd-audio-XXXXXX.wav", temp_dir); + if (path_len < 0 || (size_t)path_len >= wav_path_sz) { + fprintf(stderr, "temporary directory path is too long\n"); + return -1; + } int fd = mkstemps(wav_path, 4); if (fd < 0) { perror("mkstemps wav"); return -1; } FILE* f = fdopen(fd, "wb"); - if (!f) { perror("fdopen wav"); close(fd); return -1; } + if (!f) { perror("fdopen wav"); close(fd); unlink(wav_path); return -1; } uint64_t frames = a->sample_count; uint32_t channels = a->channels; @@ -1221,7 +1229,7 @@ static int ffmpeg_mux_raw_to_mp4(sd_image_t* frames, int num_frames, int fps, snprintf(fps_str, sizeof(fps_str), "%d", fps); // Optional audio: write a temp WAV file if the model produced audio. - char wav_path[64] = {0}; + char wav_path[4096] = {0}; bool have_audio = false; if (audio && audio->data && audio->sample_count > 0 && audio->channels > 0 && audio->sample_rate > 0) { if (write_planar_float_wav(audio, wav_path, sizeof(wav_path)) == 0) { @@ -1438,4 +1446,3 @@ int unload() { free_sd_ctx(sd_c); return 0; } - diff --git a/backend/go/vllm-cpp/options.go b/backend/go/vllm-cpp/options.go index 86717e7ac..b75e5a7fc 100644 --- a/backend/go/vllm-cpp/options.go +++ b/backend/go/vllm-cpp/options.go @@ -128,9 +128,40 @@ func parseOptions(opts *pb.ModelOptions) loadOptions { lo := loadOptions{} applyOptionsList(&lo, opts.GetOptions()) applyEngineArgs(&lo, opts.GetEngineArgs()) + applyDraftModelOption(&lo, opts.GetOptions()) return lo } +// applyDraftModelOption binds a managed companion snapshot after engine_args +// has supplied the speculative document. Companion paths do not exist until +// LocalAI materializes the artifact, so they must replace the gallery's static +// repository reference without disturbing the method or token budget. +func applyDraftModelOption(lo *loadOptions, options []string) { + if strings.TrimSpace(lo.speculativeConfig) == "" { + return + } + var draftModel string + for _, option := range options { + key, value, found := strings.Cut(option, ":") + if found && strings.TrimSpace(key) == "draft_model" { + draftModel = strings.TrimSpace(value) + } + } + if draftModel == "" { + return + } + + var spec map[string]any + if err := json.Unmarshal([]byte(lo.speculativeConfig), &spec); err != nil { + return + } + spec["model"] = draftModel + encoded, err := json.Marshal(spec) + if err == nil { + lo.speculativeConfig = string(encoded) + } +} + // applyOptionsList reads the legacy free-form "key:value" list. strings.Cut // splits on the FIRST colon only, so a JSON object value survives intact. func applyOptionsList(lo *loadOptions, options []string) { diff --git a/backend/go/vllm-cpp/options_test.go b/backend/go/vllm-cpp/options_test.go new file mode 100644 index 000000000..11b222b4a --- /dev/null +++ b/backend/go/vllm-cpp/options_test.go @@ -0,0 +1,38 @@ +package main + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + pb "github.com/mudler/LocalAI/pkg/grpc/proto" +) + +var _ = Describe("managed DFlash companion options", func() { + It("replaces only the draft model in an existing speculative configuration", func() { + managedPath := ".artifacts/huggingface/0123456789abcdef/snapshot" + lo := parseOptions(&pb.ModelOptions{ + Options: []string{"draft_model:" + managedPath}, + EngineArgs: `{ + "speculative_config": { + "method": "dflash", + "model": "Mia-AiLab/Qwen3.8-27B-DFlash2-EXL3-5.0bpw", + "num_speculative_tokens": 7 + } + }`, + }) + + Expect(lo.speculativeConfig).To(MatchJSON(`{ + "method": "dflash", + "model": ".artifacts/huggingface/0123456789abcdef/snapshot", + "num_speculative_tokens": 7 + }`)) + }) + + It("ignores a draft companion when speculative decoding is not configured", func() { + lo := parseOptions(&pb.ModelOptions{ + Options: []string{"draft_model:.artifacts/huggingface/0123456789abcdef/snapshot"}, + }) + + Expect(lo.speculativeConfig).To(BeEmpty()) + }) +}) diff --git a/backend/go/whisper/Makefile b/backend/go/whisper/Makefile index c1844ff6d..bd789a915 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?=eacbd8234c6654cdbf2c377f72b2106875479bdc +WHISPER_CPP_VERSION?=52a939a2a762224e255d366c1182b2af4dd1a032 SO_TARGET?=libgowhisper.so CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF diff --git a/backend/python/chatterbox/backend.py b/backend/python/chatterbox/backend.py index 016925806..996d4e50b 100644 --- a/backend/python/chatterbox/backend.py +++ b/backend/python/chatterbox/backend.py @@ -19,6 +19,7 @@ import grpc sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common')) from grpc_auth import get_auth_interceptors +from temp_utils import cleanup_paths import tempfile @@ -115,11 +116,6 @@ def merge_audio_files(audio_files, output_path, sample_rate): # Save the merged audio ta.save(output_path, merged_waveform, sample_rate) - # Clean up temporary files - for audio_file in audio_files: - if os.path.exists(audio_file): - os.remove(audio_file) - _ONE_DAY_IN_SECONDS = 60 * 60 * 24 # If MAX_WORKERS are specified in the environment use it, otherwise default to 1 @@ -226,19 +222,20 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): text_chunks = split_text_at_word_boundary(request.text, max_length=250) print(f"Splitting text into chunks of 250 characters: {len(text_chunks)}", file=sys.stderr) # Generate audio for each chunk - temp_audio_files = [] - for i, chunk in enumerate(text_chunks): - # Generate audio for this chunk - wav = self.model.generate(chunk, **kwargs) - - # Create temporary file for this chunk - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav') - temp_file.close() - ta.save(temp_file.name, wav, self.model.sr) - temp_audio_files.append(temp_file.name) - - # Merge all audio files - merge_audio_files(temp_audio_files, request.dst, self.model.sr) + with cleanup_paths() as temp_audio_files: + for i, chunk in enumerate(text_chunks): + # Generate audio for this chunk + wav = self.model.generate(chunk, **kwargs) + + # Register ownership before saving so a partial write is + # removed too when generation or encoding fails. + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.wav') + temp_file.close() + temp_audio_files.append(temp_file.name) + ta.save(temp_file.name, wav, self.model.sr) + + # Merge all audio files + merge_audio_files(temp_audio_files, request.dst, self.model.sr) else: # Generate audio using ChatterboxTTS for short text wav = self.model.generate(request.text, **kwargs) diff --git a/backend/python/common/python_utils.py b/backend/python/common/python_utils.py index c89813e2c..88ec0a530 100644 --- a/backend/python/common/python_utils.py +++ b/backend/python/common/python_utils.py @@ -37,6 +37,46 @@ def parse_options(options_list): return opts +def attach_media_parts(messages_dicts, n_images=0, n_videos=0): + """Rebuild the last user message as content *parts* carrying media markers. + + Backends that let the tokenizer do the templating hand plain string content + to ``apply_chat_template``, but a chat template only emits the model's own + media tokens (``<|vision_start|><|image_pad|><|vision_end|>`` for the + Qwen-VL family, and the equivalents elsewhere) when the content is a list + of parts. Without those markers the engine's multimodal processor finds + nothing to substitute and silently discards the pixels, even though they + were forwarded correctly out of band. + + Returns a new list whose last user message has + ``[{"type": "image"} * n_images, {"type": "video"} * n_videos, text]`` as + its content, or ``None`` when there is nothing to attach - no media, no + user turn, or content that is already a list of parts - so the caller can + keep using the original string-content list. + """ + if not n_images and not n_videos: + return None + idx = next( + ( + i + for i in reversed(range(len(messages_dicts))) + if messages_dicts[i].get("role") == "user" + ), + None, + ) + if idx is None: + return None + text = messages_dicts[idx].get("content") or "" + if not isinstance(text, str): + return None + parts = [{"type": "image"}] * n_images + [{"type": "video"}] * n_videos + if text: + parts.append({"type": "text", "text": text}) + patched = list(messages_dicts) + patched[idx] = dict(patched[idx], content=parts) + return patched + + def messages_to_dicts(proto_messages): """Convert proto ``Message`` objects to dicts suitable for ``apply_chat_template``. diff --git a/backend/python/common/python_utils_test.py b/backend/python/common/python_utils_test.py index c395ce92d..d12bac9c5 100644 --- a/backend/python/common/python_utils_test.py +++ b/backend/python/common/python_utils_test.py @@ -14,7 +14,7 @@ import json import types import unittest -from python_utils import messages_to_dicts, parse_options +from python_utils import attach_media_parts, messages_to_dicts, parse_options def _msg(**fields): @@ -118,5 +118,63 @@ class TestMessagesToDicts(unittest.TestCase): self.assertNotIn("tool_calls", out[0]) +class TestAttachMediaParts(unittest.TestCase): + def test_image_marker_added_to_last_user_turn(self): + messages = [ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "how high is the water?"}, + ] + out = attach_media_parts(messages, n_images=1) + self.assertEqual( + out[3]["content"], + [{"type": "image"}, {"type": "text", "text": "how high is the water?"}], + ) + # Earlier turns and the input list itself are untouched. + self.assertEqual(out[:3], messages[:3]) + self.assertEqual(messages[3]["content"], "how high is the water?") + + def test_counts_and_order_images_then_videos(self): + out = attach_media_parts( + [{"role": "user", "content": "describe"}], n_images=2, n_videos=1 + ) + self.assertEqual( + out[0]["content"], + [ + {"type": "image"}, + {"type": "image"}, + {"type": "video"}, + {"type": "text", "text": "describe"}, + ], + ) + + def test_empty_text_yields_media_only_parts(self): + out = attach_media_parts([{"role": "user", "content": ""}], n_images=1) + self.assertEqual(out[0]["content"], [{"type": "image"}]) + + def test_other_message_keys_are_preserved(self): + out = attach_media_parts( + [{"role": "user", "content": "hi", "name": "bob"}], n_images=1 + ) + self.assertEqual(out[0]["name"], "bob") + + def test_no_media_is_a_no_op(self): + self.assertIsNone(attach_media_parts([{"role": "user", "content": "hi"}])) + + def test_no_user_turn_is_a_no_op(self): + self.assertIsNone( + attach_media_parts([{"role": "system", "content": "hi"}], n_images=1) + ) + + def test_content_already_parts_is_a_no_op(self): + self.assertIsNone( + attach_media_parts( + [{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + n_images=1, + ) + ) + + if __name__ == "__main__": unittest.main() diff --git a/backend/python/common/temp_utils.py b/backend/python/common/temp_utils.py new file mode 100644 index 000000000..e67f96ff9 --- /dev/null +++ b/backend/python/common/temp_utils.py @@ -0,0 +1,36 @@ +import base64 +import contextlib +import os +import tempfile + + +@contextlib.contextmanager +def materialize_base64(data, suffix=""): + """Materialize base64 data for a path-only library and always remove it.""" + descriptor, path = tempfile.mkstemp(prefix="localai-media-", suffix=suffix) + try: + with os.fdopen(descriptor, "wb") as output: + descriptor = None + output.write(base64.b64decode(data)) + yield path + finally: + if descriptor is not None: + os.close(descriptor) + try: + os.remove(path) + except OSError: + pass + + +@contextlib.contextmanager +def cleanup_paths(): + """Collect temporary paths and remove them on success or failure.""" + paths = [] + try: + yield paths + finally: + for path in paths: + try: + os.remove(path) + except OSError: + pass diff --git a/backend/python/common/temp_utils_test.py b/backend/python/common/temp_utils_test.py new file mode 100644 index 000000000..eb743064a --- /dev/null +++ b/backend/python/common/temp_utils_test.py @@ -0,0 +1,41 @@ +import os +import tempfile +import unittest +from unittest import mock + +from temp_utils import cleanup_paths, materialize_base64 + + +class MaterializeBase64Test(unittest.TestCase): + def test_removes_materialized_file_after_success(self): + with tempfile.TemporaryDirectory() as directory: + with mock.patch.object(tempfile, "tempdir", directory): + with materialize_base64("aGVsbG8=", suffix=".data") as path: + with open(path, "rb") as materialized: + self.assertEqual(materialized.read(), b"hello") + self.assertFalse(os.path.exists(path)) + + def test_removes_materialized_file_when_consumer_fails(self): + with tempfile.TemporaryDirectory() as directory: + with mock.patch.object(tempfile, "tempdir", directory): + with self.assertRaisesRegex(RuntimeError, "decode failed"): + with materialize_base64("aGVsbG8="): + raise RuntimeError("decode failed") + self.assertEqual(os.listdir(directory), []) + + +class CleanupPathsTest(unittest.TestCase): + def test_removes_every_registered_path_after_failure(self): + with tempfile.TemporaryDirectory() as directory: + paths = [os.path.join(directory, name) for name in ("one.wav", "two.wav")] + with self.assertRaisesRegex(RuntimeError, "merge failed"): + with cleanup_paths() as registered: + for path in paths: + open(path, "wb").close() + registered.append(path) + raise RuntimeError("merge failed") + self.assertEqual(os.listdir(directory), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/python/common/template/requirements-intel.txt b/backend/python/common/template/requirements-intel.txt index cfe9fd3fa..c17f22c30 100644 --- a/backend/python/common/template/requirements-intel.txt +++ b/backend/python/common/template/requirements-intel.txt @@ -1,4 +1,4 @@ --extra-index-url https://download.pytorch.org/whl/xpu -torch==2.13.0+xpu +torch==2.14.0+xpu oneccl_bind_pt==2.8.0+xpu optimum[openvino] \ No newline at end of file diff --git a/backend/python/common/template/requirements.txt b/backend/python/common/template/requirements.txt index 1795a9694..205cdbb75 100644 --- a/backend/python/common/template/requirements.txt +++ b/backend/python/common/template/requirements.txt @@ -1,3 +1,3 @@ -grpcio==1.82.1 +grpcio==1.83.1 protobuf grpcio-tools \ No newline at end of file diff --git a/backend/python/coqui/requirements.txt b/backend/python/coqui/requirements.txt index db72b4b98..305f95f12 100644 --- a/backend/python/coqui/requirements.txt +++ b/backend/python/coqui/requirements.txt @@ -1,4 +1,4 @@ -grpcio==1.83.0 +grpcio==1.83.1 protobuf certifi packaging==26.3 \ No newline at end of file diff --git a/backend/python/diffusers/backend.py b/backend/python/diffusers/backend.py index 539ce5444..312754a34 100755 --- a/backend/python/diffusers/backend.py +++ b/backend/python/diffusers/backend.py @@ -122,6 +122,21 @@ from diffusers.schedulers import ( UniPCMultistepScheduler, ) +def select_device(request_cuda, device_option, cuda_available, xpu, mps_available): + """Pick the pipeline device. An explicit `device:` model option wins; + otherwise CUDA is used whenever torch reports it available (ROCm + builds included) or the model config forces it with `cuda: true`, + keeping the pre-existing XPU/MPS overrides. CPU is the fallback, not + the default.""" + if device_option: + return device_option + device = "cuda" if (request_cuda or cuda_available) else "cpu" + if xpu: + device = "xpu" + if mps_available: + device = "mps" + return device + def is_float(s): """Check if a string can be converted to float.""" try: @@ -627,12 +642,13 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): # modify LoraAdapter to be relative to modelFileBase request.LoraAdapter = os.path.join(request.ModelPath, request.LoraAdapter) - device = "cpu" if not request.CUDA else "cuda" - if XPU: - device = "xpu" - mps_available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available() - if mps_available: - device = "mps" + device = select_device( + request.CUDA, + self.options.pop("device", None), + torch.cuda.is_available(), + XPU, + hasattr(torch.backends, "mps") and torch.backends.mps.is_available(), + ) self.device = device if request.LoraAdapter: # Check if its a local file and not a directory ( we load lora differently for a safetensor file ) @@ -800,12 +816,12 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): image = image.resize((1024, 576)) generator = torch.manual_seed(request.seed) - frames = self.pipe(image, guidance_scale=self.cfg_scale, decode_chunk_size=CHUNK_SIZE, generator=generator).frames[0] + frames = self.pipe(image=image, guidance_scale=self.cfg_scale, decode_chunk_size=CHUNK_SIZE, generator=generator).frames[0] export_to_video(frames, request.dst, fps=FPS) return backend_pb2.Result(message="Media generated successfully", success=True) if self.txt2vid: - video_frames = self.pipe(prompt, guidance_scale=self.cfg_scale, num_inference_steps=steps, num_frames=int(FRAMES)).frames + video_frames = self.pipe(prompt=prompt, guidance_scale=self.cfg_scale, num_inference_steps=steps, num_frames=int(FRAMES)).frames export_to_video(video_frames, request.dst) return backend_pb2.Result(message="Media generated successfully", success=True) @@ -868,7 +884,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): else: # pass the kwargs dictionary to the self.pipe method image = self.pipe( - prompt, + prompt=prompt, guidance_scale=self.cfg_scale, **kwargs ).images[0] diff --git a/backend/python/diffusers/test.py b/backend/python/diffusers/test.py index eff293ee6..f2a07d4e3 100644 --- a/backend/python/diffusers/test.py +++ b/backend/python/diffusers/test.py @@ -7,6 +7,7 @@ import time from unittest.mock import patch, MagicMock # Import dynamic loader for testing (these don't need gRPC) +import backend import diffusers_dynamic_loader as loader from diffusers import DiffusionPipeline, StableDiffusionPipeline @@ -373,3 +374,74 @@ class TestGenerateImageOptionsKwargsMerge(unittest.TestCase): finally: os.unlink(src_file.name) os.unlink(dst_file.name) + + def test_text_to_image_prompt_is_passed_by_keyword(self): + """Test compatibility with pipelines that take image before prompt.""" + import os + import tempfile + + from PIL import Image + + from backend import BackendServicer + + class Flux2CompatiblePipeline: + """Model the FLUX.2 call signature: image is before prompt.""" + + def __call__(self, image=None, prompt=None, **kwargs): + if prompt is None: + raise ValueError("prompt was not passed by keyword") + self.prompt = prompt + self.kwargs = kwargs + return MagicMock(images=[Image.new("RGB", (4, 4))]) + + pipeline = Flux2CompatiblePipeline() + svc = BackendServicer.__new__(BackendServicer) + svc.pipe = pipeline + svc.cfg_scale = 7.5 + svc.controlnet = None + svc.img2vid = False + svc.txt2vid = False + svc.clip_skip = 0 + svc.PipelineType = "Flux2KleinPipeline" + svc.options = {} + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as dst_file: + dst_path = dst_file.name + + try: + request = MagicMock() + request.positive_prompt = "a red apple on a wooden table" + request.negative_prompt = "" + request.step = 4 + request.seed = 0 + request.width = 0 + request.height = 0 + request.src = "" + request.ref_images = [] + request.dst = dst_path + + svc.GenerateImage(request, context=None) + + self.assertEqual(pipeline.prompt, request.positive_prompt) + self.assertEqual(pipeline.kwargs["num_inference_steps"], 4) + finally: + os.unlink(dst_path) + + +class TestDeviceSelection(unittest.TestCase): + """Unit tests for backend.select_device (no GPU required).""" + + def test_autodetect_cuda(self): + self.assertEqual(backend.select_device(False, None, True, False, False), "cuda") + + def test_cpu_fallback(self): + self.assertEqual(backend.select_device(False, None, False, False, False), "cpu") + + def test_forced_cuda(self): + self.assertEqual(backend.select_device(True, None, False, False, False), "cuda") + + def test_device_option_wins(self): + self.assertEqual(backend.select_device(True, "cpu", True, True, True), "cpu") + + def test_mps_overrides(self): + self.assertEqual(backend.select_device(False, None, True, False, True), "mps") diff --git a/backend/python/longcat-video/README.md b/backend/python/longcat-video/README.md index 821de0130..e50a40f51 100644 --- a/backend/python/longcat-video/README.md +++ b/backend/python/longcat-video/README.md @@ -11,7 +11,7 @@ RPC. It supports: systems such as NVIDIA DGX Spark. Install the `longcat-video` or `longcat-video-avatar-1.5` recipe from the -LocalAI Model Gallery. See the [LongCat user guide](../../../docs/content/features/longcat-video.md) +LocalAI Model Gallery. LongCat video backend for Studio and API examples, hardware requirements, and manual configuration. The upstream source is pinned in `Makefile` and patched at build time. The diff --git a/backend/python/longcat-video/backend.py b/backend/python/longcat-video/backend.py index 0b54dd71f..761381efe 100755 --- a/backend/python/longcat-video/backend.py +++ b/backend/python/longcat-video/backend.py @@ -6,6 +6,7 @@ import datetime import gc import math import os +import shutil import signal import subprocess import sys @@ -888,6 +889,13 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): def _release_model(self): self.pipeline = None self.model_kind = None + try: + if hasattr(self, "dist") and self.dist.is_initialized(): + self.dist.destroy_process_group() + finally: + if self._dist_store_dir is not None: + shutil.rmtree(self._dist_store_dir, ignore_errors=True) + self._dist_store_dir = None gc.collect() if hasattr(self, "torch") and self.torch.cuda.is_available(): self.torch.cuda.empty_cache() diff --git a/backend/python/qwen-asr/backend.py b/backend/python/qwen-asr/backend.py index ea5e877f2..a559ff5e6 100644 --- a/backend/python/qwen-asr/backend.py +++ b/backend/python/qwen-asr/backend.py @@ -18,6 +18,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common')) from grpc_auth import get_auth_interceptors from model_utils import resolve_model_reference +from device_utils import device_map_for, select_device @@ -95,13 +96,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): return backend_pb2.Reply(message=bytes("OK", 'utf-8')) def LoadModel(self, request, context): - if torch.cuda.is_available(): - device = "cuda" - else: - device = "cpu" - mps_available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available() - if mps_available: - device = "mps" + device = select_device(torch) if not torch.cuda.is_available() and request.CUDA: return backend_pb2.Result(success=False, message="CUDA is not available") @@ -123,7 +118,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): model_path, local_only = resolve_model_reference( request, "Qwen/Qwen3-ASR-1.7B" ) - default_dtype = torch.bfloat16 if self.device == "cuda" else torch.float32 + default_dtype = torch.bfloat16 if self.device in ("cuda", "xpu") else torch.float32 load_dtype = default_dtype if "torch_dtype" in self.options: d = str(self.options["torch_dtype"]).lower() @@ -145,12 +140,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): if attn_implementation is not None and isinstance(attn_implementation, str): attn_implementation = attn_implementation.strip() or None - if self.device == "mps": - device_map = None - elif self.device == "cuda": - device_map = "cuda:0" - else: - device_map = "cpu" + device_map = device_map_for(self.device) load_kwargs = dict( dtype=load_dtype, @@ -423,4 +413,4 @@ if __name__ == "__main__": parser = argparse.ArgumentParser(description="Run the gRPC server.") parser.add_argument("--addr", default="localhost:50051", help="The address to bind the server to.") args = parser.parse_args() - serve(args.addr) \ No newline at end of file + serve(args.addr) diff --git a/backend/python/qwen-asr/device_utils.py b/backend/python/qwen-asr/device_utils.py new file mode 100644 index 000000000..0e1cb8005 --- /dev/null +++ b/backend/python/qwen-asr/device_utils.py @@ -0,0 +1,18 @@ +def select_device(torch_module): + mps = getattr(getattr(torch_module, "backends", None), "mps", None) + if mps is not None and mps.is_available(): + return "mps" + if torch_module.cuda.is_available(): + return "cuda" + xpu = getattr(torch_module, "xpu", None) + if xpu is not None and xpu.is_available(): + return "xpu" + return "cpu" + + +def device_map_for(device): + if device == "mps": + return None + if device in ("cuda", "xpu"): + return f"{device}:0" + return "cpu" diff --git a/backend/python/qwen-asr/device_utils_test.py b/backend/python/qwen-asr/device_utils_test.py new file mode 100644 index 000000000..fde07f32b --- /dev/null +++ b/backend/python/qwen-asr/device_utils_test.py @@ -0,0 +1,58 @@ +import unittest + +from device_utils import device_map_for, select_device + + +class Availability: + def __init__(self, available): + self._available = available + + def is_available(self): + return self._available + + +class TorchStub: + def __init__(self, *, cuda=False, mps=False, xpu=False): + self.cuda = Availability(cuda) + self.backends = type("Backends", (), {"mps": Availability(mps)})() + self.xpu = Availability(xpu) + + +class SelectDeviceTest(unittest.TestCase): + def test_preserves_cuda_selection(self): + torch_module = TorchStub(cuda=True) + + self.assertEqual(select_device(torch_module), "cuda") + + def test_preserves_mps_selection(self): + torch_module = TorchStub(mps=True) + + self.assertEqual(select_device(torch_module), "mps") + + def test_selects_xpu_when_intel_gpu_is_available(self): + torch_module = TorchStub(xpu=True) + + self.assertEqual(select_device(torch_module), "xpu") + + def test_falls_back_to_cpu(self): + torch_module = TorchStub() + + self.assertEqual(select_device(torch_module), "cpu") + + +class DeviceMapTest(unittest.TestCase): + def test_preserves_cuda_model_placement(self): + self.assertEqual(device_map_for("cuda"), "cuda:0") + + def test_preserves_mps_model_placement(self): + self.assertIsNone(device_map_for("mps")) + + def test_places_the_model_on_the_first_xpu(self): + self.assertEqual(device_map_for("xpu"), "xpu:0") + + def test_preserves_cpu_model_placement(self): + self.assertEqual(device_map_for("cpu"), "cpu") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/python/rerankers/requirements.txt b/backend/python/rerankers/requirements.txt index 49943543f..5f1e5f0ae 100644 --- a/backend/python/rerankers/requirements.txt +++ b/backend/python/rerankers/requirements.txt @@ -1,3 +1,3 @@ -grpcio==1.82.1 +grpcio==1.83.1 protobuf certifi \ No newline at end of file diff --git a/backend/python/sglang/backend.py b/backend/python/sglang/backend.py index 76d99a726..9c6848dc9 100644 --- a/backend/python/sglang/backend.py +++ b/backend/python/sglang/backend.py @@ -40,6 +40,7 @@ import grpc sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common')) +from python_utils import attach_media_parts from grpc_auth import get_auth_interceptors from model_utils import resolve_model_reference @@ -90,6 +91,14 @@ except Exception: _ONE_DAY_IN_SECONDS = 60 * 60 * 24 + +# proto3 has no field presence, so an explicit 0 is indistinguishable from +# "unset" and the zero-filter below would drop it. These two fields have a +# meaningful zero a caller can actually intend: temperature 0 is greedy +# decoding, and 0 is a valid seed. Silently substituting a default for either +# turns a reproducible request into a random one. +_EXPLICIT_ZERO_FIELDS = ("Temperature", "Seed") + MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '1')) @@ -323,7 +332,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): if not hasattr(request, proto_field): continue value = getattr(request, proto_field) - if proto_field != "Temperature" and value in (None, 0, 0.0, [], False, ""): + if proto_field not in _EXPLICIT_ZERO_FIELDS and value in (None, 0, 0.0, [], False, ""): continue # repeated fields come back as RepeatedScalarContainer — convert if hasattr(value, "__iter__") and not isinstance(value, (str, bytes)): @@ -367,6 +376,24 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): if _thinking in ("true", "false"): template_kwargs["enable_thinking"] = (_thinking == "true") + # sglang locates the attached images/videos by scanning the rendered + # prompt for the model's own media token, so the template has to be + # given content *parts* - string content renders a prompt with no + # placeholder and the media are dropped without a word (#11621). + media_dicts = attach_media_parts( + messages_dicts, len(request.Images), len(request.Videos) + ) + if media_dicts is not None: + try: + return self.tokenizer.apply_chat_template(media_dicts, **template_kwargs) + except Exception as e: + # A text-only template cannot iterate content parts; fall + # through to the text-only prompt instead of failing. + print( + f"chat template rejected multimodal content parts: {e!r}", + file=sys.stderr, + ) + try: return self.tokenizer.apply_chat_template(messages_dicts, **template_kwargs) except TypeError: @@ -374,10 +401,67 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): messages_dicts, tokenize=False, add_generation_prompt=True, ) - def _make_parsers(self, request): + def _new_reasoning_parser(self, stream_reasoning: bool, prompt: str = "", + grammar_constrained: bool = False): + """Build a ReasoningParser for one request, or None. + + Reasoning templates come in two flavours. Some let the model emit the + opening tag, others put it into the *prompt* — Qwen3's template appends + ```` when thinking is on, so the completion starts straight in + the reasoning block and only the closing ```` ever shows up. + sglang's detector keys off the opening tag, so in that second case it + classifies the whole completion as normal content and + ``reasoning_content`` stays empty. + + sglang's own OpenAI server covers this with + ``template_manager.force_reasoning``; this backend has no template + manager, so it derives the same signal from the rendered prompt. + ``force_reasoning`` is only passed when we mean True, leaving detector + defaults (e.g. DeepSeek-R1's built-in True) untouched. + + ``grammar_constrained`` suppresses the prefill heuristic. A structured + decoding constraint applies from the first token, so the model cannot + emit the closing tag even though the template opened the block: the + whole completion is schema output and belongs in ``content``. Forcing + there files the answer as reasoning and leaves content empty. sglang's + own server keeps the two apart for the same reason — its grammar + backend owns the reasoning prefix when a reasoning parser is set. + """ + if grammar_constrained: + prompt = "" + + if not (HAS_REASONING_PARSERS and self.reasoning_parser_name): + return None + + kwargs = { + "model_type": self.reasoning_parser_name, + "stream_reasoning": stream_reasoning, + } + try: + parser = ReasoningParser(**kwargs) + except Exception as e: + print(f"ReasoningParser init failed: {e!r}", file=sys.stderr) + return None + + start = getattr(getattr(parser, "detector", None), "think_start_token", None) + if start and prompt and prompt.rstrip().endswith(start): + try: + parser = ReasoningParser(force_reasoning=True, **kwargs) + except TypeError: + # sglang without the force_reasoning kwarg: keep the default + # parser rather than failing the request. + pass + except Exception as e: + print( + f"ReasoningParser(force_reasoning=True) failed: {e!r}", + file=sys.stderr, + ) + + return parser + + def _make_parsers(self, request, prompt: str = ""): """Construct fresh per-request parser instances (stateful).""" tool_parser = None - reasoning_parser = None if HAS_TOOL_PARSERS and self.tool_parser_name and request.Tools: try: @@ -389,14 +473,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): except Exception as e: print(f"FunctionCallParser init failed: {e!r}", file=sys.stderr) - if HAS_REASONING_PARSERS and self.reasoning_parser_name: - try: - reasoning_parser = ReasoningParser( - model_type=self.reasoning_parser_name, - stream_reasoning=True, - ) - except Exception as e: - print(f"ReasoningParser init failed: {e!r}", file=sys.stderr) + reasoning_parser = self._new_reasoning_parser( + True, prompt, bool(getattr(request, "Grammar", "")), + ) return tool_parser, reasoning_parser @@ -404,7 +483,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): sampling_params = self._build_sampling_params(request) prompt = self._build_prompt(request) - tool_parser, reasoning_parser = self._make_parsers(request) + tool_parser, reasoning_parser = self._make_parsers(request, prompt) image_data = list(request.Images) if request.Images else None video_data = list(request.Videos) if request.Videos else None @@ -500,15 +579,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): final_tool_calls: List[backend_pb2.ToolCallDelta] = [] if not streaming: - final_reasoning_parser = None - if HAS_REASONING_PARSERS and self.reasoning_parser_name: - try: - final_reasoning_parser = ReasoningParser( - model_type=self.reasoning_parser_name, - stream_reasoning=False, - ) - except Exception: - final_reasoning_parser = None + final_reasoning_parser = self._new_reasoning_parser( + False, prompt, bool(getattr(request, "Grammar", "")), + ) if final_reasoning_parser is not None: try: diff --git a/backend/python/sglang/test.py b/backend/python/sglang/test.py index c50ed577f..4cd559b09 100644 --- a/backend/python/sglang/test.py +++ b/backend/python/sglang/test.py @@ -128,11 +128,66 @@ class TestSglangHelpers(unittest.TestCase): self.assertNotIn("enable_thinking", kwargs_for({})) self.assertIs(kwargs_for({"enable_thinking": "FALSE"})["enable_thinking"], False) - def test_explicit_zero_temperature_is_preserved(self): - """Temperature=0 is valid greedy decoding, not an unset value.""" + def test_reasoning_parser_forced_when_template_prefills_think_tag(self): + """Qwen3's template puts ```` in the prompt, so the completion + never contains it. Without force_reasoning the detector treats the whole + completion as normal text and reasoning_content stays empty.""" + servicer = self._servicer() + servicer.reasoning_parser_name = "qwen3" + + # What the model actually emits when the prompt ends in "". + completion = "adding two and two4" + + forced = servicer._new_reasoning_parser(False, prompt="user: hi\n\n") + reasoning, content = forced.parse_non_stream(completion) + self.assertEqual(reasoning, "adding two and two") + self.assertEqual(content, "4") + + # No prefilled tag in the prompt: detector default, unchanged behaviour. + unforced = servicer._new_reasoning_parser(False, prompt="user: hi\n") + reasoning, content = unforced.parse_non_stream(completion) + self.assertFalse(reasoning) + self.assertEqual(content, completion) + + def test_reasoning_parser_not_forced_when_thinking_is_off(self): + """Thinking off means no ```` in the prompt either, so the answer + must not be swallowed into reasoning_content.""" + servicer = self._servicer() + servicer.reasoning_parser_name = "qwen3" + + parser = servicer._new_reasoning_parser(False, prompt="user: primes?\n") + reasoning, content = parser.parse_non_stream("2,3,5,7,11") + self.assertFalse(reasoning) + self.assertEqual(content, "2,3,5,7,11") + + def test_grammar_constrained_output_is_not_forced_into_reasoning(self): + """Structured decoding applies from the first token, so the model cannot + emit the closing tag even though the template opened the block. The whole + completion is schema output and must stay in content.""" + servicer = self._servicer() + servicer.reasoning_parser_name = "qwen3" + + schema_out = '{"findings": [{"line": 42, "issue": "off-by-one"}]}' + parser = servicer._new_reasoning_parser( + False, prompt="audit this\n\n", grammar_constrained=True, + ) + reasoning, content = parser.parse_non_stream(schema_out) + self.assertFalse(reasoning) + self.assertEqual(content, schema_out) + + def test_reasoning_parser_absent_without_configured_parser(self): + servicer = self._servicer() + servicer.reasoning_parser_name = None + self.assertIsNone(servicer._new_reasoning_parser(False, prompt="")) + + def test_explicit_zero_temperature_and_seed_are_preserved(self): + """Temperature=0 is greedy decoding and 0 is a valid seed — neither is + an unset value. A dropped seed turns a reproducible request random.""" from types import SimpleNamespace servicer = self._servicer() + import sys as _sys + _SEED_KEY_FOR_TEST = _sys.modules["backend"]._SEED_KEY request = SimpleNamespace( Temperature=0, N=0, @@ -154,8 +209,12 @@ class TestSglangHelpers(unittest.TestCase): params = servicer._build_sampling_params(request) self.assertEqual(params["temperature"], 0) - # Other protobuf-default scalar fields must remain filtered. + self.assertEqual(params[_SEED_KEY_FOR_TEST], 0) + # Other protobuf-default scalar fields must remain filtered. top_k=0 in + # particular is not a value sglang accepts (-1 disables it), so it must + # keep falling through to the engine default. self.assertNotIn("top_p", params) + self.assertNotIn("top_k", params) if __name__ == "__main__": diff --git a/backend/python/vllm-omni/backend.py b/backend/python/vllm-omni/backend.py index cc426e144..a23a1e535 100644 --- a/backend/python/vllm-omni/backend.py +++ b/backend/python/vllm-omni/backend.py @@ -19,7 +19,6 @@ import base64 import io import json import gc -import tempfile from PIL import Image import torch @@ -34,6 +33,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common')) from grpc_auth import get_auth_interceptors from model_utils import resolve_model_reference +from temp_utils import materialize_base64 from vllm_utils import parse_options, messages_to_dicts, setup_parsers @@ -118,13 +118,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): return video_to_ndarrays(video_path, num_frames=16) # Try base64 decode try: - timestamp = str(int(time.time() * 1000)) - p = os.path.join(tempfile.gettempdir(), f"vl-{timestamp}.data") - with open(p, "wb") as f: - f.write(base64.b64decode(video_path)) - video = VideoAsset(name=p).np_ndarrays - os.remove(p) - return video + with materialize_base64(video_path, suffix=".data") as path: + return VideoAsset(name=path).np_ndarrays except: return None @@ -136,15 +131,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): return (audio_signal.astype(np.float32), sr) # Try base64 decode try: - audio_data = base64.b64decode(audio_path) - # Save to temp file and load - timestamp = str(int(time.time() * 1000)) - p = os.path.join(tempfile.gettempdir(), f"audio-{timestamp}.wav") - with open(p, "wb") as f: - f.write(audio_data) - audio_signal, sr = librosa.load(p, sr=16000) - os.remove(p) - return (audio_signal.astype(np.float32), sr) + with materialize_base64(audio_path, suffix=".wav") as path: + audio_signal, sr = librosa.load(path, sr=16000) + return (audio_signal.astype(np.float32), sr) except: return None diff --git a/backend/python/vllm/backend.py b/backend/python/vllm/backend.py index 7235c8e07..6dabbf4bf 100644 --- a/backend/python/vllm/backend.py +++ b/backend/python/vllm/backend.py @@ -10,7 +10,6 @@ import os import json import time import gc -import tempfile from typing import List from PIL import Image @@ -20,8 +19,10 @@ import backend_pb2_grpc import grpc sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common')) +from python_utils import attach_media_parts from grpc_auth import get_auth_interceptors from model_utils import resolve_model_reference +from temp_utils import materialize_base64 from vllm_utils import apply_options_to_engine_args, normalize_option_key from vllm.engine.arg_utils import AsyncEngineArgs @@ -60,6 +61,12 @@ except ImportError: _ONE_DAY_IN_SECONDS = 60 * 60 * 24 +# proto3 has no field presence, so an explicit 0 is indistinguishable from +# "unset". These two fields have a meaningful zero a caller can intend: +# temperature 0 is greedy decoding, and 0 is a valid seed. +_EXPLICIT_ZERO_FIELDS = ("Temperature", "Seed") + + # If MAX_WORKERS are specified in the environment use it, otherwise default to 1 MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '1')) @@ -553,11 +560,80 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): for request_field, param_field in request_to_sampling_params.items(): if hasattr(request, request_field): value = getattr(request, request_field) - if request_field == "Temperature" or value not in (None, 0, [], False): + # See _EXPLICIT_ZERO_FIELDS: temperature 0 is greedy decoding + # and 0 is a valid seed, so neither may be filtered out. + if request_field in _EXPLICIT_ZERO_FIELDS or value not in (None, 0, [], False): setattr(sampling_params, param_field, value) return sampling_params + def _new_reasoning_parser(self, chat_template_kwargs): + """Build the reasoning parser, telling it whether thinking is on. + + vLLM's newer parser engines decide their *initial state* from + ``chat_template_kwargs``: ``Qwen3Parser`` reads + ``chat_template_kwargs["enable_thinking"]`` and defaults to ``True``, + starting in the REASONING state. Constructed without it, a completion + produced with thinking disabled is classified as reasoning end to end, + and the answer is reported in both ``reasoning_content`` and + ``content``. + + vLLM's own OpenAI server forwards the request's chat template kwargs + here; this backend renders the template itself, so it forwards the + same dict. Older parsers do not accept the argument — fall back to the + plain constructor for those. + """ + try: + return self.reasoning_parser_cls( + self.tokenizer, chat_template_kwargs=chat_template_kwargs or {}, + ) + except TypeError: + return self.reasoning_parser_cls(self.tokenizer) + + @staticmethod + def _split_reasoning(rp, generated_text, prompt, reasoning, content): + """Decide what the reasoning parser's output actually means. + + Covers the *older* parser shape, which has no initial state to set: + ``BaseThinkingReasoningParser.extract_reasoning`` documents its own + fallback — "For models that may not generate start token, assume the + reasoning content is always at the start." When no end token is + present it returns *everything* as reasoning and ``None`` as content, + which is right for a truncated reasoning run and wrong for a + completion that never contained reasoning at all. + + Taking ``None`` content to mean "keep the raw text" then duplicates + the answer into both fields. + + The prompt says which case it is. A template with thinking on leaves + the reasoning block open (the prompt ends with the start token); with + thinking off it closes the block in the prompt, so the completion is + plain content. Parsers that expose no token pair (the engine-based + adapters, which take the ``chat_template_kwargs`` route above) keep + the parser's verdict unchanged. + """ + start = getattr(rp, "start_token", None) + end = getattr(rp, "end_token", None) + + if end and end in generated_text: + # The parser split on the end token. Empty content here means the + # model stopped right after it, not that parsing failed. + return reasoning or "", content or "" + + if not start: + # Unknown token layout — keep the previous behaviour rather than + # guess. + return reasoning or "", content if content is not None else generated_text + + if not (start in generated_text or (prompt or "").rstrip().endswith(start)): + # No end token and the block was never open: the "reasoning starts + # at the beginning" fallback does not apply to this completion. + return "", generated_text + + # Block was open and the end token never arrived — reasoning ran out of + # budget. It is all reasoning, and there is no answer to report. + return reasoning or "", content or "" + async def _predict(self, request, context, streaming=False): # Build the sampling parameters sampling_params = self._build_sampling_params(request) @@ -572,6 +648,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): # Extract image paths and process images prompt = request.Prompt + # Kept in scope: the reasoning parser needs to know which chat + # template kwargs produced this prompt. + template_kwargs = {} image_paths = request.Images image_data = [self.load_image(img_path) for img_path in image_paths] @@ -582,7 +661,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): # If tokenizer template is enabled and messages are provided instead of prompt, apply the tokenizer template if not request.Prompt and request.UseTokenizerTemplate and request.Messages: messages_dicts = self._messages_to_dicts(request.Messages) - template_kwargs = {"tokenize": False, "add_generation_prompt": True} + template_kwargs.update({"tokenize": False, "add_generation_prompt": True}) # Pass tools for tool calling if request.Tools: @@ -595,13 +674,33 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): if _thinking in ("true", "false"): template_kwargs["enable_thinking"] = (_thinking == "true") - try: - prompt = self.tokenizer.apply_chat_template(messages_dicts, **template_kwargs) - except TypeError: - # Some tokenizers don't support tools/enable_thinking kwargs — retry without them - prompt = self.tokenizer.apply_chat_template( - messages_dicts, tokenize=False, add_generation_prompt=True - ) + # vLLM substitutes multi_modal_data into the model's own media + # token, so the template has to be given content *parts* - string + # content renders a prompt with no placeholder and the media are + # dropped without a word (#11621). + prompt = None + media_dicts = attach_media_parts( + messages_dicts, len(image_data), len(video_data) + ) + if media_dicts is not None: + try: + prompt = self.tokenizer.apply_chat_template(media_dicts, **template_kwargs) + except Exception as e: + # A text-only template cannot iterate content parts; fall + # through to the text-only prompt instead of failing. + print( + f"chat template rejected multimodal content parts: {e!r}", + file=sys.stderr, + ) + + if prompt is None: + try: + prompt = self.tokenizer.apply_chat_template(messages_dicts, **template_kwargs) + except TypeError: + # Some tokenizers don't support tools/enable_thinking kwargs — retry without them + prompt = self.tokenizer.apply_chat_template( + messages_dicts, tokenize=False, add_generation_prompt=True + ) # Generate text using the LLM engine request_id = random_uuid() @@ -757,10 +856,11 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): if self.reasoning_parser_cls: try: - rp = self.reasoning_parser_cls(self.tokenizer) + rp = self._new_reasoning_parser(template_kwargs) r, c = rp.extract_reasoning(generated_text, request=None) - reasoning_content = r or "" - content = c if c is not None else generated_text + reasoning_content, content = self._split_reasoning( + rp, generated_text, prompt, r, c, + ) except Exception as e: print(f"Reasoning parser error: {e}", file=sys.stderr) @@ -905,13 +1005,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): Video: The loaded video. """ try: - timestamp = str(int(time.time() * 1000)) # Generate timestamp - p = os.path.join(tempfile.gettempdir(), f"vl-{timestamp}.data") - with open(p, "wb") as f: - f.write(base64.b64decode(video_path)) - video = VideoAsset(name=p).np_ndarrays - os.remove(p) - return video + with materialize_base64(video_path, suffix=".data") as path: + return VideoAsset(name=path).np_ndarrays except Exception as e: print(f"Error loading video {video_path}: {e}", file=sys.stderr) return None diff --git a/backend/python/vllm/requirements.txt b/backend/python/vllm/requirements.txt index 889f21d0d..ba7147632 100644 --- a/backend/python/vllm/requirements.txt +++ b/backend/python/vllm/requirements.txt @@ -1,4 +1,4 @@ -grpcio==1.83.0 +grpcio==1.83.1 protobuf certifi setuptools diff --git a/backend/python/vllm/test.py b/backend/python/vllm/test.py index a0679d4ff..27a846f09 100644 --- a/backend/python/vllm/test.py +++ b/backend/python/vllm/test.py @@ -121,16 +121,18 @@ class TestBackendServicer(unittest.TestCase): finally: self.tearDown() - def test_explicit_zero_temperature_is_preserved(self): - """Temperature=0 is valid greedy decoding, not an unset value.""" + def test_explicit_zero_temperature_and_seed_are_preserved(self): + """Temperature=0 is greedy decoding and 0 is a valid seed — neither is + an unset value. A dropped seed turns a reproducible request random.""" import sys, os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from backend import BackendServicer servicer = BackendServicer() - request = backend_pb2.PredictOptions(Prompt="hello", Temperature=0) + request = backend_pb2.PredictOptions(Prompt="hello", Temperature=0, Seed=0) sampling_params = servicer._build_sampling_params(request) self.assertEqual(sampling_params.temperature, 0) + self.assertEqual(sampling_params.seed, 0) # Other protobuf-default scalar fields must remain filtered. self.assertEqual(sampling_params.top_p, 0.9) @@ -549,3 +551,116 @@ class TestStreamingToolParser(unittest.TestCase): intermediate, ["Hello ", "world", "!"], f"plain streaming changed; got {intermediate!r}", ) + + +class TestReasoningSplit(unittest.TestCase): + """Server-less tests for BackendServicer._split_reasoning. + + vLLM's BaseThinkingReasoningParser returns the whole completion as + reasoning and None as content whenever the end token is missing. Taken + literally that duplicates a thinking-disabled answer into both fields. + """ + + class _Parser: + start_token = "" + end_token = "" + + def _split(self, generated, prompt, reasoning, content): + import sys, os + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from backend import BackendServicer + return BackendServicer._split_reasoning( + self._Parser(), generated, prompt, reasoning, content, + ) + + def test_thinking_off_is_not_duplicated_into_reasoning(self): + """No tags anywhere: the answer is content, and only content.""" + r, c = self._split( + "391", "user: 17*23?\n\n\n\n\n", + reasoning="391", content=None, + ) + self.assertEqual(r, "") + self.assertEqual(c, "391") + + def test_prefilled_start_tag_keeps_truncated_reasoning(self): + """Prompt left the block open and the end token never arrived + (budget exhausted): that really is all reasoning.""" + r, c = self._split( + "thinking and thinking", "user: hi\n\n", + reasoning="thinking and thinking", content=None, + ) + self.assertEqual(r, "thinking and thinking") + self.assertEqual(c, "") + + def test_end_token_present_keeps_parser_split(self): + r, c = self._split( + "adding two and two4", "user: hi\n\n", + reasoning="adding two and two", content="4", + ) + self.assertEqual(r, "adding two and two") + self.assertEqual(c, "4") + + def test_stop_right_after_end_token_yields_empty_content(self): + """Content must not fall back to the raw text — that would put the + reasoning into the answer.""" + r, c = self._split( + "reasoned", "user: hi\n\n", + reasoning="reasoned", content=None, + ) + self.assertEqual(r, "reasoned") + self.assertEqual(c, "") + + def test_unknown_token_layout_keeps_previous_behaviour(self): + class _Bare: + pass + import sys, os + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from backend import BackendServicer + r, c = BackendServicer._split_reasoning( + _Bare(), "raw", "prompt", "raw", None, + ) + self.assertEqual(r, "raw") + self.assertEqual(c, "raw") + + +class TestReasoningParserConstruction(unittest.TestCase): + """The parser must learn whether thinking was on for this request. + + vLLM's engine-based parsers (Qwen3Parser and friends) read + chat_template_kwargs["enable_thinking"] and default to True, so a parser + built without it treats a thinking-disabled completion as pure reasoning. + """ + + def _servicer(self): + import sys, os + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from backend import BackendServicer + s = BackendServicer() + s.tokenizer = object() + return s + + def test_chat_template_kwargs_are_forwarded(self): + seen = {} + + class _Parser: + def __init__(self, tokenizer, **kwargs): + seen.update(kwargs) + + s = self._servicer() + s.reasoning_parser_cls = _Parser + s._new_reasoning_parser({"enable_thinking": False}) + self.assertEqual( + seen.get("chat_template_kwargs"), {"enable_thinking": False}, + ) + + def test_parser_without_the_kwarg_still_builds(self): + """Older parsers take only the tokenizer — must not break them.""" + class _Old: + def __init__(self, tokenizer): + self.tokenizer = tokenizer + + s = self._servicer() + s.reasoning_parser_cls = _Old + self.assertIsInstance( + s._new_reasoning_parser({"enable_thinking": False}), _Old, + ) diff --git a/backend/python/whisperx/backend.py b/backend/python/whisperx/backend.py index 7318e10b7..dc6202286 100644 --- a/backend/python/whisperx/backend.py +++ b/backend/python/whisperx/backend.py @@ -16,6 +16,7 @@ import grpc sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common')) from grpc_auth import get_auth_interceptors +from transcript_utils import require_diarization_token, seconds_to_nanoseconds @@ -81,6 +82,11 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): import whisperx from whisperx.diarize import DiarizationPipeline + try: + require_diarization_token(request.diarize, self.hf_token) + except ValueError as err: + context.abort(grpc.StatusCode.FAILED_PRECONDITION, str(err)) + resultSegments = [] text = "" try: @@ -117,8 +123,8 @@ class BackendServicer(backend_pb2_grpc.BackendServicer): # Build result segments for idx, seg in enumerate(transcript["segments"]): seg_text = seg.get("text", "") - start = int(seg.get("start", 0)) - end = int(seg.get("end", 0)) + start = seconds_to_nanoseconds(seg.get("start", 0)) + end = seconds_to_nanoseconds(seg.get("end", 0)) speaker = seg.get("speaker", "") resultSegments.append(backend_pb2.TranscriptSegment( diff --git a/backend/python/whisperx/test_transcript_utils.py b/backend/python/whisperx/test_transcript_utils.py new file mode 100644 index 000000000..debe2ea6e --- /dev/null +++ b/backend/python/whisperx/test_transcript_utils.py @@ -0,0 +1,25 @@ +import unittest + +import transcript_utils + + +class TestTranscriptUtils(unittest.TestCase): + def test_diarization_requires_hugging_face_token(self): + with self.assertRaisesRegex( + ValueError, + "HF_TOKEN is required for WhisperX diarization", + ): + transcript_utils.require_diarization_token(True, None) + + def test_diarization_does_not_require_token_when_disabled(self): + transcript_utils.require_diarization_token(False, None) + + def test_seconds_are_serialized_as_nanoseconds(self): + self.assertEqual( + transcript_utils.seconds_to_nanoseconds(3.25), + 3_250_000_000, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/python/whisperx/transcript_utils.py b/backend/python/whisperx/transcript_utils.py new file mode 100644 index 000000000..a8ac57510 --- /dev/null +++ b/backend/python/whisperx/transcript_utils.py @@ -0,0 +1,12 @@ +"""Helpers for WhisperX transcript responses.""" + + +def require_diarization_token(diarize, token): + """Reject diarization when WhisperX cannot load its gated pipeline.""" + if diarize and not token: + raise ValueError("HF_TOKEN is required for WhisperX diarization") + + +def seconds_to_nanoseconds(seconds): + """Convert WhisperX timestamps to the duration unit used by LocalAI.""" + return int(seconds * 1_000_000_000) diff --git a/core/gallery/exl3_gallery_test.go b/core/gallery/exl3_gallery_test.go new file mode 100644 index 000000000..5c55be57e --- /dev/null +++ b/core/gallery/exl3_gallery_test.go @@ -0,0 +1,101 @@ +package gallery_test + +import ( + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/gallery" + "gopkg.in/yaml.v3" +) + +func remarshal(value any, target any) error { + data, err := yaml.Marshal(value) + if err != nil { + return err + } + return yaml.Unmarshal(data, target) +} + +var _ = Describe("EXL3 gallery entries", func() { + It("pins the four full repositories and configures the Qwen DFlash companion", func() { + entries, err := gallery.ReadConfigFile[[]gallery.GalleryModel](filepath.Join("..", "..", "gallery", "index.yaml")) + Expect(err).ToNot(HaveOccurred()) + + byName := make(map[string]gallery.GalleryModel, len(*entries)) + for _, entry := range *entries { + byName[entry.Name] = entry + } + + expected := map[string]struct { + repo string + revision string + }{ + "qwen3.8-27b-exl3-vllm-cpp": { + repo: "Mia-AiLab/Qwen3.8-27B-EXL3-3.5bpw", revision: "19441ac874c4018295da848e250f23511361cda4", + }, + "qwen3.8-27b-dflash2-exl3-vllm-cpp": { + repo: "Mia-AiLab/Qwen3.8-27B-EXL3-3.5bpw", revision: "19441ac874c4018295da848e250f23511361cda4", + }, + "deepseek-v4-flash-spark-exl3-vllm-cpp": { + repo: "0xSero/deepseek-v4-flash-0731-spark", revision: "ce5ff0f1efb2e184aafc759d281bfae47d3a359c", + }, + "deepseek-v4-flash-exl3-3bpw-vllm-cpp": { + repo: "0xSero/DeepSeek-V4-Flash-0731-EXL3-3.0bpw", revision: "e0bf84ac76a5100e8790c22ad10b70b1e2d06d71", + }, + } + + for name, want := range expected { + entry, found := byName[name] + Expect(found).To(BeTrue(), "missing gallery entry %q", name) + Expect(entry.Tags).To(ContainElements("vllm-cpp", "exl3", "gpu", "cuda"), name) + Expect(entry.Overrides).To(HaveKeyWithValue("backend", "vllm-cpp"), name) + cfg := config.ModelConfig{} + Expect(remarshal(entry.Overrides, &cfg)).To(Succeed(), name) + Expect(cfg.Artifacts).ToNot(BeEmpty(), name) + Expect(cfg.Artifacts[0].Source.Repo).To(Equal(want.repo), name) + Expect(cfg.Artifacts[0].Source.Revision).To(Equal(want.revision), name) + } + + plain := byName["qwen3.8-27b-exl3-vllm-cpp"] + Expect(plain.Tags).ToNot(ContainElement("dflash")) + dflashTags := 0 + for name := range expected { + if contains(byName[name].Tags, "dflash") { + dflashTags++ + } + } + Expect(dflashTags).To(Equal(1)) + + dflash := byName["qwen3.8-27b-dflash2-exl3-vllm-cpp"] + Expect(dflash.Tags).To(ContainElement("dflash")) + Expect(dflash.Variants).To(ConsistOf(gallery.Variant{Model: "qwen3.8-27b-exl3-vllm-cpp"})) + cfg := config.ModelConfig{} + Expect(remarshal(dflash.Overrides, &cfg)).To(Succeed()) + Expect(cfg.ContextSize).To(HaveValue(Equal(8192))) + Expect(cfg.EngineArgs).To(HaveKeyWithValue("num_blocks", 2048)) + Expect(cfg.EngineArgs).To(HaveKeyWithValue("max_num_seqs", 8)) + Expect(cfg.EngineArgs).To(HaveKeyWithValue("max_num_batched_tokens", 16384)) + Expect(cfg.EngineArgs).To(HaveKeyWithValue("enable_prefix_caching", false)) + spec, ok := cfg.EngineArgs["speculative_config"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(spec).To(HaveKeyWithValue("method", "dflash")) + Expect(spec).To(HaveKeyWithValue("num_speculative_tokens", 7)) + Expect(cfg.Artifacts).To(HaveLen(2)) + Expect(cfg.Artifacts[1].Name).To(Equal("draft_model")) + Expect(cfg.Artifacts[1].Target).To(Equal("companion")) + Expect(cfg.Artifacts[1].Source.Repo).To(Equal("Mia-AiLab/Qwen3.8-27B-DFlash2-EXL3-5.0bpw")) + Expect(cfg.Artifacts[1].Source.Revision).To(Equal("4f0436269bca761b071f05319e8e04a87cc633f9")) + }) +}) + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/core/http/endpoints/localai/face_register.go b/core/http/endpoints/localai/face_register.go index fbeb29e0c..9cd40b456 100644 --- a/core/http/endpoints/localai/face_register.go +++ b/core/http/endpoints/localai/face_register.go @@ -1,6 +1,7 @@ package localai import ( + "errors" "net/http" "github.com/labstack/echo/v4" @@ -33,22 +34,31 @@ func FaceRegisterEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, a return echo.NewHTTPError(http.StatusBadRequest, "name is required") } - img, err := decodeImageInput(input.Img) - if err != nil { - return err + if (input.Img == "") == (len(input.Embedding) == 0) { + return echo.NewHTTPError(http.StatusBadRequest, "provide exactly one of img or embedding") } - - xlog.Debug("FaceRegister", "model", cfg.Name, "name", input.Name) - embedding, err := backend.FaceEmbed(c.Request().Context(), img, ml, appConfig, *cfg) - if err != nil { - return mapBackendError(err) + embedding := input.Embedding + if len(embedding) == 0 { + img, err := decodeImageInput(input.Img) + if err != nil { + return err + } + xlog.Debug("FaceRegister", "model", cfg.Name, "name", input.Name) + embedding, err = backend.FaceEmbed(c.Request().Context(), img, ml, appConfig, *cfg) + if err != nil { + return mapBackendError(err) + } } stored, err := registry.Register(c.Request().Context(), embedding, facerecognition.Metadata{ - Name: input.Name, - Labels: input.Labels, + Name: input.Name, + RegisteredAt: input.RegisteredAt, + Labels: input.Labels, }) if err != nil { + if errors.Is(err, facerecognition.ErrInvalidEmbedding) || errors.Is(err, facerecognition.ErrDimensionMismatch) { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } return err } return c.JSON(http.StatusOK, schema.FaceRegisterResponse{ diff --git a/core/http/endpoints/localai/face_register_test.go b/core/http/endpoints/localai/face_register_test.go new file mode 100644 index 000000000..16970345a --- /dev/null +++ b/core/http/endpoints/localai/face_register_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT + +package localai_test + +import ( + "context" + "net/http" + "net/http/httptest" + "time" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/config" + . "github.com/mudler/LocalAI/core/http/endpoints/localai" + "github.com/mudler/LocalAI/core/http/middleware" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/facerecognition" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type registrationRecorder struct { + facerecognition.Registry + vector []float32 + meta facerecognition.Metadata + err error +} + +func (r *registrationRecorder) Register(_ context.Context, v []float32, m facerecognition.Metadata) (facerecognition.Metadata, error) { + r.vector = v + r.meta = m + m.ID = "saved-id" + return m, r.err +} + +var _ = Describe("Face registration replay", func() { + var reg *registrationRecorder + call := func(in schema.FaceRegisterRequest) (*httptest.ResponseRecorder, error) { + e := echo.New() + rec := httptest.NewRecorder() + c := e.NewContext(httptest.NewRequest(http.MethodPost, "/v1/face/register", nil), rec) + c.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &in) + c.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{}) + // No model loader: replay must not call the embedding backend. + err := FaceRegisterEndpoint(nil, nil, nil, reg)(c) + return rec, err + } + BeforeEach(func() { reg = ®istrationRecorder{} }) + It("accepts the saved vector and timestamp without running inference", func() { + at := time.Now().UTC() + in := schema.FaceRegisterRequest{Name: "Alice", Embedding: []float32{1, 0}, RegisteredAt: at, Labels: map[string]string{"client_id": "alice"}} + in.Model = "faces" + rec, err := call(in) + Expect(err).NotTo(HaveOccurred()) + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(reg.vector).To(Equal(in.Embedding)) + Expect(reg.meta.RegisteredAt).To(Equal(at)) + Expect(reg.meta.Labels).To(Equal(in.Labels)) + Expect(rec.Body.String()).To(ContainSubstring("saved-id")) + }) + It("rejects ambiguous and missing inputs before inference", func() { + for _, in := range []schema.FaceRegisterRequest{ + {Name: "Alice"}, + {Name: "Alice", Img: "image", Embedding: []float32{1, 0}}, + } { + in.Model = "faces" + _, err := call(in) + Expect(err).To(HaveOccurred()) + Expect(err.(*echo.HTTPError).Code).To(Equal(http.StatusBadRequest)) + Expect(reg.vector).To(BeNil()) + } + }) + It("reports invalid vectors as a client error", func() { + reg.err = facerecognition.ErrInvalidEmbedding + in := schema.FaceRegisterRequest{Name: "Alice", Embedding: []float32{0, 0}} + in.Model = "faces" + _, err := call(in) + Expect(err).To(HaveOccurred()) + Expect(err.(*echo.HTTPError).Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/core/http/endpoints/openai/realtime.go b/core/http/endpoints/openai/realtime.go index 32b08b3fd..b387805cf 100644 --- a/core/http/endpoints/openai/realtime.go +++ b/core/http/endpoints/openai/realtime.go @@ -12,6 +12,7 @@ import ( "math" "os" "strconv" + "strings" "sync" "time" @@ -633,6 +634,17 @@ func runRealtimeSession(application *application.Application, t Transport, model sendError(t, "model_load_error", "Failed to load model", "", "") return } + if wrapped, ok := m.(*wrappedModel); ok { + resolvedVoice, params, release, resolveErr := resolveRealtimeVoice(context.Background(), session.Voice, wrapped.TTSConfig, application.VoiceProfileStore()) + if resolveErr != nil { + xlog.Error("failed to resolve realtime voice", "error", resolveErr) + sendError(t, "voice_profile_error", resolveErr.Error(), "", "") + return + } + defer release() + session.Voice = resolvedVoice + wrapped.ttsParams = params + } session.ModelInterface = m // A pipeline-seeded option list gets its scoring prompt prewarmed // alongside the model warm-up below, so the session's first turn @@ -1923,7 +1935,7 @@ func commitUtteranceWithTranscript(ctx context.Context, utt []byte, live *liveUt // Generate an LLM response only when there is a transcript to feed it. A // sound-detection-only session (no transcription) has no LLM stage, so it // stops here after emitting the sound-detection event. - if session.InputAudioTranscription != nil && !session.TranscriptionOnly { + if session.InputAudioTranscription != nil && !session.TranscriptionOnly && strings.TrimSpace(transcript) != "" { generateResponse(ctx, session, utt, transcript, speaker, conv, t) } } diff --git a/core/http/endpoints/openai/realtime_model.go b/core/http/endpoints/openai/realtime_model.go index 68dd61997..33ca2c762 100644 --- a/core/http/endpoints/openai/realtime_model.go +++ b/core/http/endpoints/openai/realtime_model.go @@ -6,7 +6,9 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" + "errors" "fmt" + "maps" "strings" "sync" "time" @@ -18,6 +20,7 @@ import ( "github.com/mudler/LocalAI/core/http/middleware" "github.com/mudler/LocalAI/core/schema" "github.com/mudler/LocalAI/core/services/routing/router" + "github.com/mudler/LocalAI/core/services/voiceprofile" "github.com/mudler/LocalAI/core/templates" "github.com/mudler/LocalAI/pkg/functions" "github.com/mudler/LocalAI/pkg/grpc/proto" @@ -35,6 +38,7 @@ var ( // which are for Any-To-Any models, but instead we will call a pipeline (for e.g STT->LLM->TTS) type wrappedModel struct { TTSConfig *config.ModelConfig + ttsParams map[string]string TranscriptionConfig *config.ModelConfig LLMConfig *config.ModelConfig VADConfig *config.ModelConfig @@ -391,11 +395,35 @@ func newRealtimeDecisionID() string { } func (m *wrappedModel) TTS(ctx context.Context, text, voice, language string) (string, *proto.Result, error) { - return backend.ModelTTS(ctx, text, voice, language, "", nil, m.modelLoader, m.appConfig, *m.TTSConfig) + return backend.ModelTTS(ctx, text, voice, language, "", maps.Clone(m.ttsParams), m.modelLoader, m.appConfig, *m.TTSConfig) } func (m *wrappedModel) TTSStream(ctx context.Context, text, voice, language string, onAudio func(pcm []byte, sampleRate int) error) error { - return ttsStream(ctx, m.modelLoader, m.appConfig, *m.TTSConfig, text, voice, language, onAudio) + return ttsStream(ctx, m.modelLoader, m.appConfig, *m.TTSConfig, text, voice, language, maps.Clone(m.ttsParams), onAudio) +} + +func resolveRealtimeVoice(ctx context.Context, configuredVoice string, ttsConfig *config.ModelConfig, profiles *voiceprofile.Store) (string, map[string]string, func(), error) { + if !voiceprofile.IsReference(configuredVoice) { + return configuredVoice, nil, func() {}, nil + } + profileID, valid := voiceprofile.ParseReference(configuredVoice) + if !valid { + return "", nil, nil, fmt.Errorf("invalid voice profile reference %q", configuredVoice) + } + if config.VoiceCloningForModel(ttsConfig) == nil { + return "", nil, nil, fmt.Errorf("selected TTS model does not support reference-audio voice cloning") + } + if profiles == nil { + return "", nil, nil, fmt.Errorf("voice profile store is unavailable") + } + profile, referencePath, release, err := profiles.LeaseAudio(ctx, profileID) + if err != nil { + if errors.Is(err, voiceprofile.ErrNotFound) { + return "", nil, nil, fmt.Errorf("voice profile not found: %w", err) + } + return "", nil, nil, fmt.Errorf("resolve voice profile: %w", err) + } + return referencePath, map[string]string{"ref_text": profile.Transcript}, release, nil } func (m *wrappedModel) TranscribeStream(ctx context.Context, audio, language string, translate, diarize bool, prompt string, onDelta func(text string)) (*schema.TranscriptionResult, error) { @@ -674,11 +702,11 @@ const wavStreamHeaderBytes = 44 // callback, which wants raw PCM plus the sample rate. The header is buffered // until complete, the sample rate is read from it, and subsequent bytes are // forwarded as PCM. -func ttsStream(ctx context.Context, ml *model.ModelLoader, appConfig *config.ApplicationConfig, ttsConfig config.ModelConfig, text, voice, language string, onAudio func(pcm []byte, sampleRate int) error) error { +func ttsStream(ctx context.Context, ml *model.ModelLoader, appConfig *config.ApplicationConfig, ttsConfig config.ModelConfig, text, voice, language string, params map[string]string, onAudio func(pcm []byte, sampleRate int) error) error { var header []byte headerDone := false sampleRate := 0 - return backend.ModelTTSStream(ctx, text, voice, language, "", nil, ml, appConfig, ttsConfig, func(b []byte) error { + return backend.ModelTTSStream(ctx, text, voice, language, "", params, ml, appConfig, ttsConfig, func(b []byte) error { if headerDone { if len(b) == 0 { return nil diff --git a/core/http/endpoints/openai/realtime_semantic_vad_test.go b/core/http/endpoints/openai/realtime_semantic_vad_test.go index c3f5d7ef8..c36e13563 100644 --- a/core/http/endpoints/openai/realtime_semantic_vad_test.go +++ b/core/http/endpoints/openai/realtime_semantic_vad_test.go @@ -355,6 +355,19 @@ var _ = Describe("commitUtteranceWithTranscript", func() { Expect(tr.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionCompleted)).To(Equal(1)) }) + + It("does not generate a response for a blank transcript", func() { + session, model := itSession(nil) + model.transcribeFinal = &schema.TranscriptionResult{Text: " \t\n"} + tr := &fakeTransport{} + conv := &Conversation{} + + commitUtterance(context.Background(), []byte{1, 2}, session, conv, tr) + + Expect(tr.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionCompleted)).To(Equal(1)) + Expect(conv.Items).To(BeEmpty()) + Expect(tr.countEvents(types.ServerEventTypeResponseCreated)).To(Equal(0)) + }) }) // transcribeUtterance is the retranscribe gate's offline decode of the diff --git a/core/http/endpoints/openai/realtime_voice_profile_test.go b/core/http/endpoints/openai/realtime_voice_profile_test.go new file mode 100644 index 000000000..801d37bbc --- /dev/null +++ b/core/http/endpoints/openai/realtime_voice_profile_test.go @@ -0,0 +1,180 @@ +package openai + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "time" + + grpcPkg "github.com/mudler/LocalAI/pkg/grpc" + "github.com/mudler/LocalAI/pkg/grpc/proto" + "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/LocalAI/pkg/system" + "google.golang.org/grpc" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/services/voiceprofile" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func realtimeProfileWAV(duration time.Duration) []byte { + const ( + sampleRate = 16000 + channels = 1 + bitsPerSample = 16 + ) + dataSize := int(duration.Seconds() * sampleRate * channels * bitsPerSample / 8) + buf := bytes.NewBuffer(nil) + buf.WriteString("RIFF") + _ = binary.Write(buf, binary.LittleEndian, uint32(36+dataSize)) + buf.WriteString("WAVEfmt ") + _ = binary.Write(buf, binary.LittleEndian, uint32(16)) + _ = binary.Write(buf, binary.LittleEndian, uint16(1)) + _ = binary.Write(buf, binary.LittleEndian, uint16(channels)) + _ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate)) + _ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate*channels*bitsPerSample/8)) + _ = binary.Write(buf, binary.LittleEndian, uint16(channels*bitsPerSample/8)) + _ = binary.Write(buf, binary.LittleEndian, uint16(bitsPerSample)) + buf.WriteString("data") + _ = binary.Write(buf, binary.LittleEndian, uint32(dataSize)) + buf.Write(make([]byte, dataSize)) + return buf.Bytes() +} + +var _ = Describe("realtime pipeline voice profiles", func() { + It("resolves a saved profile to an immutable lease and transcript", func(ctx SpecContext) { + store := voiceprofile.NewStore(GinkgoT().TempDir()) + DeferCleanup(func() { Expect(store.Close()).To(Succeed()) }) + profile, err := store.Create(ctx, voiceprofile.CreateInput{ + Name: "Narrator", + Language: "en-US", + Transcript: "The reference transcript.", + ConsentConfirmed: true, + }, bytes.NewReader(realtimeProfileWAV(time.Second))) + Expect(err).NotTo(HaveOccurred()) + + voice, params, release, err := resolveRealtimeVoice(ctx, profile.Voice, &config.ModelConfig{ + Name: "clone-base", + Backend: "qwen3-tts-cpp", + TTSConfig: config.TTSConfig{VoiceCloning: ptrTo(true)}, + }, store) + + Expect(err).NotTo(HaveOccurred()) + Expect(voice).To(BeAnExistingFile()) + Expect(params).To(Equal(map[string]string{"ref_text": "The reference transcript."})) + release() + release() + Expect(voice).NotTo(BeAnExistingFile()) + }) + + It("leaves an ordinary backend voice unchanged with no parameters", func() { + voice, params, release, err := resolveRealtimeVoice(context.Background(), "speaker-7", &config.ModelConfig{}, nil) + + Expect(err).NotTo(HaveOccurred()) + Expect(voice).To(Equal("speaker-7")) + Expect(params).To(BeNil()) + Expect(release).NotTo(BeNil()) + Expect(func() { release(); release() }).NotTo(Panic()) + }) + + DescribeTable("returns actionable reference errors", + func(configuredVoice string, cfg *config.ModelConfig, store *voiceprofile.Store, expected string) { + _, _, release, err := resolveRealtimeVoice(context.Background(), configuredVoice, cfg, store) + Expect(err).To(MatchError(ContainSubstring(expected))) + Expect(release).To(BeNil()) + }, + Entry("malformed reference", "localai://voice-profiles/not-a-uuid", &config.ModelConfig{}, nil, "invalid voice profile reference"), + Entry("unsupported model", "localai://voice-profiles/00000000-0000-0000-0000-000000000001", &config.ModelConfig{Backend: "piper"}, nil, "does not support reference-audio voice cloning"), + Entry("unavailable store", "localai://voice-profiles/00000000-0000-0000-0000-000000000001", &config.ModelConfig{Name: "clone-base", Backend: "qwen3-tts-cpp", TTSConfig: config.TTSConfig{VoiceCloning: ptrTo(true)}}, nil, "voice profile store is unavailable"), + ) + + It("reports a missing profile", func() { + store := voiceprofile.NewStore(GinkgoT().TempDir()) + DeferCleanup(func() { Expect(store.Close()).To(Succeed()) }) + _, _, release, err := resolveRealtimeVoice(context.Background(), "localai://voice-profiles/00000000-0000-0000-0000-000000000001", &config.ModelConfig{ + Name: "clone-base", Backend: "qwen3-tts-cpp", TTSConfig: config.TTSConfig{VoiceCloning: ptrTo(true)}, + }, store) + Expect(errors.Is(err, voiceprofile.ErrNotFound)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("voice profile not found")) + Expect(release).To(BeNil()) + }) +}) + +type recordingTTSBackend struct { + grpcPkg.Backend + requests []*proto.TTSRequest +} + +func (b *recordingTTSBackend) HealthCheck(context.Context) (bool, error) { return true, nil } +func (b *recordingTTSBackend) IsBusy() bool { return false } + +func (b *recordingTTSBackend) record(req *proto.TTSRequest) { + b.requests = append(b.requests, req) + req.Params["ref_text"] = "backend mutation" +} + +func (b *recordingTTSBackend) TTS(_ context.Context, req *proto.TTSRequest, _ ...grpc.CallOption) (*proto.Result, error) { + b.record(req) + return &proto.Result{Success: true}, nil +} + +func (b *recordingTTSBackend) TTSStream(_ context.Context, req *proto.TTSRequest, callback func(*proto.Reply), _ ...grpc.CallOption) error { + b.record(req) + header := make([]byte, wavStreamHeaderBytes) + binary.LittleEndian.PutUint32(header[24:28], 24000) + callback(&proto.Reply{Audio: header}) + return nil +} + +var _ = Describe("wrappedModel voice profile parameters", func() { + var ( + wrapped *wrappedModel + backendRecorder *recordingTTSBackend + ) + + BeforeEach(func() { + state, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir())) + Expect(err).NotTo(HaveOccurred()) + appConfig := config.NewApplicationConfig(config.WithSystemState(state)) + appConfig.GeneratedContentDir = GinkgoT().TempDir() + loader := model.NewModelLoader(state) + backendRecorder = &recordingTTSBackend{} + cfg := &config.ModelConfig{Name: "tts-test", Backend: "test"} + cfg.Model = "weights" + loaded := model.NewModelWithClient(cfg.ModelID(), "in-process", backendRecorder) + loaded.MarkHealthy() + _, err = loader.LoadModel(cfg.ModelID(), cfg.Model, func(_, _, _ string) (*model.Model, error) { return loaded, nil }) + Expect(err).NotTo(HaveOccurred()) + wrapped = &wrappedModel{ + TTSConfig: cfg, + ttsParams: map[string]string{"ref_text": "Original transcript"}, + modelLoader: loader, + appConfig: appConfig, + } + }) + + It("forwards a fresh transcript parameter map to every unary request", func() { + _, _, err := wrapped.TTS(context.Background(), "one", "voice.wav", "en") + Expect(err).NotTo(HaveOccurred()) + _, _, err = wrapped.TTS(context.Background(), "two", "voice.wav", "en") + Expect(err).NotTo(HaveOccurred()) + + Expect(backendRecorder.requests).To(HaveLen(2)) + Expect(backendRecorder.requests[0].Params).To(HaveKeyWithValue("ref_text", "backend mutation")) + Expect(backendRecorder.requests[1].Params).To(HaveKeyWithValue("ref_text", "backend mutation")) + Expect(wrapped.ttsParams).To(HaveKeyWithValue("ref_text", "Original transcript")) + }) + + It("forwards a copied transcript parameter map to streaming requests", func() { + err := wrapped.TTSStream(context.Background(), "one", "voice.wav", "en", func([]byte, int) error { return nil }) + Expect(err).NotTo(HaveOccurred()) + + Expect(backendRecorder.requests).To(HaveLen(1)) + Expect(backendRecorder.requests[0].Params).To(HaveKeyWithValue("ref_text", "backend mutation")) + Expect(wrapped.ttsParams).To(HaveKeyWithValue("ref_text", "Original transcript")) + }) +}) + +func ptrTo[T any](value T) *T { return &value } diff --git a/core/http/react-ui/bun.lock b/core/http/react-ui/bun.lock index f0b034a69..4d9ce9572 100644 --- a/core/http/react-ui/bun.lock +++ b/core/http/react-ui/bun.lock @@ -53,6 +53,7 @@ "overrides": { "hono": "4.12.34", "ip-address": "10.3.1", + "path-to-regexp": "^8.4.0", }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -807,7 +808,7 @@ "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "path-to-regexp": ["path-to-regexp@8.4.0", "", {}, "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], diff --git a/core/http/react-ui/e2e/collections.spec.js b/core/http/react-ui/e2e/collections.spec.js index 4fa4168dd..f3f4bd8c2 100644 --- a/core/http/react-ui/e2e/collections.spec.js +++ b/core/http/react-ui/e2e/collections.spec.js @@ -19,4 +19,33 @@ test.describe('Collections page', () => { await input.fill('my-kb') await expect(input).toHaveValue('my-kb') }) + + test('posts the source update interval as a JSON number', async ({ page }) => { + const collectionName = 'interval-regression' + const collectionPath = encodeURIComponent(collectionName) + let postedBody + + await page.route(`**/api/agents/collections/${collectionPath}/entries`, route => + route.fulfill({ contentType: 'application/json', body: JSON.stringify({ entries: [] }) })) + await page.route(`**/api/agents/collections/${collectionPath}/sources`, async route => { + if (route.request().method() === 'POST') { + postedBody = route.request().postDataJSON() + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ status: 'ok' }) }) + } else { + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ sources: [] }) }) + } + }) + + await page.goto(`/app/collections/${collectionPath}`) + await page.getByRole('button', { name: 'Sources' }).click() + await page.locator('#source-url').fill('https://example.com/feed') + await page.locator('#source-interval').fill('3600') + await page.getByRole('button', { name: 'Add Source' }).click() + + await expect.poll(() => postedBody).toEqual({ + url: 'https://example.com/feed', + update_interval: 3600, + }) + expect(typeof postedBody.update_interval).toBe('number') + }) }) diff --git a/core/http/react-ui/e2e/traces-pagination.spec.js b/core/http/react-ui/e2e/traces-pagination.spec.js index c6d7b89a1..a7e56afd4 100644 --- a/core/http/react-ui/e2e/traces-pagination.spec.js +++ b/core/http/react-ui/e2e/traces-pagination.spec.js @@ -79,4 +79,30 @@ test.describe('Traces - bounded list and on-demand detail', () => { await expect(page.locator('text=hello from the response body')).toBeVisible() await expect(page.locator('text=203.0.113.9').first()).toBeVisible() }) + + test('keeps the expanded trace open when a refresh prepends a new row', async ({ page }) => { + await page.locator('tr', { hasText: '/v1/chat/completions' }).first().click() + await expect(page.locator('text=hello from the request body')).toBeVisible() + + await page.route('**/api/traces?*', (route) => { + route.fulfill({ + contentType: 'application/json', + headers: { 'X-Total-Count': '843' }, + body: JSON.stringify([ + { + id: '8', + request: { method: 'GET', path: '/v1/models', body: null }, + response: { status: 200, body: null }, + }, + ...LIST_BODY, + ]), + }) + }) + + await page.getByRole('button', { name: 'Refresh' }).click() + + await expect(page.locator('text=hello from the request body')).toBeVisible() + const originalRow = page.locator('tr', { hasText: '/v1/chat/completions' }).first() + await expect(originalRow.locator('i.fa-chevron-down')).toBeVisible() + }) }) diff --git a/core/http/react-ui/package-lock.json b/core/http/react-ui/package-lock.json index 9c8edbfc8..7ac92af0c 100644 --- a/core/http/react-ui/package-lock.json +++ b/core/http/react-ui/package-lock.json @@ -32,7 +32,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^17.0.6", - "react-router": "7.18.2", + "react-router": "8.3.1", "react-router-dom": "7.18.2", "yaml": "^2.8.3" }, @@ -649,27 +649,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2198,6 +2214,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -2889,9 +2911,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -5951,9 +5973,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -6061,22 +6083,24 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.8" } }, "node_modules/react-i18next": { @@ -6106,20 +6130,19 @@ } }, "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.1.tgz", + "integrity": "sha512-TEOpiO2g0TJHEOJeRVv4amUFun9v1npCKszvcquNvzETUtJ8udV86ah5eFoHT7g26bsBvT6EiIhqulR8eDF++A==", "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "cookie-es": "^3.1.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.22.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=19.2.7", + "react-dom": ">=19.2.7" }, "peerDependenciesMeta": { "react-dom": { @@ -6143,6 +6166,28 @@ "react-dom": ">=18" } }, + "node_modules/react-router-dom/node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", diff --git a/core/http/react-ui/package.json b/core/http/react-ui/package.json index fc3832680..6d3861a79 100644 --- a/core/http/react-ui/package.json +++ b/core/http/react-ui/package.json @@ -20,7 +20,8 @@ }, "overrides": { "hono": "4.12.34", - "ip-address": "10.3.1" + "ip-address": "10.3.1", + "path-to-regexp": "^8.4.0" }, "dependencies": { "@codemirror/autocomplete": "^6.18.6", @@ -47,7 +48,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^17.0.6", - "react-router": "7.18.2", + "react-router": "8.3.1", "react-router-dom": "7.18.2", "yaml": "^2.8.3" }, diff --git a/core/http/react-ui/src/pages/CollectionDetails.jsx b/core/http/react-ui/src/pages/CollectionDetails.jsx index 0469df078..2ce4b6991 100644 --- a/core/http/react-ui/src/pages/CollectionDetails.jsx +++ b/core/http/react-ui/src/pages/CollectionDetails.jsx @@ -432,10 +432,12 @@ export default function CollectionDetails() { setNewSourceInterval(e.target.value)} - placeholder="e.g. 1h, 30m" + placeholder="e.g. 60 (minutes)" />