mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-10 13:08:55 -04:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dec5cdf19b | ||
|
|
bf405c003d | ||
|
|
d60aaa171d | ||
|
|
1816013ebd | ||
|
|
428898373a | ||
|
|
de563f17b5 | ||
|
|
e78271563b | ||
|
|
109244a76a | ||
|
|
752ee66506 | ||
|
|
f12bcfac9a | ||
|
|
dc353aecb6 |
No files matched your search
@@ -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?=05e508a70e3600b01454c647cdb122133ba8e64c
|
||||
AUDIO_CPP_VERSION?=fa5aaac9266a98c68f8a5c9fcd1ba6ff65875416
|
||||
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=fe215a8ccdce6b844d2a3a3bbde08ae76a6284bf
|
||||
IK_LLAMA_VERSION?=3e416d7f5a9d4cc3195e8171dbf891541ca59c6a
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=67672dc5b76f8bc17785a19d3dc6d1463fc2902c
|
||||
LLAMA_VERSION?=434ddbbc0e30522e897670681e503b797c12b7c1
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# on a cu130 host. Pull the cu130-flavoured wheel from vLLM's per-tag index
|
||||
# instead — the cublas13 case in install.sh adds --index-strategy=unsafe-best-match
|
||||
# so uv consults this index alongside PyPI.
|
||||
--extra-index-url https://wheels.vllm.ai/0.28.0/cu130
|
||||
--extra-index-url https://wheels.vllm.ai/0.29.0/cu130
|
||||
# VERSION COUPLING: darwin/Apple-Silicon builds use vllm-metal (see install.sh),
|
||||
# which pins this exact vLLM version. Bumping vllm here means coordinating with a
|
||||
# vllm-metal release that supports the new version, or macOS/Metal builds break.
|
||||
vllm==0.28.0
|
||||
vllm==0.29.0
|
||||
@@ -99,3 +99,12 @@ var DiffusersSchedulerOptions = []FieldOption{
|
||||
{Value: "heun", Label: "Heun"},
|
||||
{Value: "unipc", Label: "UniPC"},
|
||||
}
|
||||
|
||||
// SystemMessagesAfterFirstOptions are the values of template.system_messages_after_first:
|
||||
// how system messages that appear after the first turn are handled before the chat
|
||||
// template runs (empty = pass through unchanged, which strict Jinja templates reject).
|
||||
var SystemMessagesAfterFirstOptions = []FieldOption{
|
||||
{Value: "", Label: "Pass through (default)"},
|
||||
{Value: "merge", Label: "Merge into the first system message"},
|
||||
{Value: "user", Label: "Forward as user messages"},
|
||||
}
|
||||
@@ -382,6 +382,14 @@ func DefaultRegistry() map[string]FieldMetaOverride {
|
||||
Description: "Use the chat template from the model's tokenizer config",
|
||||
Order: 44,
|
||||
},
|
||||
"template.system_messages_after_first": {
|
||||
Section: "templates",
|
||||
Label: "System Messages After First",
|
||||
Description: "How system messages that appear after the first turn are handled before templating: merge into the first system message, or forward as user messages. Empty passes them through unchanged, which strict Jinja templates reject.",
|
||||
Component: "select",
|
||||
Options: SystemMessagesAfterFirstOptions,
|
||||
Order: 45,
|
||||
},
|
||||
// Router section template — kept in the templates UI section
|
||||
// (rather than the router section under "other") so operators
|
||||
// editing prompt shapes find all template-typed fields in one
|
||||
|
||||
@@ -1351,6 +1351,16 @@ type TemplateConfig struct {
|
||||
// that can use the tokenizers specified in the JSON config files of the models
|
||||
UseTokenizerTemplate bool `yaml:"use_tokenizer_template,omitempty" json:"use_tokenizer_template,omitempty"`
|
||||
|
||||
// SystemMessagesAfterFirst controls what happens to system-role messages that
|
||||
// appear after the leading system block. Some tokenizer chat templates (e.g.
|
||||
// Qwen3.8 / Flash-Next) raise "System message must be at the beginning" for
|
||||
// them, while agent frameworks (cogito tool selection, adjustment prompts)
|
||||
// legitimately append system instructions mid-conversation.
|
||||
// ""/"error": pass through unchanged (template decides)
|
||||
// "merge": fold them into the leading system message
|
||||
// "user": forward them as user-role instructions (keeps their position)
|
||||
SystemMessagesAfterFirst string `yaml:"system_messages_after_first,omitempty" json:"system_messages_after_first,omitempty"`
|
||||
|
||||
// JoinChatMessagesByCharacter is a string that will be used to join chat messages together.
|
||||
// It defaults to \n
|
||||
JoinChatMessagesByCharacter *string `yaml:"join_chat_messages_by_character,omitempty" json:"join_chat_messages_by_character,omitempty"`
|
||||
|
||||
@@ -66,6 +66,65 @@ func stripEmptySystemMessages(messages []schema.Message) []schema.Message {
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeLateSystemMessages handles system-role messages that appear after the
|
||||
// leading system block, according to template.system_messages_after_first:
|
||||
// "merge" folds them into the first system message (created if absent), "user"
|
||||
// forwards them as user-role turns at their original position. Any other value
|
||||
// returns the messages unchanged. Needed for tokenizer templates that reject
|
||||
// late system turns (Qwen3.8: "System message must be at the beginning") while
|
||||
// agent frameworks append instructions mid-conversation.
|
||||
func normalizeLateSystemMessages(messages []schema.Message, mode string) []schema.Message {
|
||||
if mode != "merge" && mode != "user" {
|
||||
return messages
|
||||
}
|
||||
lead := 0
|
||||
for lead < len(messages) && messages[lead].Role == "system" {
|
||||
lead++
|
||||
}
|
||||
late := false
|
||||
for _, m := range messages[lead:] {
|
||||
if m.Role == "system" {
|
||||
late = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !late {
|
||||
return messages
|
||||
}
|
||||
out := make([]schema.Message, 0, len(messages)+1)
|
||||
out = append(out, messages[:lead]...)
|
||||
if mode == "merge" && lead == 0 {
|
||||
out = append(out, schema.Message{Role: "system"})
|
||||
}
|
||||
for _, m := range messages[lead:] {
|
||||
if m.Role != "system" {
|
||||
out = append(out, m)
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(messageText(m))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
switch mode {
|
||||
case "merge":
|
||||
first := &out[0]
|
||||
joined := strings.TrimSpace(messageText(*first))
|
||||
if joined != "" {
|
||||
joined += "\n\n"
|
||||
}
|
||||
joined += text
|
||||
first.Content = joined
|
||||
first.StringContent = joined
|
||||
case "user":
|
||||
m.Role = "user"
|
||||
m.Content = text
|
||||
m.StringContent = text
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeToolCallDeltas merges streaming tool call deltas into complete tool calls.
|
||||
// In SSE streaming, a single tool call arrives as multiple chunks sharing the same Index:
|
||||
// the first chunk carries the ID, Type, and Name; subsequent chunks append to Arguments.
|
||||
@@ -182,6 +241,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
// Drop blank system turns from the web UI (and similar clients) so they
|
||||
// cannot suppress the model YAML system_prompt / tokenizer defaults.
|
||||
input.Messages = stripEmptySystemMessages(input.Messages)
|
||||
input.Messages = normalizeLateSystemMessages(input.Messages, config.TemplateConfig.SystemMessagesAfterFirst)
|
||||
|
||||
// Tokenizer-template models pass messages through to the backend as-is,
|
||||
// so apply the configured system_prompt when the request did not supply
|
||||
|
||||
@@ -378,6 +378,50 @@ var _ = Describe("system message helpers", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("normalizeLateSystemMessages", func() {
|
||||
msgs := func() []schema.Message {
|
||||
return []schema.Message{
|
||||
{Role: "system", Content: "lead", StringContent: "lead"},
|
||||
{Role: "user", Content: "q", StringContent: "q"},
|
||||
{Role: "assistant", Content: "a", StringContent: "a"},
|
||||
{Role: "system", Content: "late", StringContent: "late"},
|
||||
{Role: "user", Content: "q2", StringContent: "q2"},
|
||||
}
|
||||
}
|
||||
It("leaves messages untouched by default", func() {
|
||||
out := normalizeLateSystemMessages(msgs(), "")
|
||||
Expect(out).To(HaveLen(5))
|
||||
Expect(out[3].Role).To(Equal("system"))
|
||||
})
|
||||
It("merge folds late system turns into the leading one", func() {
|
||||
out := normalizeLateSystemMessages(msgs(), "merge")
|
||||
Expect(out).To(HaveLen(4))
|
||||
Expect(out[0].Role).To(Equal("system"))
|
||||
Expect(out[0].StringContent).To(Equal("lead\n\nlate"))
|
||||
for _, m := range out[1:] {
|
||||
Expect(m.Role).NotTo(Equal("system"))
|
||||
}
|
||||
})
|
||||
It("merge creates a leading system message when none exists", func() {
|
||||
in := msgs()[1:]
|
||||
out := normalizeLateSystemMessages(in, "merge")
|
||||
Expect(out[0].Role).To(Equal("system"))
|
||||
Expect(out[0].StringContent).To(Equal("late"))
|
||||
Expect(out).To(HaveLen(4))
|
||||
})
|
||||
It("user forwards late system turns as user turns in place", func() {
|
||||
out := normalizeLateSystemMessages(msgs(), "user")
|
||||
Expect(out).To(HaveLen(5))
|
||||
Expect(out[3].Role).To(Equal("user"))
|
||||
Expect(out[3].StringContent).To(Equal("late"))
|
||||
Expect(out[0].Role).To(Equal("system"))
|
||||
})
|
||||
It("does nothing when no late system turn exists", func() {
|
||||
in := msgs()[:3]
|
||||
Expect(normalizeLateSystemMessages(in, "user")).To(HaveLen(3))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("stripEmptySystemMessages", func() {
|
||||
It("removes blank system turns and keeps the rest", func() {
|
||||
in := []schema.Message{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
|
||||
"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"
|
||||
laudio "github.com/mudler/LocalAI/pkg/audio"
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
@@ -136,6 +138,8 @@ type Session struct {
|
||||
Instructions string
|
||||
DefaultConversationID string
|
||||
ModelInterface Model
|
||||
ttsParams map[string]string
|
||||
voiceRelease func()
|
||||
// The pipeline model config or the config for an any-to-any model
|
||||
ModelConfig *config.ModelConfig
|
||||
InputSampleRate int
|
||||
@@ -199,6 +203,22 @@ type Session struct {
|
||||
respSink *responseSink
|
||||
}
|
||||
|
||||
func (s *Session) installVoiceBinding(voice string, params map[string]string, release func()) {
|
||||
if release == nil {
|
||||
release = func() {}
|
||||
}
|
||||
var once sync.Once
|
||||
s.Voice = voice
|
||||
s.ttsParams = maps.Clone(params)
|
||||
s.voiceRelease = func() { once.Do(release) }
|
||||
}
|
||||
|
||||
func (s *Session) releaseVoiceBinding() {
|
||||
if s.voiceRelease != nil {
|
||||
s.voiceRelease()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) FromClient(session *types.SessionUnion) {
|
||||
}
|
||||
|
||||
@@ -635,15 +655,15 @@ func runRealtimeSession(application *application.Application, t Transport, model
|
||||
return
|
||||
}
|
||||
if wrapped, ok := m.(*wrappedModel); ok {
|
||||
resolvedVoice, params, release, resolveErr := resolveRealtimeVoice(context.Background(), session.Voice, wrapped.TTSConfig, application.VoiceProfileStore())
|
||||
resolvedVoice, params, release, resolveErr := resolveRealtimeVoice(context.Background(), wrapped.TTSConfig.TTSConfig.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.installVoiceBinding(resolvedVoice, params, release)
|
||||
defer session.releaseVoiceBinding()
|
||||
wrapped.setTTSParams(params)
|
||||
}
|
||||
session.ModelInterface = m
|
||||
// A pipeline-seeded option list gets its scoring prompt prewarmed
|
||||
@@ -838,6 +858,7 @@ func runRealtimeSession(application *application.Application, t Transport, model
|
||||
application.ApplicationConfig(),
|
||||
evaluator,
|
||||
buildRealtimeRoutingContext(application, session.ID),
|
||||
application.VoiceProfileStore(),
|
||||
); err != nil {
|
||||
xlog.Error("failed to update session", "error", err)
|
||||
sendError(t, "session_update_error", fmt.Sprintf("Failed to update session: %v", err), "", "")
|
||||
@@ -1176,7 +1197,7 @@ func updateTransSession(session *Session, update *types.SessionUnion, cl *config
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateSession(session *Session, update *types.SessionUnion, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, evaluator *templates.Evaluator, routing *RealtimeRoutingContext) error {
|
||||
func updateSession(session *Session, update *types.SessionUnion, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, evaluator *templates.Evaluator, routing *RealtimeRoutingContext, profiles *voiceprofile.Store) error {
|
||||
sessionLock.Lock()
|
||||
defer sessionLock.Unlock()
|
||||
|
||||
@@ -1184,8 +1205,13 @@ func updateSession(session *Session, update *types.SessionUnion, cl *config.Mode
|
||||
return nil
|
||||
}
|
||||
|
||||
session.TranscriptionOnly = false
|
||||
rt := update.Realtime
|
||||
explicitVoice := rt.Audio != nil && rt.Audio.Output != nil && rt.Audio.Output.Voice != ""
|
||||
rebuild := rt.Model != "" || explicitVoice || (rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.Transcription != nil)
|
||||
|
||||
candidateModelName := session.Model
|
||||
candidateConfig := session.ModelConfig
|
||||
candidateTranscription := session.InputAudioTranscription
|
||||
|
||||
if rt.Model != "" {
|
||||
cfg, err := cl.LoadModelConfigFileByNameDefaultOptions(rt.Model, appConfig)
|
||||
@@ -1196,40 +1222,78 @@ func updateSession(session *Session, update *types.SessionUnion, cl *config.Mode
|
||||
return fmt.Errorf("model is not a valid pipeline model: %s", rt.Model)
|
||||
}
|
||||
|
||||
if session.InputAudioTranscription == nil {
|
||||
session.InputAudioTranscription = &types.AudioTranscription{}
|
||||
}
|
||||
session.InputAudioTranscription.Model = cfg.Pipeline.Transcription
|
||||
session.Voice = cfg.TTSConfig.Voice
|
||||
session.Model = rt.Model
|
||||
session.ModelConfig = cfg
|
||||
}
|
||||
|
||||
if rt.Audio != nil && rt.Audio.Output != nil && rt.Audio.Output.Voice != "" {
|
||||
session.Voice = string(rt.Audio.Output.Voice)
|
||||
candidateModelName = rt.Model
|
||||
candidateConfig = cfg
|
||||
candidateTranscription = &types.AudioTranscription{Model: cfg.Pipeline.Transcription}
|
||||
}
|
||||
|
||||
if rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.Transcription != nil {
|
||||
trUpd := rt.Audio.Input.Transcription
|
||||
trUpd := *rt.Audio.Input.Transcription
|
||||
// A language-only update (e.g. a client forcing the STT language) carries
|
||||
// an empty Model. Preserve the pipeline's configured transcription backend
|
||||
// instead of blanking it — otherwise the next utterance transcribes against
|
||||
// an empty model and the backend RPC fails with "unimplemented".
|
||||
if trUpd.Model == "" && session.InputAudioTranscription != nil {
|
||||
trUpd.Model = session.InputAudioTranscription.Model
|
||||
if trUpd.Model == "" && candidateTranscription != nil {
|
||||
trUpd.Model = candidateTranscription.Model
|
||||
}
|
||||
session.InputAudioTranscription = trUpd
|
||||
candidateTranscription = &trUpd
|
||||
if trUpd.Model != "" {
|
||||
session.ModelConfig.Pipeline.Transcription = trUpd.Model
|
||||
cfgCopy := *candidateConfig
|
||||
candidateConfig = &cfgCopy
|
||||
candidateConfig.Pipeline.Transcription = trUpd.Model
|
||||
}
|
||||
}
|
||||
|
||||
if rt.Model != "" || (rt.Audio != nil && rt.Audio.Output != nil && rt.Audio.Output.Voice != "") || (rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.Transcription != nil) {
|
||||
m, err := newModel(&session.ModelConfig.Pipeline, cl, ml, appConfig, evaluator, routing)
|
||||
candidateModel := session.ModelInterface
|
||||
candidateVoice := session.Voice
|
||||
candidateParams := maps.Clone(session.ttsParams)
|
||||
var candidateRelease func()
|
||||
selectVoice := rt.Model != "" || explicitVoice
|
||||
if rebuild {
|
||||
m, err := newModel(&candidateConfig.Pipeline, cl, ml, appConfig, evaluator, routing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.ModelInterface = m
|
||||
candidateModel = m
|
||||
wrapped := m.(*wrappedModel)
|
||||
if selectVoice {
|
||||
configuredVoice := wrapped.TTSConfig.TTSConfig.Voice
|
||||
if explicitVoice {
|
||||
configuredVoice = string(rt.Audio.Output.Voice)
|
||||
}
|
||||
candidateVoice, candidateParams, candidateRelease, err = resolveRealtimeVoice(
|
||||
context.Background(), configuredVoice, wrapped.TTSConfig, profiles,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
wrapped.setTTSParams(candidateParams)
|
||||
}
|
||||
|
||||
if rt.LocalAIClassifier != nil {
|
||||
if err := validateClassifierActivation(candidateModel, rt.LocalAIClassifier); err != nil {
|
||||
if candidateRelease != nil {
|
||||
candidateRelease()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
oldRelease := session.voiceRelease
|
||||
session.TranscriptionOnly = false
|
||||
session.Model = candidateModelName
|
||||
session.ModelConfig = candidateConfig
|
||||
session.ModelInterface = candidateModel
|
||||
session.InputAudioTranscription = candidateTranscription
|
||||
if selectVoice {
|
||||
session.installVoiceBinding(candidateVoice, candidateParams, candidateRelease)
|
||||
if oldRelease != nil {
|
||||
oldRelease()
|
||||
}
|
||||
}
|
||||
|
||||
if rebuild {
|
||||
// A session.update that swaps the model/voice rebuilds the pipeline, so
|
||||
// warm the new backends too (unless opted out) — otherwise the next turn
|
||||
// pays the cold-start load the original session warm-up already avoided.
|
||||
@@ -1238,9 +1302,9 @@ func updateSession(session *Session, update *types.SessionUnion, cl *config.Mode
|
||||
// stall every other session. Load errors are logged (and still surface on
|
||||
// first use); per-stage failures are already warned inside
|
||||
// backend.PreloadStages.
|
||||
if !session.ModelConfig.Pipeline.DisableWarmup {
|
||||
if !candidateConfig.Pipeline.DisableWarmup {
|
||||
go func() {
|
||||
if err := m.Warmup(context.Background()); err != nil {
|
||||
if err := candidateModel.Warmup(context.Background()); err != nil {
|
||||
xlog.Error("realtime warmup failed after session.update", "error", err)
|
||||
}
|
||||
}()
|
||||
@@ -1299,9 +1363,6 @@ func updateSession(session *Session, update *types.SessionUnion, cl *config.Mode
|
||||
// Replace-not-merge, like tools: the client owns the whole option
|
||||
// list. Invalid configs reject the update without touching the
|
||||
// session's current classifier.
|
||||
if err := validateClassifierActivation(session.ModelInterface, rt.LocalAIClassifier); err != nil {
|
||||
return err
|
||||
}
|
||||
session.Classifier = rt.LocalAIClassifier
|
||||
prewarmClassifier(session)
|
||||
}
|
||||
|
||||
@@ -398,6 +398,10 @@ func (m *wrappedModel) TTS(ctx context.Context, text, voice, language string) (s
|
||||
return backend.ModelTTS(ctx, text, voice, language, "", maps.Clone(m.ttsParams), m.modelLoader, m.appConfig, *m.TTSConfig)
|
||||
}
|
||||
|
||||
func (m *wrappedModel) setTTSParams(params map[string]string) {
|
||||
m.ttsParams = maps.Clone(params)
|
||||
}
|
||||
|
||||
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, maps.Clone(m.ttsParams), onAudio)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
|
||||
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
|
||||
"github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
@@ -177,4 +181,174 @@ var _ = Describe("wrappedModel voice profile parameters", func() {
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("realtime session voice switching", func() {
|
||||
type fixture struct {
|
||||
store *voiceprofile.Store
|
||||
voiceDir string
|
||||
loader *config.ModelConfigLoader
|
||||
models *model.ModelLoader
|
||||
appConfig *config.ApplicationConfig
|
||||
profileA voiceprofile.Profile
|
||||
profileB voiceprofile.Profile
|
||||
}
|
||||
|
||||
newFixture := func(ctx SpecContext) *fixture {
|
||||
modelDir := GinkgoT().TempDir()
|
||||
voiceDir := GinkgoT().TempDir()
|
||||
store := voiceprofile.NewStore(voiceDir)
|
||||
DeferCleanup(func() { Expect(store.Close()).To(Succeed()) })
|
||||
profileA, err := store.Create(ctx, voiceprofile.CreateInput{
|
||||
Name: "Alpha", Language: "en", Transcript: "Alpha transcript", ConsentConfirmed: true,
|
||||
}, bytes.NewReader(realtimeProfileWAV(time.Second)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
profileB, err := store.Create(ctx, voiceprofile.CreateInput{
|
||||
Name: "Beta", Language: "it", Transcript: "Beta transcript", ConsentConfirmed: true,
|
||||
}, bytes.NewReader(realtimeProfileWAV(time.Second)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
configs := map[string]string{
|
||||
"vad": "name: vad\nbackend: test\nparameters:\n model: vad.bin\n",
|
||||
"stt": "name: stt\nbackend: test\nparameters:\n model: stt.bin\n",
|
||||
"llm": "name: llm\nbackend: test\nparameters:\n model: llm.bin\n",
|
||||
"tts-a": fmt.Sprintf("name: tts-a\nbackend: qwen3-tts-cpp\nparameters:\n model: tts-a.bin\ntts:\n voice: %s\n voice_cloning: true\n", profileA.Voice),
|
||||
"tts-b": fmt.Sprintf("name: tts-b\nbackend: qwen3-tts-cpp\nparameters:\n model: tts-b.bin\ntts:\n voice: %s\n voice_cloning: true\n", profileB.Voice),
|
||||
"pipe-a": "name: pipe-a\npipeline:\n vad: vad\n transcription: stt\n llm: llm\n tts: tts-a\n disable_warmup: true\n",
|
||||
"pipe-b": "name: pipe-b\npipeline:\n vad: vad\n transcription: stt\n llm: llm\n tts: tts-b\n disable_warmup: true\n",
|
||||
}
|
||||
for name, body := range configs {
|
||||
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
|
||||
}
|
||||
loader := config.NewModelConfigLoader(modelDir)
|
||||
Expect(loader.LoadModelConfigsFromPath(modelDir)).To(Succeed())
|
||||
state, err := system.GetSystemState(system.WithModelPath(modelDir))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return &fixture{
|
||||
store: store, voiceDir: voiceDir, loader: loader, models: model.NewModelLoader(state),
|
||||
appConfig: config.NewApplicationConfig(config.WithSystemState(state)),
|
||||
profileA: profileA, profileB: profileB,
|
||||
}
|
||||
}
|
||||
|
||||
newSession := func(f *fixture, voice string) *Session {
|
||||
cfg, err := f.loader.LoadModelConfigFileByNameDefaultOptions("pipe-a", f.appConfig)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
m, err := newModel(&cfg.Pipeline, f.loader, f.models, f.appConfig, nil, nil)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
session := &Session{
|
||||
Model: "pipe-a", Voice: voice, ModelConfig: cfg, ModelInterface: m,
|
||||
InputAudioTranscription: &types.AudioTranscription{Model: "stt"},
|
||||
}
|
||||
resolved, params, release, err := resolveRealtimeVoice(context.Background(), voice, m.(*wrappedModel).TTSConfig, f.store)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
session.installVoiceBinding(resolved, params, release)
|
||||
return session
|
||||
}
|
||||
|
||||
update := func(f *fixture, session *Session, rt *types.RealtimeSession) error {
|
||||
return updateSession(session, &types.SessionUnion{Realtime: rt}, f.loader, f.models, f.appConfig, nil, nil, f.store)
|
||||
}
|
||||
|
||||
It("switches ordinary voices to profiles and clears the lease at final cleanup", func(ctx SpecContext) {
|
||||
f := newFixture(ctx)
|
||||
session := newSession(f, "speaker-1")
|
||||
Expect(update(f, session, &types.RealtimeSession{Audio: &types.RealtimeSessionAudio{Output: &types.SessionAudioOutput{Voice: types.Voice(f.profileA.Voice)}}})).To(Succeed())
|
||||
|
||||
Expect(session.Voice).To(BeAnExistingFile())
|
||||
Expect(session.ttsParams).To(Equal(map[string]string{"ref_text": "Alpha transcript"}))
|
||||
leased := session.Voice
|
||||
session.releaseVoiceBinding()
|
||||
session.releaseVoiceBinding()
|
||||
Expect(leased).NotTo(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("replaces one profile lease with another and then an ordinary voice", func(ctx SpecContext) {
|
||||
f := newFixture(ctx)
|
||||
session := newSession(f, f.profileA.Voice)
|
||||
firstLease := session.Voice
|
||||
|
||||
Expect(update(f, session, &types.RealtimeSession{Audio: &types.RealtimeSessionAudio{Output: &types.SessionAudioOutput{Voice: types.Voice(f.profileB.Voice)}}})).To(Succeed())
|
||||
Expect(firstLease).NotTo(BeAnExistingFile())
|
||||
secondLease := session.Voice
|
||||
Expect(secondLease).To(BeAnExistingFile())
|
||||
Expect(session.ttsParams).To(HaveKeyWithValue("ref_text", "Beta transcript"))
|
||||
|
||||
Expect(update(f, session, &types.RealtimeSession{Audio: &types.RealtimeSessionAudio{Output: &types.SessionAudioOutput{Voice: "speaker-2"}}})).To(Succeed())
|
||||
Expect(secondLease).NotTo(BeAnExistingFile())
|
||||
Expect(session.Voice).To(Equal("speaker-2"))
|
||||
Expect(session.ttsParams).To(BeNil())
|
||||
Expect(session.ModelInterface.(*wrappedModel).ttsParams).To(BeNil())
|
||||
})
|
||||
|
||||
It("uses a new model default profile unless an explicit voice takes precedence", func(ctx SpecContext) {
|
||||
f := newFixture(ctx)
|
||||
session := newSession(f, "speaker-1")
|
||||
Expect(update(f, session, &types.RealtimeSession{Model: "pipe-b"})).To(Succeed())
|
||||
Expect(session.ttsParams).To(HaveKeyWithValue("ref_text", "Beta transcript"))
|
||||
defaultLease := session.Voice
|
||||
|
||||
Expect(update(f, session, &types.RealtimeSession{
|
||||
Model: "pipe-a",
|
||||
Audio: &types.RealtimeSessionAudio{Output: &types.SessionAudioOutput{Voice: "speaker-explicit"}},
|
||||
})).To(Succeed())
|
||||
Expect(defaultLease).NotTo(BeAnExistingFile())
|
||||
Expect(session.Voice).To(Equal("speaker-explicit"))
|
||||
Expect(session.ttsParams).To(BeNil())
|
||||
})
|
||||
|
||||
It("preserves a profile binding across a language-only rebuild", func(ctx SpecContext) {
|
||||
f := newFixture(ctx)
|
||||
session := newSession(f, f.profileA.Voice)
|
||||
lease := session.Voice
|
||||
Expect(update(f, session, &types.RealtimeSession{Audio: &types.RealtimeSessionAudio{Input: &types.SessionAudioInput{
|
||||
Transcription: &types.AudioTranscription{Language: "fr"},
|
||||
}}})).To(Succeed())
|
||||
|
||||
Expect(session.Voice).To(Equal(lease))
|
||||
Expect(session.InputAudioTranscription.Model).To(Equal("stt"))
|
||||
Expect(session.InputAudioTranscription.Language).To(Equal("fr"))
|
||||
wrapped := session.ModelInterface.(*wrappedModel)
|
||||
Expect(wrapped.ttsParams).To(Equal(map[string]string{"ref_text": "Alpha transcript"}))
|
||||
wrapped.ttsParams["ref_text"] = "wrapper mutation"
|
||||
Expect(session.ttsParams).To(HaveKeyWithValue("ref_text", "Alpha transcript"))
|
||||
})
|
||||
|
||||
It("rolls back the model, wrapper, voice, and lease when preparation fails", func(ctx SpecContext) {
|
||||
f := newFixture(ctx)
|
||||
session := newSession(f, f.profileA.Voice)
|
||||
oldModel, oldConfig, oldVoice := session.ModelInterface, session.ModelConfig, session.Voice
|
||||
err := update(f, session, &types.RealtimeSession{
|
||||
Model: "pipe-b",
|
||||
Audio: &types.RealtimeSessionAudio{Output: &types.SessionAudioOutput{Voice: "localai://voice-profiles/00000000-0000-0000-0000-000000000001"}},
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(session.Model).To(Equal("pipe-a"))
|
||||
Expect(session.ModelConfig).To(BeIdenticalTo(oldConfig))
|
||||
Expect(session.ModelInterface).To(BeIdenticalTo(oldModel))
|
||||
Expect(session.Voice).To(Equal(oldVoice))
|
||||
Expect(oldVoice).To(BeAnExistingFile())
|
||||
Expect(session.ttsParams).To(HaveKeyWithValue("ref_text", "Alpha transcript"))
|
||||
})
|
||||
|
||||
It("releases a candidate profile lease when later validation fails", func(ctx SpecContext) {
|
||||
f := newFixture(ctx)
|
||||
session := newSession(f, "speaker-1")
|
||||
leases := func() []string {
|
||||
matches, err := filepath.Glob(filepath.Join(f.voiceDir, voiceprofile.DirectoryName, ".leases", "*", "*.wav"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return matches
|
||||
}
|
||||
Expect(leases()).To(BeEmpty())
|
||||
|
||||
err := update(f, session, &types.RealtimeSession{
|
||||
Audio: &types.RealtimeSessionAudio{Output: &types.SessionAudioOutput{Voice: types.Voice(f.profileA.Voice)}},
|
||||
LocalAIClassifier: classifierTestConfig(0, nil),
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(session.Voice).To(Equal("speaker-1"))
|
||||
Expect(leases()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
func ptrTo[T any](value T) *T { return &value }
|
||||
@@ -103,3 +103,48 @@ test.describe("Models gallery - recommended panel prominence", () => {
|
||||
await expect(grid(page).locator(".lane__tag--evidence")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Start with a fitting model so absence assertions cannot pass during loading.
|
||||
// Then change the polled hardware budget while keeping the same gallery.
|
||||
for (const view of ["models", "home"]) {
|
||||
test(`${view} removes GPU recommendations when no candidate fits`, async ({ page }) => {
|
||||
await mockGallery(page, 0);
|
||||
await page.route("**/v1/models", (route) =>
|
||||
route.fulfill({ json: { data: [] } }),
|
||||
);
|
||||
const gib = 1024 ** 3;
|
||||
let budget = 24 * gib;
|
||||
await page.route("**/api/resources", (route) =>
|
||||
route.fulfill({ json: {
|
||||
type: "gpu",
|
||||
aggregate: { total_memory: budget, gpu_count: 1 },
|
||||
gpus: [{ vendor: "nvidia", total_memory: budget }],
|
||||
} }),
|
||||
);
|
||||
await page.route("**/api/models/estimate/*", (route) =>
|
||||
route.fulfill({ json: {
|
||||
sizeBytes: 17.4 * gib,
|
||||
sizeDisplay: "17.4 GB",
|
||||
estimates: { 4096: { vramBytes: 18.4 * gib, vramDisplay: "18.4 GB" } },
|
||||
} }),
|
||||
);
|
||||
await page.goto(view === "models" ? "/app/models" : "/app/");
|
||||
const section = view === "models" ? panel(page) : page.locator(".home-starters");
|
||||
await expect(section).toBeVisible();
|
||||
await expect(section).toContainText("tiny-chat");
|
||||
|
||||
// Wait for BOTH recommendation estimates, not the hook's loading render
|
||||
// or the gallery rail's separate context-size requests.
|
||||
const estimatesFinished = REC_MODELS.map(model => page.waitForResponse(response => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname.endsWith('/api/models/estimate/' + model.name) &&
|
||||
url.searchParams.get('contexts') === '4096' && response.status() === 200;
|
||||
}).then(response => response.finished()));
|
||||
budget = 12 * gib;
|
||||
await Promise.all(estimatesFinished);
|
||||
await page.evaluate(() => new Promise(resolve =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
));
|
||||
await expect(section).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
}
|
||||
Generated
+21
-10
@@ -24,7 +24,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hono": "4.12.34",
|
||||
"hono": "4.13.5",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.6",
|
||||
@@ -771,9 +771,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3467,9 +3467,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.34",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz",
|
||||
"integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==",
|
||||
"version": "4.13.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
|
||||
"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -4593,10 +4593,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"coverage:report": "nyc report"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "4.12.34",
|
||||
"hono": "4.13.5",
|
||||
"ip-address": "10.3.1",
|
||||
"path-to-regexp": "^8.4.0"
|
||||
},
|
||||
@@ -40,7 +40,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hono": "4.12.34",
|
||||
"hono": "4.13.5",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.6",
|
||||
|
||||
@@ -3,63 +3,17 @@ import { useTranslation } from 'react-i18next'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import { useRecommendedModels, isNvfp4Name } from '../hooks/useRecommendedModels'
|
||||
|
||||
// Static fallback used only when the live gallery / estimates can't be reached
|
||||
// (offline, trimmed gallery). The hook is the primary, data-driven path; these
|
||||
// are real gallery names kept as a safety net so onboarding never shows nothing.
|
||||
// Gemma picks use the QAT (quantization-aware-trained) Q4 builds. NVIDIA boxes
|
||||
// get NVFP4 + MTP variants at the mid/large tiers (see NVIDIA below).
|
||||
const BASE = {
|
||||
cpu: [
|
||||
{ name: 'gemma-4-e2b-it-qat-q4_0', size: '~1.5 GB' },
|
||||
{ name: 'qwen3.5-4b-claude-4.6-opus-reasoning-distilled', size: '~2.5 GB' },
|
||||
{ name: 'gemma-4-e4b-it-qat-q4_0', size: '~3 GB' },
|
||||
{ name: 'lfm2.5-1.2b-instruct', size: '~0.8 GB' },
|
||||
],
|
||||
'gpu-small': [
|
||||
{ name: 'gemma-4-e4b-it-qat-q4_0', size: '~3 GB' },
|
||||
{ name: 'lfm2.5-8b-a1b', size: '~5 GB' },
|
||||
{ name: 'qwen3.5-9b', size: '~5.5 GB' },
|
||||
{ name: 'gemma-4-12b-it-qat-q4_0', size: '~7 GB' },
|
||||
],
|
||||
'gpu-mid': [
|
||||
{ name: 'qwen3.6-27b', size: '~16 GB' },
|
||||
{ name: 'qwen3.6-27b-mtp-pi-tune', size: '~16 GB' },
|
||||
{ name: 'gemma-4-26b-a4b-it-qat-q4_0', size: '~16 GB' },
|
||||
{ name: 'qwen3.5-27b', size: '~16 GB' },
|
||||
],
|
||||
'gpu-large': [
|
||||
{ name: 'qwen3.6-35b-a3b-apex', size: '~20 GB' },
|
||||
{ name: 'qwen3.6-35b-a3b-claude-4.6-opus-reasoning-distilled', size: '~20 GB' },
|
||||
{ name: 'gemma-4-31b-it-qat-q4_0', size: '~18 GB' },
|
||||
{ name: 'qwen3.5-35b-a3b-apex', size: '~20 GB' },
|
||||
],
|
||||
}
|
||||
|
||||
// NVIDIA-only overrides: NVFP4 is a Blackwell-optimised 4-bit format paired with
|
||||
// MTP (multi-token prediction) for speed. Only the mid/large tiers have these.
|
||||
const NVIDIA = {
|
||||
'gpu-mid': [
|
||||
{ name: 'qwen3.6-27b-nvfp4-mtp', size: '~14 GB' },
|
||||
{ name: 'qwen3.6-27b-mtp-pi-tune', size: '~16 GB' },
|
||||
{ name: 'gemma-4-26b-a4b-it-qat-q4_0', size: '~16 GB' },
|
||||
{ name: 'qwen3.6-27b', size: '~16 GB' },
|
||||
],
|
||||
'gpu-large': [
|
||||
{ name: 'qwen3.6-35b-a3b-nvfp4-mtp', size: '~18 GB' },
|
||||
{ name: 'qwen3.6-27b-nvfp4-mtp', size: '~14 GB' },
|
||||
{ name: 'qwen3.6-35b-a3b-apex', size: '~20 GB' },
|
||||
{ name: 'gemma-4-31b-it-qat-q4_0', size: '~18 GB' },
|
||||
],
|
||||
}
|
||||
|
||||
function fallbackFor(tierId, isNvidia) {
|
||||
if (isNvidia && NVIDIA[tierId]) return NVIDIA[tierId]
|
||||
return BASE[tierId] || BASE.cpu
|
||||
}
|
||||
// Offline CPU suggestions do not claim a measured GPU fit.
|
||||
const CPU_FALLBACK = [
|
||||
{ name: 'gemma-4-e2b-it-qat-q4_0', size: '~1.5 GB' },
|
||||
{ name: 'qwen3.5-4b-claude-4.6-opus-reasoning-distilled', size: '~2.5 GB' },
|
||||
{ name: 'gemma-4-e4b-it-qat-q4_0', size: '~3 GB' },
|
||||
{ name: 'lfm2.5-1.2b-instruct', size: '~0.8 GB' },
|
||||
]
|
||||
|
||||
export default function StarterModels({ addToast, onInstallStarted }) {
|
||||
const { t } = useTranslation('home')
|
||||
const { recommended, tier, isNvidia, loading } = useRecommendedModels({ count: 4 })
|
||||
const { recommended, tier, loading } = useRecommendedModels({ count: 4 })
|
||||
const [installing, setInstalling] = useState(() => new Set())
|
||||
|
||||
// While the hardware probe + gallery query are in flight, render nothing
|
||||
@@ -67,10 +21,11 @@ export default function StarterModels({ addToast, onInstallStarted }) {
|
||||
if (loading) return null
|
||||
|
||||
// Prefer live recommendations; fall back to the static list only when the
|
||||
// gallery yielded nothing.
|
||||
// gallery yielded nothing on a CPU host. Static GPU picks have no measured
|
||||
// fit and must not replace an empty set of fitting recommendations.
|
||||
const items = (recommended && recommended.length > 0)
|
||||
? recommended.map(r => ({ name: r.name, size: r.sizeDisplay }))
|
||||
: fallbackFor(tier.id, isNvidia)
|
||||
: tier.id === 'cpu' ? CPU_FALLBACK : []
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
|
||||
+3
-3
@@ -53,16 +53,16 @@ function rank(candidates, tier, count, isNvidia) {
|
||||
}
|
||||
const limit = tier.vram * 0.95
|
||||
const fits = pool.filter(c => c.vramBytes != null && c.vramBytes <= limit)
|
||||
const base = fits.length > 0 ? fits : pool // tiny GPU where nothing fits → fall through to smallest
|
||||
const byPreference = (a, b) => {
|
||||
// On NVIDIA, surface NVFP4 first; then largest-that-fits (best quality).
|
||||
if (isNvidia) {
|
||||
const an = isNvfp4Name(a.name), bn = isNvfp4Name(b.name)
|
||||
if (an !== bn) return an ? -1 : 1
|
||||
}
|
||||
return fits.length > 0 ? b.sizeBytes - a.sizeBytes : a.sizeBytes - b.sizeBytes
|
||||
return b.sizeBytes - a.sizeBytes
|
||||
}
|
||||
return [...base].sort(byPreference).slice(0, count)
|
||||
// An oversized or unestimated model cannot be labelled a hardware fit.
|
||||
return [...fits].sort(byPreference).slice(0, count)
|
||||
}
|
||||
|
||||
export function useRecommendedModels({ count = 4, candidatePool = 10 } = {}) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package nodes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -593,10 +594,27 @@ func isFilePath(s string) bool {
|
||||
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||||
return false
|
||||
}
|
||||
// Raw JPEG base64 begins with /9j because every JPEG starts with the
|
||||
// FF D8 FF marker. Do not mistake that leading slash for an absolute path.
|
||||
if isRawJPEGBase64(s) {
|
||||
return false
|
||||
}
|
||||
// Starts with / (absolute path) or contains path separator
|
||||
return s[0] == '/' || filepath.IsAbs(s)
|
||||
}
|
||||
|
||||
func isRawJPEGBase64(s string) bool {
|
||||
if len(s) < 4 {
|
||||
return false
|
||||
}
|
||||
prefix, err := base64.StdEncoding.DecodeString(s[:4])
|
||||
if err != nil || len(prefix) != 3 || prefix[0] != 0xff || prefix[1] != 0xd8 || prefix[2] != 0xff {
|
||||
return false
|
||||
}
|
||||
_, err = io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(s)))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// copyFile copies src to dst.
|
||||
func copyFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil {
|
||||
|
||||
@@ -127,6 +127,23 @@ var _ = Describe("FileStagingClient request lifecycle", func() {
|
||||
Expect(requestID()).To(MatchRegexp(`^` + fullUUIDPattern + `$`))
|
||||
})
|
||||
|
||||
It("passes raw JPEG base64 to predict without staging it as a path", func(ctx SpecContext) {
|
||||
const jpegBase64 = "/9j/2Q=="
|
||||
backend := &lifecycleBackend{}
|
||||
stager := &lifecycleStager{}
|
||||
client := NewFileStagingClient(backend, stager, "worker-1")
|
||||
|
||||
_, err := client.Predict(ctx, &pb.PredictOptions{Images: []string{jpegBase64}})
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(stager.ensureCalls).To(BeEmpty())
|
||||
Expect(backend.predictInput.Images).To(Equal([]string{jpegBase64}))
|
||||
})
|
||||
|
||||
It("still recognizes an invalid JPEG-like base64 string as a path", func() {
|
||||
Expect(isFilePath("/9j/not-a-jpeg")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("releases every staged key and preserves caller requests", func(ctx SpecContext) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -674,6 +674,7 @@ Templates use Go templates with [Sprig functions](http://masterminds.github.io/s
|
||||
| `template.multimodal` | string | Template for multimodal interactions |
|
||||
| `template.reply_prefix` | string | Prefix to add to model replies |
|
||||
| `template.use_tokenizer_template` | bool | Use tokenizer's built-in template (vLLM/transformers) |
|
||||
| `template.system_messages_after_first` | string | What to do with `system`-role messages that appear after the leading system block: `merge` folds them into the first system message, `user` forwards them as user-role turns at their position. Unset keeps them as-is. Needed for tokenizer templates that reject late system turns (e.g. Qwen3.8) while agent frameworks append instructions mid-conversation. |
|
||||
| `template.join_chat_messages_by_character` | string | Character to join chat messages (default: `\n`) |
|
||||
|
||||
### Template Variables
|
||||
|
||||
@@ -141,7 +141,22 @@ pipeline:
|
||||
|
||||
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.
|
||||
You can also change the profile during a realtime session. Set `audio.output.voice` to a Voice Library URI in a `session.update` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"audio": {
|
||||
"output": {
|
||||
"voice": "localai://voice-profiles/550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the same update changes `model`, the explicit `audio.output.voice` value takes precedence over the new model's `tts.voice` default. The selected model must support Voice Library cloning.
|
||||
|
||||
#### Supported backend and model variants
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ This section covers everything you need to know about installing and configuring
|
||||
|
||||
The Model Gallery is the simplest way to install models. It provides pre-configured models ready to use.
|
||||
|
||||
GPU recommendations require a memory estimate within 95% of the detected model memory budget at a 4096-token context. If none of the sampled candidates fit, the recommendation section is hidden. You can still browse the gallery and check individual models at your intended context size. The Home page also omits static GPU suggestions when no fitting recommendation is available.
|
||||
|
||||
### Via WebUI
|
||||
|
||||
1. Open the LocalAI WebUI at `http://localhost:8080`
|
||||
|
||||
Reference in new issue
Block a user