From 2ba66004409caafb666172445518c014c26bac93 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Tue, 8 Sep 2026 22:59:57 +0200 Subject: [PATCH 01/10] fix(detection): avoid temporary image files (#11938) RF-DETR and Locate Anything wrote each decoded request image to the OS temporary directory. A full temporary filesystem then disabled detection, even though both native libraries already accept encoded image buffers. Pass decoded images directly to the native buffer APIs. This removes the request-time disk dependency and prevents crash-orphaned image files. Assisted-by: Codex:gpt-5 Co-authored-by: Ettore Di Giacinto --- .../golocateanythingcpp.go | 26 ++++----- .../golocateanythingcpp_unit_test.go | 54 ++++++++++++++++++ backend/go/rfdetr-cpp/gorfdetrcpp.go | 29 +++++----- .../go/rfdetr-cpp/gorfdetrcpp_unit_test.go | 56 +++++++++++++++++++ 4 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 backend/go/locate-anything-cpp/golocateanythingcpp_unit_test.go create mode 100644 backend/go/rfdetr-cpp/gorfdetrcpp_unit_test.go 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/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")) + }) +}) From 780e458f115737fd301e459d89c5a1eb7f172ab8 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Tue, 8 Sep 2026 23:12:57 +0200 Subject: [PATCH 02/10] fix(realtime): skip responses for empty transcripts (#11940) Realtime turns could invoke the LLM and TTS even when speech transcription returned only whitespace. This let ambient noise produce unsolicited assistant output and polluted conversation history with an empty user turn. Require non-blank transcript text before automatic response generation while preserving the completed transcription event. Assisted-by: Codex:gpt-5 golangci-lint Co-authored-by: Ettore Di Giacinto --- core/http/endpoints/openai/realtime.go | 3 ++- .../endpoints/openai/realtime_semantic_vad_test.go | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/core/http/endpoints/openai/realtime.go b/core/http/endpoints/openai/realtime.go index 32b08b3fd..8b17a8238 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" @@ -1923,7 +1924,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_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 From 80872e5e8d94a6f4c34c18f7b937ea75d669d9b5 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Tue, 8 Sep 2026 23:13:20 +0200 Subject: [PATCH 03/10] chore(model-gallery): :arrow_up: update checksum (#11939) :arrow_up: Checksum updates in gallery/index.yaml Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- gallery/index.yaml | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/gallery/index.yaml b/gallery/index.yaml index b1af776e6..804049539 100644 --- a/gallery/index.yaml +++ b/gallery/index.yaml @@ -113,24 +113,7 @@ url: "github:mudler/LocalAI/gallery/virtual.yaml@master" urls: - https://huggingface.co/unsloth/GLM-5.3-Flash-GGUF - description: | - # GLM-5.3-Flash - - 👋 Join our WeChat or Discord community. - - 📖 Check out the GLM-5.3-Flash blog and GLM-5 Technical report. - - 📍 Use GLM-5.3-Flash API services on Z.ai API Platform. - - ## Introduction - - We introduce GLM-5.3-Flash, the first natively multimodal model in the GLM-5 series. With 320B total parameters and just 18B active parameters, it outperforms GLM-5.2 across benchmarks and real-world workloads at one-tenth the price, while approaching Claude Opus 4.8 on coding and agentic benchmarks. - - GLM-5.3-Flash starts from a newly trained base model, with its architecture and training recipe redesigned around capability and efficiency. For the first time in the GLM series, we introduce a hybrid architecture combining sparse and linear attention, sharply reducing long-context serving costs while preserving precise long-context capabilities. The model also adopts Manifold-Constrained Hyper-Connections (mHC) to further improve scaling efficiency. Together with our latest 30T-token multimodal pre-training corpus, these changes enable GLM-5.3-Flash to deliver more intelligence with less compute. - - ## Serve GLM-5.3-Flash Locally - - ... + description: "# GLM-5.3-Flash\n\n\U0001F44B Join our WeChat or Discord community.\n\n\U0001F4D6 Check out the GLM-5.3-Flash blog and GLM-5 Technical report.\n\n\U0001F4CD Use GLM-5.3-Flash API services on Z.ai API Platform.\n\n## Introduction\n\nWe introduce GLM-5.3-Flash, the first natively multimodal model in the GLM-5 series. With 320B total parameters and just 18B active parameters, it outperforms GLM-5.2 across benchmarks and real-world workloads at one-tenth the price, while approaching Claude Opus 4.8 on coding and agentic benchmarks.\n\nGLM-5.3-Flash starts from a newly trained base model, with its architecture and training recipe redesigned around capability and efficiency. For the first time in the GLM series, we introduce a hybrid architecture combining sparse and linear attention, sharply reducing long-context serving costs while preserving precise long-context capabilities. The model also adopts Manifold-Constrained Hyper-Connections (mHC) to further improve scaling efficiency. Together with our latest 30T-token multimodal pre-training corpus, these changes enable GLM-5.3-Flash to deliver more intelligence with less compute.\n\n## Serve GLM-5.3-Flash Locally\n\n...\n" license: "mit" tags: - llm @@ -768,9 +751,7 @@ model: llama-cpp/models/s1-mini/s1-mini-q4_k_m.gguf temperature: 0 system_prompt: >- - You are a text normalizer for speech-to-text transcripts. The input begins - with a control line specifying the styling, structure, and context settings; - clean the transcript to match those settings and output only the cleaned text. + You are a text normalizer for speech-to-text transcripts. The input begins with a control line specifying the styling, structure, and context settings; clean the transcript to match those settings and output only the cleaned text. template: use_tokenizer_template: true files: @@ -796,9 +777,7 @@ model: llama-cpp/models/s1-mini/s1-mini-f16.gguf temperature: 0 system_prompt: >- - You are a text normalizer for speech-to-text transcripts. The input begins - with a control line specifying the styling, structure, and context settings; - clean the transcript to match those settings and output only the cleaned text. + You are a text normalizer for speech-to-text transcripts. The input begins with a control line specifying the styling, structure, and context settings; clean the transcript to match those settings and output only the cleaned text. template: use_tokenizer_template: true files: @@ -14131,8 +14110,8 @@ model: kokoro-int8-multi-lang-v1_0/model.int8.onnx files: - filename: kokoro-int8-multi-lang-v1_0.tar.bz2 - sha256: 75654a84864be26f345f020f4070c2c019e96dd1b7f9bf6e2ffd59efac6aa5a3 uri: https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-int8-multi-lang-v1_0.tar.bz2 + sha256: 4c3052abaa60943a341f193888cf6abd68787dae6ab8ae5c925a706caa247e4e - name: supertonic-3 url: github:mudler/LocalAI/gallery/supertonic.yaml@master urls: From 8c718441f6d224ccd4509cdff5ca2db453a937fc Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Tue, 8 Sep 2026 23:42:31 +0200 Subject: [PATCH 04/10] fix(realtime): resolve pipeline voice profiles (#11942) * fix(realtime): resolve saved voice profiles Realtime pipelines now validate saved voices against the selected TTS model and retain leased audio until session teardown. Each synthesis request receives its own transcript parameter map. Assisted-by: Codex:GPT-5 * docs(tts): document realtime voice defaults Show how a realtime pipeline selects a saved Voice Library profile at session start. Clarify which session voice updates remain supported. Assisted-by: Codex:GPT-5 --------- Co-authored-by: Ettore Di Giacinto --- core/http/endpoints/openai/realtime.go | 11 ++ core/http/endpoints/openai/realtime_model.go | 36 +++- .../openai/realtime_voice_profile_test.go | 180 ++++++++++++++++++ docs/content/features/text-to-audio.md | 19 ++ 4 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 core/http/endpoints/openai/realtime_voice_profile_test.go diff --git a/core/http/endpoints/openai/realtime.go b/core/http/endpoints/openai/realtime.go index 8b17a8238..b387805cf 100644 --- a/core/http/endpoints/openai/realtime.go +++ b/core/http/endpoints/openai/realtime.go @@ -634,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 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_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/docs/content/features/text-to-audio.md b/docs/content/features/text-to-audio.md index 8ff355a73..877bd4796 100644 --- a/docs/content/features/text-to-audio.md +++ b/docs/content/features/text-to-audio.md @@ -124,6 +124,25 @@ Reference selection follows this order: When a saved profile is selected, LocalAI supplies both its private WAV and exact transcript for that request. It does not rewrite the model YAML or copy the recording into the model directory. +### Realtime pipeline default + +Set `tts.voice` on a realtime pipeline model to use a saved Voice Library profile as the session default: + +```yaml +name: gpt-realtime +tts: + voice: localai://voice-profiles/550e8400-e29b-41d4-a716-446655440000 +pipeline: + vad: silero-vad-ggml + transcription: whisper-large-turbo + llm: qwen3-4b + tts: qwen3-tts-base +``` + +LocalAI resolves this profile when the realtime session starts. The selected TTS model must support Voice Library cloning. + +This feature does not resolve Voice Library URIs sent later through realtime `session.update`. You can still use `session.update` with ordinary backend voice names or IDs. + #### Supported backend and model variants | Backend | Automatically compatible variants | From bf93008ef3d6662d6a69447ca121a2af14c741f9 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Tue, 8 Sep 2026 23:44:22 +0200 Subject: [PATCH 05/10] fix(backends): bound temporary scratch files (#11941) Backend processes shared the host temporary directory, so crashes could leave request images and audio behind until the filesystem filled. Give each process a locked LocalAI-owned runtime, remove scratch on exit, and sweep only marked abandoned runtimes at the next start. Also close known request error-path leaks in the Python media backends, CrispASR, LongCat Video, and stable-diffusion.cpp. Assisted-by: Codex:gpt-5 Co-authored-by: Ettore Di Giacinto --- Makefile | 2 +- backend/go/crispasr/gocrispasr.go | 2 +- backend/go/stablediffusion-ggml/cpp/gosd.cpp | 16 +- backend/python/chatterbox/backend.py | 33 ++-- backend/python/common/temp_utils.py | 36 ++++ backend/python/common/temp_utils_test.py | 41 +++++ backend/python/longcat-video/backend.py | 8 + backend/python/vllm-omni/backend.py | 23 +-- backend/python/vllm/backend.py | 11 +- core/services/worker/supervisor.go | 11 ++ docs/content/reference/cli-reference.md | 7 + pkg/model/initializers.go | 9 +- pkg/model/loader.go | 4 + pkg/model/process.go | 57 +++++-- pkg/model/process_exit_test.go | 10 +- pkg/model/process_runtime.go | 168 +++++++++++++++++++ pkg/model/process_runtime_test.go | 109 ++++++++++++ pkg/model/process_statedir_test.go | 38 ----- 18 files changed, 475 insertions(+), 110 deletions(-) create mode 100644 backend/python/common/temp_utils.py create mode 100644 backend/python/common/temp_utils_test.py create mode 100644 pkg/model/process_runtime.go create mode 100644 pkg/model/process_runtime_test.go delete mode 100644 pkg/model/process_statedir_test.go 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/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/stablediffusion-ggml/cpp/gosd.cpp b/backend/go/stablediffusion-ggml/cpp/gosd.cpp index 7722e8d06..b876df256 100644 --- a/backend/go/stablediffusion-ggml/cpp/gosd.cpp +++ b/backend/go/stablediffusion-ggml/cpp/gosd.cpp @@ -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) { 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/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/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/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 de9af3798..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 @@ -23,6 +22,7 @@ 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 @@ -1005,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/core/services/worker/supervisor.go b/core/services/worker/supervisor.go index 184419457..1d9b52365 100644 --- a/core/services/worker/supervisor.go +++ b/core/services/worker/supervisor.go @@ -597,6 +597,7 @@ func (s *backendSupervisor) reapDeadProcess(key string, bp *backendProcess) { if bp == nil { return } + s.cleanupProcessRuntime(bp.proc) if bp.port <= 0 { xlog.Error("Cannot recycle backend port: dead process has invalid recorded port", "backend", key, "addr", bp.addr, "port", bp.port) return @@ -614,6 +615,7 @@ func (s *backendSupervisor) releaseBackendStart(key string, bp *backendProcess) return } delete(s.processes, key) + s.cleanupProcessRuntime(bp.proc) if bp.port <= 0 { xlog.Error("Cannot recycle backend port: startup has invalid recorded port", "backend", key, "addr", bp.addr, "port", bp.port) return @@ -947,6 +949,7 @@ func (s *backendSupervisor) finishBackendStop(key string, bp *backendProcess, st return fmt.Errorf("stopping backend process %s: %w", key, stopErr) } delete(s.processes, key) + s.cleanupProcessRuntime(bp.proc) if bp.port <= 0 { xlog.Error("Cannot recycle backend port: process has invalid recorded port", "backend", key, "addr", bp.addr, "port", bp.port) return nil @@ -955,6 +958,14 @@ func (s *backendSupervisor) finishBackendStop(key string, bp *backendProcess, st return nil } +func (s *backendSupervisor) cleanupProcessRuntime(proc *process.Process) { + // Some focused supervisor tests provide synthetic process handles without a + // ModelLoader. Production processes always come from s.ml.StartProcess. + if s.ml != nil { + s.ml.CleanupProcessRuntime(proc) + } +} + // stopAllBackends stops all running backend processes. func (s *backendSupervisor) stopAllBackends(force bool) { s.mu.Lock() diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md index 2ba6c96e8..d00d8046b 100644 --- a/docs/content/reference/cli-reference.md +++ b/docs/content/reference/cli-reference.md @@ -27,9 +27,16 @@ Complete reference for all LocalAI command-line interface (CLI) parameters and e | `--upload-path` | `TMPDIR/localai-UID/upload` | Path to store uploads from files API. Defaults under the OS temp dir (`$TMPDIR`, falling back to `/tmp`), scoped to the current user's UID. | `$LOCALAI_UPLOAD_PATH`, `$UPLOAD_PATH` | | `--localai-config-dir` | `BASEPATH/configuration` | Directory for dynamic loading of certain configuration files (currently runtime_settings.json, api_keys.json, and external_backends.json). See [Runtime Settings]({{%relref "features/runtime-settings" %}}) for web-based configuration. | `$LOCALAI_CONFIG_DIR` | | `--localai-config-dir-poll-interval` | | Time duration to poll the LocalAI Config Dir if your system has broken fsnotify events (example: `1m`) | `$LOCALAI_CONFIG_DIR_POLL_INTERVAL` | + | `--models-config-file` | | YAML file containing a list of model backend configs (alias: `--config-file`) | `$LOCALAI_MODELS_CONFIG_FILE`, `$CONFIG_FILE` | | `--artifact-download-concurrency` | `1` | How many files of a model artifact to download at once. `1` downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume. Whole files only — a single file is never split, so resume and per-file checksum verification are unaffected | `$LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY` | +Backend processes receive a private scratch directory through `TMPDIR`, `TMP`, +and `TEMP`. LocalAI removes that directory when the backend exits and removes +abandoned directories left by a LocalAI crash before starting another backend. +Set `$LOCALAI_BACKEND_TEMP_DIR` to choose their base volume. LocalAI always +appends `localai-UID/backend-runtime`; the default base is `TMPDIR`. + ## Backend Flags | Parameter | Default | Description | Environment Variable | diff --git a/pkg/model/initializers.go b/pkg/model/initializers.go index e73d45535..49276abc3 100644 --- a/pkg/model/initializers.go +++ b/pkg/model/initializers.go @@ -173,7 +173,7 @@ func (ml *ModelLoader) spawnGRPCModel(backend, uri string, o *Options, modelID, if !ready { xlog.Debug("GRPC Service NOT ready") startupErr := grpcStartupError(client.Process()) - stopLoadProcess(client, modelID) + ml.stopLoadProcess(client, modelID) return nil, startupErr } @@ -189,11 +189,11 @@ func (ml *ModelLoader) spawnGRPCModel(backend, uri string, o *Options, modelID, res, err := client.GRPC(o.parallelRequests, ml.wd).LoadModel(o.context, options) if err != nil { - stopLoadProcess(client, modelID) + ml.stopLoadProcess(client, modelID) return nil, fmt.Errorf("could not load model: %w", err) } if !res.Success { - stopLoadProcess(client, modelID) + ml.stopLoadProcess(client, modelID) return nil, fmt.Errorf("could not load model (no success): %s", res.Message) } @@ -260,7 +260,7 @@ func lastNonEmptyLine(path string, maxBytes int64) string { // stopLoadProcess tears down a backend process whose load did not complete. // The stop error is only logged: the load error is what the caller reports. -func stopLoadProcess(client *Model, modelID string) { +func (ml *ModelLoader) stopLoadProcess(client *Model, modelID string) { process := client.Process() if process == nil { return @@ -268,6 +268,7 @@ func stopLoadProcess(client *Model, modelID string) { if err := process.Stop(); err != nil { xlog.Warn("failed to stop backend process after failed load", "error", err, "modelID", modelID) } + ml.cleanupProcessRuntime(process) } // parallelSlotsFromOptions returns the effective n_parallel from the backend diff --git a/pkg/model/loader.go b/pkg/model/loader.go index 322b11e36..2df4aee2a 100644 --- a/pkg/model/loader.go +++ b/pkg/model/loader.go @@ -106,6 +106,10 @@ type ModelLoader struct { // the exit code can't, since a child killed by our own SIGTERM/SIGKILL // reports -1, indistinguishable from a signal-induced crash. stoppingProcs sync.Map + // processRuntimes keeps the owned state/scratch directory alive until the + // loader has consumed any exit diagnostics. The exit watcher removes the + // potentially large scratch contents immediately. + processRuntimes sync.Map // loadFailures records, per modelID, the cooldown window applied after a // failed load so that a client repeatedly polling a broken model does not // spawn (and leak) a fresh backend process on every request. Guarded by mu. diff --git a/pkg/model/process.go b/pkg/model/process.go index fb1ed004a..9b799e5a5 100644 --- a/pkg/model/process.go +++ b/pkg/model/process.go @@ -177,12 +177,14 @@ func (ml *ModelLoader) deleteProcess(ctx context.Context, s string, force bool) // A concurrently crashed/already-reaped process can no longer own // resources even if Stop could not read or signal its PID. store.Delete(s) + ml.cleanupProcessRuntime(process) return nil } return err } store.Delete(s) + ml.cleanupProcessRuntime(process) return nil } func (ml *ModelLoader) StopGRPC(filter GRPCProcessFilter) error { @@ -231,16 +233,6 @@ func (ml *ModelLoader) StartProcess(grpcProcess, id string, serverAddress string return ml.startProcess(grpcProcess, id, serverAddress, args...) } -// newProcessStateDir creates the directory a backend process uses for its pid, -// state and log files, and reports why when it cannot. -func newProcessStateDir() (string, error) { - dir, err := os.MkdirTemp(os.TempDir(), "go-processmanager") - if err != nil { - return "", fmt.Errorf("creating backend process state directory under %s: %w", os.TempDir(), err) - } - return dir, nil -} - func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string, args ...string) (*process.Process, error) { // Make sure the process is executable // Check first if it has executable permissions @@ -262,7 +254,12 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string return nil, err } - env := os.Environ() + runtime, err := newBackendProcessRuntime() + if err != nil { + return nil, err + } + + env := backendTempEnvironment(os.Environ(), runtime.tempDir) // Vulkan backends are self-contained: they bundle their own loader and // Mesa driver .so files in lib/ plus the matching ICD manifests in // vulkan/icd.d/. Point the loader at those manifests so it doesn't rely on @@ -271,16 +268,14 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string // and the GPU would silently fall back to CPU). No-op for other backends. env = append(env, vulkanICDEnv(workDir)...) - // Resolve the state directory here rather than through + // Resolve and own the state directory here rather than through // process.WithTemporaryStateDir(). process.New applies its options but // discards the error they return, so a temp directory that cannot be // created leaves StateDir empty and every later option unapplied. Run() - // then reported "mkdir : no such file or directory" with no path, hiding - // the real cause (a full volume, or a TMPDIR that no longer resolves). - stateDir, err := newProcessStateDir() - if err != nil { - return nil, err - } + // then reports "mkdir : no such file or directory" with no useful path. + // The same owned directory also contains backend scratch so an unexpected + // exit cannot strand request files directly in the host's shared /tmp. + stateDir := runtime.dir grpcControlProcess := process.New( process.WithStateDir(stateDir), @@ -296,8 +291,10 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string } if err := grpcControlProcess.Run(); err != nil { + runtime.cleanup() return grpcControlProcess, err } + ml.processRuntimes.Store(grpcControlProcess, runtime) xlog.Debug("GRPC Service state dir", "dir", grpcControlProcess.StateDir()) @@ -376,11 +373,35 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string } xlog.Warn("Backend process exited unexpectedly", fields...) } + runtime.cleanupScratch() + close(runtime.diagnosticsDone) }() return grpcControlProcess, nil } +func (ml *ModelLoader) cleanupProcessRuntime(process *process.Process) { + if process == nil { + return + } + value, ok := ml.processRuntimes.LoadAndDelete(process) + if !ok { + return + } + runtime := value.(*backendProcessRuntime) + go func() { + <-runtime.diagnosticsDone + runtime.cleanup() + }() +} + +// CleanupProcessRuntime releases state and scratch owned by a process started +// through StartProcess. Callers that supervise processes outside ModelLoader's +// model store must invoke it after they have consumed exit diagnostics. +func (ml *ModelLoader) CleanupProcessRuntime(process *process.Process) { + ml.cleanupProcessRuntime(process) +} + // vulkanICDEnv returns environment overrides that point the Vulkan loader at // the ICD manifests a backend bundles in /vulkan/icd.d. Vulkan // backends ship a self-contained stack — their own loader and Mesa driver .so diff --git a/pkg/model/process_exit_test.go b/pkg/model/process_exit_test.go index cc5554bcf..2b941bf6e 100644 --- a/pkg/model/process_exit_test.go +++ b/pkg/model/process_exit_test.go @@ -15,8 +15,10 @@ import ( var _ = Describe("backend process exit diagnostics", func() { It("includes the exit code and final stderr line for an unexpected exit", func() { tmpDir := GinkgoT().TempDir() + backendTempRoot := filepath.Join(tmpDir, "backend-runtime") + GinkgoT().Setenv(backendTempDirEnv, backendTempRoot) backendPath := filepath.Join(tmpDir, "failing-backend") - Expect(os.WriteFile(backendPath, []byte("#!/bin/sh\necho 'first diagnostic' >&2\necho 'fatal metal pipeline error' >&2\nexit 42\n"), 0o700)).To(Succeed()) + Expect(os.WriteFile(backendPath, []byte("#!/bin/sh\nprintf '%s' \"$TMPDIR\" > \"$0.tmpdir\"\necho 'first diagnostic' >&2\necho 'fatal metal pipeline error' >&2\nexit 42\n"), 0o700)).To(Succeed()) captured := &bytes.Buffer{} handler := slog.NewTextHandler(captured, &slog.HandlerOptions{Level: slog.LevelWarn}) @@ -29,10 +31,16 @@ var _ = Describe("backend process exit diagnostics", func() { process, err := loader.startProcess(backendPath, "test-model", "127.0.0.1:65535") Expect(err).ToNot(HaveOccurred()) Eventually(process.Done()).Should(BeClosed()) + backendTemp, err := os.ReadFile(backendPath + ".tmpdir") + Expect(err).ToNot(HaveOccurred()) + Expect(string(backendTemp)).To(Equal(filepath.Join(process.StateDir(), "tmp"))) + Eventually(string(backendTemp)).ShouldNot(BeADirectory()) Eventually(captured.String).Should(And( ContainSubstring("Backend process exited unexpectedly"), ContainSubstring("exitCode=42"), ContainSubstring(`stderr="fatal metal pipeline error"`), )) + loader.cleanupProcessRuntime(process) + Eventually(process.StateDir()).ShouldNot(BeADirectory()) }) }) diff --git a/pkg/model/process_runtime.go b/pkg/model/process_runtime.go new file mode 100644 index 000000000..b7443ef7d --- /dev/null +++ b/pkg/model/process_runtime.go @@ -0,0 +1,168 @@ +package model + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/gofrs/flock" + "github.com/mudler/xlog" +) + +const ( + backendTempDirEnv = "LOCALAI_BACKEND_TEMP_DIR" + backendRuntimeDirPrefix = "process-" + backendRuntimeMarker = ".localai-backend-runtime" + backendRuntimeMagic = "localai-backend-runtime-v1\n" +) + +// backendProcessRuntime owns both go-processmanager's state and all temporary +// files created by one backend process. The held lock distinguishes a live +// runtime from one abandoned when LocalAI was killed or crashed. +type backendProcessRuntime struct { + dir string + tempDir string + lock *flock.Flock + scratch sync.Once + once sync.Once + // diagnosticsDone closes after the exit watcher has read the state files. + diagnosticsDone chan struct{} +} + +func backendRuntimeRoot() string { + base := os.TempDir() + if configured := os.Getenv(backendTempDirEnv); configured != "" { + base = configured + } + // Always append a LocalAI- and user-specific namespace. Even if an operator + // points the configurable base at /tmp, the sweeper never inspects unrelated + // process-* directories in that shared parent. + return filepath.Join(base, fmt.Sprintf("localai-%d", os.Getuid()), "backend-runtime") +} + +func newBackendProcessRuntime() (*backendProcessRuntime, error) { + root := backendRuntimeRoot() + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, fmt.Errorf("creating backend runtime root %s: %w", root, err) + } + + // Serialize sweeping with creation. Otherwise a second LocalAI instance + // could observe the new directory in the tiny window before its owner lock + // is acquired and mistake it for an abandoned runtime. + sweepLock := flock.New(filepath.Join(root, ".sweep.lock")) + if err := sweepLock.Lock(); err != nil { + return nil, fmt.Errorf("locking backend runtime root %s: %w", root, err) + } + defer func() { + if err := sweepLock.Unlock(); err != nil { + xlog.Warn("Failed to unlock backend runtime root", "root", root, "error", err) + } + }() + + sweepAbandonedBackendRuntimes(root) + + dir, err := os.MkdirTemp(root, backendRuntimeDirPrefix) + if err != nil { + return nil, fmt.Errorf("creating backend process runtime under %s: %w", root, err) + } + if err := os.WriteFile(filepath.Join(dir, backendRuntimeMarker), []byte(backendRuntimeMagic), 0o600); err != nil { + _ = os.RemoveAll(dir) + return nil, fmt.Errorf("marking backend process runtime %s: %w", dir, err) + } + runtimeLock := flock.New(filepath.Join(dir, ".owner.lock")) + if err := runtimeLock.Lock(); err != nil { + _ = os.RemoveAll(dir) + return nil, fmt.Errorf("locking backend process runtime %s: %w", dir, err) + } + tempDir := filepath.Join(dir, "tmp") + if err := os.Mkdir(tempDir, 0o700); err != nil { + _ = runtimeLock.Unlock() + _ = os.RemoveAll(dir) + return nil, fmt.Errorf("creating backend scratch directory %s: %w", tempDir, err) + } + + return &backendProcessRuntime{ + dir: dir, + tempDir: tempDir, + lock: runtimeLock, + diagnosticsDone: make(chan struct{}), + }, nil +} + +func sweepAbandonedBackendRuntimes(root string) { + entries, err := os.ReadDir(root) + if err != nil { + xlog.Warn("Failed to inspect backend runtime root", "root", root, "error", err) + return + } + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), backendRuntimeDirPrefix) { + continue + } + dir := filepath.Join(root, entry.Name()) + marker, err := os.ReadFile(filepath.Join(dir, backendRuntimeMarker)) + if err != nil || string(marker) != backendRuntimeMagic { + continue + } + ownerLock := flock.New(filepath.Join(dir, ".owner.lock")) + available, err := ownerLock.TryLock() + if err != nil { + xlog.Warn("Failed to inspect backend runtime ownership", "dir", dir, "error", err) + continue + } + if !available { + continue + } + if err := ownerLock.Unlock(); err != nil { + xlog.Warn("Failed to release abandoned backend runtime lock", "dir", dir, "error", err) + continue + } + if err := os.RemoveAll(dir); err != nil { + xlog.Warn("Failed to remove abandoned backend runtime", "dir", dir, "error", err) + } + } +} + +func (r *backendProcessRuntime) cleanup() { + if r == nil { + return + } + r.once.Do(func() { + r.cleanupScratch() + if err := r.lock.Unlock(); err != nil { + xlog.Warn("Failed to unlock backend process runtime", "dir", r.dir, "error", err) + } + if err := os.RemoveAll(r.dir); err != nil { + xlog.Warn("Failed to remove backend process runtime", "dir", r.dir, "error", err) + } + }) +} + +func (r *backendProcessRuntime) cleanupScratch() { + if r == nil { + return + } + r.scratch.Do(func() { + if err := os.RemoveAll(r.tempDir); err != nil { + xlog.Warn("Failed to remove backend scratch directory", "dir", r.tempDir, "error", err) + } + }) +} + +func backendTempEnvironment(env []string, tempDir string) []string { + result := make([]string, 0, len(env)+3) + for _, entry := range env { + key, _, found := strings.Cut(entry, "=") + if found && (key == "TMPDIR" || key == "TMP" || key == "TEMP") { + continue + } + result = append(result, entry) + } + return append(result, + "TMPDIR="+tempDir, + "TMP="+tempDir, + "TEMP="+tempDir, + ) +} diff --git a/pkg/model/process_runtime_test.go b/pkg/model/process_runtime_test.go new file mode 100644 index 000000000..b27547300 --- /dev/null +++ b/pkg/model/process_runtime_test.go @@ -0,0 +1,109 @@ +package model + +import ( + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Backend process runtime directory", func() { + It("keeps active runtimes while sweeping abandoned ones", func() { + root := GinkgoT().TempDir() + GinkgoT().Setenv(backendTempDirEnv, root) + ownedRoot := backendRuntimeRoot() + unrelated := filepath.Join(root, backendRuntimeDirPrefix+"unrelated") + Expect(os.MkdirAll(unrelated, 0o700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(unrelated, "keep"), []byte("unrelated"), 0o600)).To(Succeed()) + + active, err := newBackendProcessRuntime() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(active.cleanup) + Expect(os.WriteFile(filepath.Join(active.tempDir, "active.img"), []byte("active"), 0o600)).To(Succeed()) + + abandoned := filepath.Join(ownedRoot, backendRuntimeDirPrefix+"abandoned") + Expect(os.MkdirAll(abandoned, 0o700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(abandoned, backendRuntimeMarker), []byte(backendRuntimeMagic), 0o600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(abandoned, "orphan.img"), []byte("orphan"), 0o600)).To(Succeed()) + foreign := filepath.Join(ownedRoot, backendRuntimeDirPrefix+"foreign") + Expect(os.MkdirAll(foreign, 0o700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(foreign, "keep"), []byte("foreign"), 0o600)).To(Succeed()) + + other, err := newBackendProcessRuntime() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(other.cleanup) + + Expect(active.dir).To(BeADirectory()) + Expect(abandoned).ToNot(BeAnExistingFile()) + Expect(foreign).To(BeADirectory()) + Expect(unrelated).To(BeADirectory()) + }) + + It("uses one private directory for process state and backend scratch", func() { + root := GinkgoT().TempDir() + GinkgoT().Setenv(backendTempDirEnv, root) + + runtime, err := newBackendProcessRuntime() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(runtime.cleanup) + + Expect(filepath.Dir(runtime.dir)).To(Equal(backendRuntimeRoot())) + Expect(runtime.tempDir).To(Equal(filepath.Join(runtime.dir, "tmp"))) + info, err := os.Stat(runtime.tempDir) + Expect(err).ToNot(HaveOccurred()) + Expect(info.IsDir()).To(BeTrue()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o700))) + }) + + It("overrides inherited temp variables for the backend only", func() { + env := backendTempEnvironment([]string{ + "PATH=/bin", + "TMPDIR=/old/tmpdir", + "TMP=/old/tmp", + "TEMP=/old/temp", + }, "/owned/scratch") + + Expect(env).To(ConsistOf( + "PATH=/bin", + "TMPDIR=/owned/scratch", + "TMP=/owned/scratch", + "TEMP=/owned/scratch", + )) + for _, key := range []string{"TMPDIR", "TMP", "TEMP"} { + count := 0 + for _, entry := range env { + if strings.HasPrefix(entry, key+"=") { + count++ + } + } + Expect(count).To(Equal(1), key) + } + }) + + It("removes the runtime when its owner exits", func() { + GinkgoT().Setenv(backendTempDirEnv, GinkgoT().TempDir()) + + runtime, err := newBackendProcessRuntime() + Expect(err).ToNot(HaveOccurred()) + dir := runtime.dir + runtime.cleanup() + + Expect(dir).ToNot(BeAnExistingFile()) + }) + + It("reports which configured root cannot be used", func() { + parent := GinkgoT().TempDir() + file := filepath.Join(parent, "not-a-directory") + Expect(os.WriteFile(file, []byte("x"), 0o600)).To(Succeed()) + base := filepath.Join(file, "backend-runtime") + GinkgoT().Setenv(backendTempDirEnv, base) + root := backendRuntimeRoot() + + runtime, err := newBackendProcessRuntime() + Expect(err).To(HaveOccurred()) + Expect(runtime).To(BeNil()) + Expect(err.Error()).To(ContainSubstring(root)) + }) +}) diff --git a/pkg/model/process_statedir_test.go b/pkg/model/process_statedir_test.go deleted file mode 100644 index 207c2991d..000000000 --- a/pkg/model/process_statedir_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package model - -import ( - "os" - "path/filepath" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Backend process state directory", func() { - It("reports why the state directory could not be created", func() { - // A worker whose volume is full, or whose TMPDIR no longer resolves, - // cannot get a state directory. go-processmanager's New() drops the - // option error, leaving StateDir empty, and Run() then failed with - // "mkdir : no such file or directory" naming no path at all. Resolving - // the directory here keeps the real cause attached. - GinkgoT().Setenv("TMPDIR", filepath.Join(GinkgoT().TempDir(), "does-not-exist")) - - dir, err := newProcessStateDir() - Expect(err).To(HaveOccurred()) - Expect(dir).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("backend process state directory")) - Expect(err.Error()).To(ContainSubstring("does-not-exist"), - "the error must name the directory it could not create") - }) - - It("returns a usable directory when the temp location works", func() { - GinkgoT().Setenv("TMPDIR", GinkgoT().TempDir()) - - dir, err := newProcessStateDir() - Expect(err).ToNot(HaveOccurred()) - Expect(dir).ToNot(BeEmpty()) - info, statErr := os.Stat(dir) - Expect(statErr).ToNot(HaveOccurred()) - Expect(info.IsDir()).To(BeTrue()) - }) -}) From be5342ef05a3e5acd920d5d645e3d8fe23d2edcd Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Wed, 9 Sep 2026 08:51:09 +0200 Subject: [PATCH 06/10] fix(worker): resolve temporary paths in tests (#11944) Capacity guards reject symlink components. On macOS, temporary paths start with /var, which links to /private/var, so the new staging tests fail before exercising cleanup or capacity accounting. Resolve the fixture directories before building guarded paths. Keep explicit symlinks within the fixtures for containment tests. Assisted-by: Codex:gpt-6 Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> --- .../worker/ephemeral_capacity_test.go | 46 +++++++++---------- .../services/worker/ephemeral_cleanup_test.go | 4 +- .../worker/file_staging_release_test.go | 36 +++++++-------- core/services/worker/worker_suite_test.go | 9 ++++ 4 files changed, 52 insertions(+), 43 deletions(-) diff --git a/core/services/worker/ephemeral_capacity_test.go b/core/services/worker/ephemeral_capacity_test.go index a5b4fd1f4..640d17799 100644 --- a/core/services/worker/ephemeral_capacity_test.go +++ b/core/services/worker/ephemeral_capacity_test.go @@ -43,7 +43,7 @@ func (capacityShortWriter) Write(p []byte) (int, error) { var _ = Describe("EphemeralCapacityGuard", func() { It("derives bounded defaults and preserves positive overrides", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() limit, headroom, err := effectiveEphemeralCapacity([]string{root}, 0, -1) Expect(err).NotTo(HaveOccurred()) Expect(limit).To(BeNumerically(">", 0)) @@ -57,8 +57,8 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("accounts existing regular files without following symlinks", func() { - root := GinkgoT().TempDir() - outside := filepath.Join(GinkgoT().TempDir(), "outside.bin") + root := canonicalWorkerTempDir() + outside := filepath.Join(canonicalWorkerTempDir(), "outside.bin") Expect(os.WriteFile(filepath.Join(root, "existing.bin"), make([]byte, 6), 0o600)).To(Succeed()) Expect(os.WriteFile(outside, make([]byte, 100), 0o600)).To(Succeed()) Expect(os.Symlink(outside, filepath.Join(root, "outside-link"))).To(Succeed()) @@ -77,7 +77,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("serializes competing reservations", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 1, 0) Expect(err).NotTo(HaveOccurred()) @@ -109,7 +109,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("makes only an equal active reservation idempotent", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "nested", "payload.bin") @@ -128,7 +128,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("retains committed bytes when the same path starts another reservation", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "payload.bin") @@ -147,7 +147,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("retains startup-accounted bytes when the path is reserved", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() path := filepath.Join(root, "payload.bin") Expect(os.WriteFile(path, make([]byte, 4), 0o600)).To(Succeed()) guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) @@ -163,7 +163,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("commits the regular file's actual size", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "payload.bin") @@ -176,7 +176,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("preserves configured filesystem headroom", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 1<<30, 1<<62) Expect(err).NotTo(HaveOccurred()) @@ -189,7 +189,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("reserves bounded chunks before forwarding unknown-length input", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, ephemeralCapacityWriteChunk+1, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "payload.bin") @@ -210,7 +210,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("waits for an open bounded writer before committing", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "payload.bin") @@ -254,7 +254,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("does not share pending capacity between concurrent writers", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "payload.bin") @@ -290,7 +290,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("rolls back bytes the destination writer does not accept", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 5, 0) Expect(err).NotTo(HaveOccurred()) writer, err := guard.NewWriter(filepath.Join(root, "payload.bin"), capacityShortWriter{}) @@ -304,8 +304,8 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("rejects paths outside roots and through symlinks", func() { - root := GinkgoT().TempDir() - outside := GinkgoT().TempDir() + root := canonicalWorkerTempDir() + outside := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 100, 0) Expect(err).NotTo(HaveOccurred()) @@ -319,7 +319,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("supports recovery tree accounting without dropping active reservations", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) active := filepath.Join(root, "active", "payload.bin") @@ -335,7 +335,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("waits for pre-release reservations before request cleanup scans", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "audio", "request-1", "input.wav") @@ -357,7 +357,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("rejects staging after request cleanup begins", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) Expect(guard.BeginRequestRelease(context.Background(), "request-1")).To(Succeed()) @@ -370,7 +370,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("leaves a late commit recoverable when release times out", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "audio", "request-1", "late.wav") @@ -386,7 +386,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("bounds release markers without reopening registered work", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) Expect(guard.BeginRequestOperation("request-pinned")).To(Succeed()) @@ -411,7 +411,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("applies backpressure at the release-pin cap and clears ownership", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "audio", "request-target", "input.wav") @@ -438,7 +438,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("makes committed files recoverable when pin backpressure expires", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() guard, err := NewEphemeralCapacityGuard([]string{root}, 10, 0) Expect(err).NotTo(HaveOccurred()) path := filepath.Join(root, "audio", "request-target", "input.wav") @@ -459,7 +459,7 @@ var _ = Describe("EphemeralCapacityGuard", func() { }) It("rejects a registered cache-hit claim after pin backpressure expires", func() { - root := GinkgoT().TempDir() + root := canonicalWorkerTempDir() path := filepath.Join(root, "audio", "request-target", "input.wav") Expect(os.MkdirAll(filepath.Dir(path), 0o750)).To(Succeed()) Expect(os.WriteFile(path, []byte("data"), 0o600)).To(Succeed()) diff --git a/core/services/worker/ephemeral_cleanup_test.go b/core/services/worker/ephemeral_cleanup_test.go index c8cb9e150..a8bec3616 100644 --- a/core/services/worker/ephemeral_cleanup_test.go +++ b/core/services/worker/ephemeral_cleanup_test.go @@ -24,7 +24,7 @@ var _ = Describe("Worker ephemeral staging cleanup", func() { return dir } - BeforeEach(func() { stagingDir = GinkgoT().TempDir() }) + BeforeEach(func() { stagingDir = canonicalWorkerTempDir() }) It("removes staged request directories older than the TTL", func() { old := mkEphemeral("aaaa1111", 48*time.Hour) @@ -57,7 +57,7 @@ var _ = Describe("Worker ephemeral staging cleanup", func() { }) It("sweeps both transport roots by newest descendant and skips active requests", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() httpRoot := filepath.Join(stagingDir, "ephemeral") s3Root := filepath.Join(cacheDir, "ephemeral") guard, err := NewEphemeralCapacityGuard([]string{httpRoot, s3Root}, 8, 0) diff --git a/core/services/worker/file_staging_release_test.go b/core/services/worker/file_staging_release_test.go index 62dfcd3cb..1019d475d 100644 --- a/core/services/worker/file_staging_release_test.go +++ b/core/services/worker/file_staging_release_test.go @@ -87,7 +87,7 @@ func (m *releaseMessagingClient) Close() {} var _ = Describe("Worker exact-key staging release", func() { It("protects a startup-accounted HTTP cache hit through authenticated repeated probes", func() { - stagingDir := GinkgoT().TempDir() + stagingDir := canonicalWorkerTempDir() root := filepath.Join(stagingDir, "ephemeral") key := "ephemeral/audio/request-id/input.wav" remotePath := filepath.Join(stagingDir, filepath.FromSlash(key)) @@ -107,11 +107,11 @@ var _ = Describe("Worker exact-key staging release", func() { Expect(err).NotTo(HaveOccurred()) addr := listener.Addr().String() Expect(listener.Close()).To(Succeed()) - server, err := nodes.StartFileTransferServerWithCapacity(addr, stagingDir, GinkgoT().TempDir(), GinkgoT().TempDir(), "secret", 0, nil, guard) + server, err := nodes.StartFileTransferServerWithCapacity(addr, stagingDir, canonicalWorkerTempDir(), canonicalWorkerTempDir(), "secret", 0, nil, guard) Expect(err).NotTo(HaveOccurred()) DeferCleanup(nodes.ShutdownFileTransferServer, server) - localPath := filepath.Join(GinkgoT().TempDir(), "input.wav") + localPath := filepath.Join(canonicalWorkerTempDir(), "input.wav") Expect(os.WriteFile(localPath, content, 0o600)).To(Succeed()) stager := nodes.NewHTTPFileStager(func(string) (string, error) { return addr, nil }, "secret") for range 2 { @@ -129,7 +129,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("claims a startup-scanned cache hit against stale recovery until release", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() root := filepath.Join(cacheDir, "ephemeral") key := "ephemeral/audio/request-id/input.wav" cachePath := filepath.Join(cacheDir, filepath.FromSlash(key)) @@ -158,7 +158,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("downloads again when a cache file disappears while being claimed", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() key := "ephemeral/audio/request-id/input.wav" cachePath := filepath.Join(cacheDir, filepath.FromSlash(key)) Expect(os.MkdirAll(filepath.Dir(cachePath), 0o750)).To(Succeed()) @@ -176,7 +176,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("makes repeated cache-hit claims idempotent", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() root := filepath.Join(cacheDir, "ephemeral") key := "ephemeral/audio/request-id/input.wav" cachePath := filepath.Join(cacheDir, filepath.FromSlash(key)) @@ -199,7 +199,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("capacity-checks growth of a startup-scanned cache file", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() root := filepath.Join(cacheDir, "ephemeral") key := "ephemeral/audio/request-id/input.wav" cachePath := filepath.Join(cacheDir, filepath.FromSlash(key)) @@ -220,7 +220,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("reserves S3 object size before download and releases it with the exact key", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() root := filepath.Join(cacheDir, "ephemeral") store := &stagingObjectStore{payload: []byte("data")} fm, err := storage.NewFileManager(store, cacheDir) @@ -240,7 +240,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("rejects an oversized S3 object before starting its download", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() store := &stagingObjectStore{payload: []byte("oversized")} fm, err := storage.NewFileManager(store, cacheDir) Expect(err).NotTo(HaveOccurred()) @@ -253,7 +253,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("rolls back an S3 reservation when the download fails", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() root := filepath.Join(cacheDir, "ephemeral") store := &stagingObjectStore{payload: []byte("data"), getErr: errors.New("download failed")} fm, err := storage.NewFileManager(store, cacheDir) @@ -267,7 +267,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("removes only the exact cache file and upload sidecars", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() categoryDir := filepath.Join(cacheDir, "ephemeral", "request-id", "audio") Expect(os.MkdirAll(categoryDir, 0750)).To(Succeed()) target := filepath.Join(categoryDir, "input.wav") @@ -285,7 +285,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("succeeds for a missing file and prunes empty category and request directories", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() categoryDir := filepath.Join(cacheDir, "ephemeral", "request-id", "audio") Expect(os.MkdirAll(categoryDir, 0750)).To(Succeed()) @@ -298,8 +298,8 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("rejects traversal and symlink escapes", func() { - cacheDir := GinkgoT().TempDir() - outsideDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() + outsideDir := canonicalWorkerTempDir() outsidePath := filepath.Join(outsideDir, "input.wav") Expect(os.WriteFile(outsidePath, []byte("keep"), 0640)).To(Succeed()) requestDir := filepath.Join(cacheDir, "ephemeral", "request-id") @@ -319,7 +319,7 @@ var _ = Describe("Worker exact-key staging release", func() { It("rejects symlinked files and sidecars without deleting their targets", func() { for _, linkedName := range []string{"input.wav", "input.wav.sha256", "input.wav.sha256.target"} { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() categoryDir := filepath.Join(cacheDir, "ephemeral", "request-id", "audio") Expect(os.MkdirAll(categoryDir, 0750)).To(Succeed()) target := filepath.Join(categoryDir, "input.wav") @@ -336,7 +336,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("registers an exact release handler", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() path := filepath.Join(cacheDir, "ephemeral", "request-id", "audio", "input.wav") Expect(os.MkdirAll(filepath.Dir(path), 0750)).To(Succeed()) Expect(os.WriteFile(path, []byte("data"), 0640)).To(Succeed()) @@ -358,7 +358,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("releases a request batch through one worker message", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() keys := []string{ "ephemeral/audio/request-id/input.wav", "ephemeral/images/request-id/frame.jpg", @@ -387,7 +387,7 @@ var _ = Describe("Worker exact-key staging release", func() { }) It("returns validation errors through the release handler", func() { - cacheDir := GinkgoT().TempDir() + cacheDir := canonicalWorkerTempDir() fm, err := storage.NewFileManager(nil, cacheDir) Expect(err).NotTo(HaveOccurred()) client := &releaseMessagingClient{} diff --git a/core/services/worker/worker_suite_test.go b/core/services/worker/worker_suite_test.go index 64186d88f..e55a32f5c 100644 --- a/core/services/worker/worker_suite_test.go +++ b/core/services/worker/worker_suite_test.go @@ -1,6 +1,7 @@ package worker import ( + "path/filepath" "testing" . "github.com/onsi/ginkgo/v2" @@ -11,3 +12,11 @@ func TestWorker(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Worker Suite") } + +// Capacity guards reject symlink components, including macOS /var -> /private/var. +func canonicalWorkerTempDir() string { + GinkgoHelper() + dir, err := filepath.EvalSymlinks(GinkgoT().TempDir()) + Expect(err).NotTo(HaveOccurred()) + return dir +} From 36cbe294b2abd1a5cf4334b30cc75d9249007ea6 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Wed, 9 Sep 2026 08:51:23 +0200 Subject: [PATCH 07/10] chore: :arrow_up: Update ggml-org/whisper.cpp to `c44b60b8053bbf2a5c1e014f11323fb3f2485177` (#11937) :arrow_up: Update ggml-org/whisper.cpp Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/go/whisper/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/go/whisper/Makefile b/backend/go/whisper/Makefile index bd789a915..49c47b8ae 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?=52a939a2a762224e255d366c1182b2af4dd1a032 +WHISPER_CPP_VERSION?=c44b60b8053bbf2a5c1e014f11323fb3f2485177 SO_TARGET?=libgowhisper.so CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF From afb9bfd183ce2dce6a3e287b6144fb76e242efc6 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Wed, 9 Sep 2026 08:51:36 +0200 Subject: [PATCH 08/10] chore: :arrow_up: Update antirez/ds4 to `6289c516273979173abbc062209a81dd3706b804` (#11936) :arrow_up: Update antirez/ds4 Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/cpp/ds4/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/cpp/ds4/Makefile b/backend/cpp/ds4/Makefile index c2e69c82a..39ab3c760 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?=f62ca29a308724cde5bc99134ede19104b2a3260 +# Upstream pin lives below as DS4_VERSION?=6289c516273979173abbc062209a81dd3706b804 # (.github/bump_deps.sh) can find and update it - matches the # llama-cpp / ik-llama-cpp / turboquant convention. -DS4_VERSION?=f62ca29a308724cde5bc99134ede19104b2a3260 +DS4_VERSION?=6289c516273979173abbc062209a81dd3706b804 DS4_REPO?=https://github.com/antirez/ds4 CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) From 89dcdea0a0723d91397f5618a41e5511c0b3831a Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Wed, 9 Sep 2026 08:52:12 +0200 Subject: [PATCH 09/10] chore: :arrow_up: Update NVIDIA/NeMo-Speech.cpp to `a5b6953c4a579a2bbd1c0913ad8a85c2a4d99953` (#11935) :arrow_up: Update NVIDIA/NeMo-Speech.cpp Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/go/nemo-speech-cpp/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/go/nemo-speech-cpp/Makefile b/backend/go/nemo-speech-cpp/Makefile index 1496b4244..cc6d24242 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?=ffa38cb2408f1e832a36d46fef5e3e1e80d07e6c +NEMO_SPEECH_VERSION?=a5b6953c4a579a2bbd1c0913ad8a85c2a4d99953 NEMO_SPEECH_REPO?=https://github.com/NVIDIA/NeMo-Speech.cpp GOCMD?=go From 14b1796cdda17d1aeb39202765d318427d82f6da Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Wed, 9 Sep 2026 08:52:33 +0200 Subject: [PATCH 10/10] chore: :arrow_up: Update 0xShug0/audio.cpp to `05e508a70e3600b01454c647cdb122133ba8e64c` (#11933) :arrow_up: Update 0xShug0/audio.cpp Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/cpp/audio-cpp/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cpp/audio-cpp/Makefile b/backend/cpp/audio-cpp/Makefile index aa33c0bb7..27b80ed89 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?=9c6a282337cc83f227cc10428867a478947706ad +AUDIO_CPP_VERSION?=05e508a70e3600b01454c647cdb122133ba8e64c AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))