chore: merge master into distributed transport PR

Bring the distributed branch onto current master before the CI fix.

Assisted-by: Codex:gpt-6
This commit is contained in:
localai-org-maint-bot committed 2026-10-01 03:12:40 +00:00
commit 4bc0e92a3d
102 files changed
+7143 -205

No files matched your search

+4 -3
View File
@@ -213,9 +213,10 @@ func transcriptResultFromProto(r *proto.TranscriptResult) *schema.TranscriptionR
var words []schema.TranscriptionWord
for _, w := range s.Words {
var word = schema.TranscriptionWord{
Start: time.Duration(w.Start),
End: time.Duration(w.End),
Text: w.Text,
Start: time.Duration(w.Start),
End: time.Duration(w.End),
Text: w.Text,
Speaker: w.Speaker,
}
words = append(words, word)
tr.Words = append(tr.Words, word)
+47 -8
View File
@@ -26,11 +26,33 @@ import (
// backchannel ("uh-huh") ended — callers must NOT treat Eob as a turn
// boundary.
type LiveTranscriptionEvent struct {
Delta string
Eou bool
Eob bool
Words []schema.TranscriptionWord
Final *schema.TranscriptionResult
Delta string
Eou bool
Eob bool
Words []schema.TranscriptionWord
Speakers []LiveSpeakerSegment
Sounds []LiveSoundEvent
Final *schema.TranscriptionResult
}
// LiveSpeakerSegment is one closed speaker segment from a companion
// diarization/scene stream running alongside live transcription. Start/End
// are stream-relative seconds (mapped from the backend's nanoseconds).
type LiveSpeakerSegment struct {
Speaker string
Start float64
End float64
}
// LiveSoundEvent is one closed sound event from a companion sound/scene
// stream running alongside live transcription. Start/End are stream-relative
// seconds (mapped from the backend's nanoseconds).
type LiveSoundEvent struct {
Label string
Index int
Peak float32
Start float64
End float64
}
// LiveTranscriptionSession is a handle on an open live transcription stream.
@@ -298,9 +320,26 @@ func liveEventFromProto(r *proto.TranscriptLiveResponse) LiveTranscriptionEvent
}
for _, w := range r.GetWords() {
ev.Words = append(ev.Words, schema.TranscriptionWord{
Start: time.Duration(w.Start),
End: time.Duration(w.End),
Text: w.Text,
Start: time.Duration(w.Start),
End: time.Duration(w.End),
Text: w.Text,
Speaker: w.Speaker,
})
}
for _, s := range r.GetSpeakers() {
ev.Speakers = append(ev.Speakers, LiveSpeakerSegment{
Speaker: s.GetSpeaker(),
Start: time.Duration(s.GetStart()).Seconds(),
End: time.Duration(s.GetEnd()).Seconds(),
})
}
for _, s := range r.GetSounds() {
ev.Sounds = append(ev.Sounds, LiveSoundEvent{
Label: s.GetLabel(),
Index: int(s.GetIndex()),
Peak: s.GetPeak(),
Start: time.Duration(s.GetStart()).Seconds(),
End: time.Duration(s.GetEnd()).Seconds(),
})
}
if r.GetFinalResult() != nil {
@@ -54,11 +54,53 @@ var _ = Describe("liveEventFromProto", func() {
Expect(ev.Final).To(BeNil())
})
It("carries word speakers and final segment speakers from a diarizing backend", func() {
ev := liveEventFromProto(&proto.TranscriptLiveResponse{
Words: []*proto.TranscriptWord{{Text: "hi", Speaker: "1"}},
})
Expect(ev.Words[0].Speaker).To(Equal("1"))
ev = liveEventFromProto(&proto.TranscriptLiveResponse{
FinalResult: &proto.TranscriptResult{
Text: "hi there",
Segments: []*proto.TranscriptSegment{{Text: "hi", Speaker: "0"}, {Text: "there", Speaker: "1"}},
},
})
Expect(ev.Final.Segments[1].Speaker).To(Equal("1"))
})
It("maps the eob backchannel flag separately from eou", func() {
ev := liveEventFromProto(&proto.TranscriptLiveResponse{Delta: "uh-huh", Eob: true})
Expect(ev.Eob).To(BeTrue())
Expect(ev.Eou).To(BeFalse())
})
It("maps speaker segments and sound events (ns -> seconds)", func() {
ev := liveEventFromProto(&proto.TranscriptLiveResponse{
Speakers: []*proto.LiveSpeakerSegment{
{Speaker: "1", Start: int64(1500 * time.Millisecond), End: int64(3200 * time.Millisecond)},
},
Sounds: []*proto.LiveSoundEvent{
{Label: "Dog bark", Index: 5, Peak: 0.8, Start: int64(500 * time.Millisecond), End: int64(900 * time.Millisecond)},
},
})
Expect(ev.Speakers).To(HaveLen(1))
Expect(ev.Speakers[0].Speaker).To(Equal("1"))
Expect(ev.Speakers[0].Start).To(BeNumerically("~", 1.5, 1e-9))
Expect(ev.Speakers[0].End).To(BeNumerically("~", 3.2, 1e-9))
Expect(ev.Sounds).To(HaveLen(1))
Expect(ev.Sounds[0].Label).To(Equal("Dog bark"))
Expect(ev.Sounds[0].Index).To(Equal(5))
Expect(ev.Sounds[0].Peak).To(BeNumerically("~", 0.8, 1e-6))
Expect(ev.Sounds[0].Start).To(BeNumerically("~", 0.5, 1e-9))
Expect(ev.Sounds[0].End).To(BeNumerically("~", 0.9, 1e-9))
})
It("leaves speakers and sounds nil when the proto carries none", func() {
ev := liveEventFromProto(&proto.TranscriptLiveResponse{Delta: "hi"})
Expect(ev.Speakers).To(BeNil())
Expect(ev.Sounds).To(BeNil())
})
})
// liveTraceState is what makes streaming-only pipelines visible on the
+1 -1
View File
@@ -314,7 +314,7 @@ func agentOptions(dir, model string, opts Options) app.Options {
TraceDir: opts.TraceDir,
}
if opts.Yolo {
overrides.ApprovalMode = "auto"
overrides.ApprovalMode = nibtypes.ApprovalAuto
}
return app.Options{
+3 -3
View File
@@ -509,7 +509,7 @@ var _ = Describe("prepare", func() {
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(BeEmpty())
opts.Yolo = true
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(Equal("auto"))
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(Equal(nibtypes.ApprovalAuto))
})
// The specs above pin what is handed over. These pin what nib does with
@@ -570,7 +570,7 @@ var _ = Describe("prepare", func() {
opts.Yolo = true
cfg := resolve(agentOptions(dir, "a-model", opts))
Expect(cfg.ApprovalMode).To(Equal("auto"))
Expect(cfg.ApprovalMode).To(Equal(nibtypes.ApprovalAuto))
})
// The other half of the same rule, and the reason an unset flag is
@@ -582,7 +582,7 @@ var _ = Describe("prepare", func() {
cfg := resolve(agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)))
Expect(cfg.APIKey).To(Equal("saved-key"))
Expect(cfg.ApprovalMode).To(Equal("prompt"))
Expect(cfg.ApprovalMode).To(Equal(nibtypes.ApprovalPrompt))
})
})
})
+8 -6
View File
@@ -93,18 +93,20 @@ func (t *TranscriptCMD) Run(ctx *cliContext.Context) error {
}
for _, word := range(tr.Words) {
trs.Words = append(trs.Words, schema.TranscriptionWordSeconds{
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Speaker: word.Speaker,
})
}
for _, seg := range(tr.Segments) {
segWords := []schema.TranscriptionWordSeconds{}
for _, word := range(seg.Words) {
segWords = append(segWords, schema.TranscriptionWordSeconds{
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Speaker: word.Speaker,
})
}
trs.Segments = append(trs.Segments, schema.TranscriptionSegmentSeconds{
+15 -5
View File
@@ -35,6 +35,7 @@ const (
UsecaseSpeakerRecognition = "speaker_recognition"
UsecaseTokenClassify = "token_classify"
UsecaseScore = "score"
UsecaseDecisions = "decisions"
)
// GRPCMethod identifies a Backend service RPC from backend.proto.
@@ -216,6 +217,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
GRPCMethod: MethodScore,
Description: "Joint log-probability scoring of candidate continuations via the Score RPC. Declared explicitly via known_usecases and usable alongside generation usecases.",
},
UsecaseDecisions: {
Flag: FLAG_DECISIONS,
GRPCMethod: MethodScore,
Description: "Decision models (served by POST /v1/systemone): typed choice, noul and score questions over a state text, answered by a non-generative decision model through the Score RPC (question_type systemone). Declared explicitly via known_usecases.",
},
}
// BackendCapability describes which gRPC methods and usecases a backend supports.
@@ -349,10 +355,10 @@ var BackendCapabilities = map[string]BackendCapability{
// model returns an error rather than silent garbage.
"vllm-cpp": {
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodGenerateVideo, MethodTokenClassify, MethodScore},
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseVideo, UsecaseTokenClassify, UsecaseScore},
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseVision, UsecaseVideo, UsecaseTokenClassify, UsecaseScore, UsecaseDecisions},
DefaultUsecases: []string{UsecaseChat},
AcceptsImages: true,
Description: "vllm.cpp — the LocalAI team's C++20 port of vLLM; text generation, MiniMax-H3 video+audio generation, GLiNER2.5 zero-shot NER, cua-s1-forms scoring, and kev/laya decision pipelines",
Description: "vllm.cpp — the LocalAI team's C++20 port of vLLM; text generation, MiniMax-H3 video+audio generation, GLiNER2.5 zero-shot NER, cua-s1-forms scoring, and decision models (kev, laya, CLM, GLiNER2.5-Decide, xor, nimble)",
},
"vllm-omni": {
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodGenerateImage, MethodGenerateVideo, MethodTTS},
@@ -477,11 +483,15 @@ var BackendCapabilities = map[string]BackendCapability{
DefaultUsecases: []string{UsecaseTranscript},
Description: "NVIDIA NeMo speech recognition",
},
// parakeet-cpp loads three model kinds, picked from the GGUF: an ASR model
// transcribes (and labels speakers when a diarization_model companion is
// attached), a Nemotron-3-Diarization model answers Diarize, and a CED model
// answers SoundDetection. PossibleUsecases is their union.
"parakeet-cpp": {
GRPCMethods: []GRPCMethod{MethodAudioTranscription},
PossibleUsecases: []string{UsecaseTranscript},
GRPCMethods: []GRPCMethod{MethodAudioTranscription, MethodDiarize, MethodSoundDetection},
PossibleUsecases: []string{UsecaseTranscript, UsecaseDiarization, UsecaseSoundClassification},
DefaultUsecases: []string{UsecaseTranscript},
Description: "NVIDIA NeMo Parakeet ASR (parakeet.cpp)",
Description: "NVIDIA NeMo Parakeet ASR, Nemotron-3-Diarization speaker diarization and CED sound-event detection (parakeet.cpp)",
},
// nemo-speech-cpp is one gRPC server in front of four NeMo-Speech.cpp model
// families, picked at load time from the GGUF general.architecture key, so
+2 -2
View File
@@ -16,14 +16,14 @@ import (
// reservedNonChatModel reports whether the operator reserved this model for an
// internal primitive — the router score classifier or the PII NER
// token_classify tier. Such a model has no chat template and must not be
// token_classify tier, or a decision head. Such a model has no chat template and must not be
// given the generative-chat defaults the GGUF importer otherwise applies
// (FLAG_CHAT, jinja templating): surfacing it in chat pickers defeats the
// reservation. Operators who do want a combined model declare both usecases
// explicitly — the combination is valid.
func reservedNonChatModel(cfg *ModelConfig) bool {
return cfg.KnownUsecases != nil &&
(*cfg.KnownUsecases&(FLAG_SCORE|FLAG_TOKEN_CLASSIFY)) != 0
(*cfg.KnownUsecases&(FLAG_SCORE|FLAG_TOKEN_CLASSIFY|FLAG_DECISIONS)) != 0
}
// genAudioEncoderKey is the mmproj metadata flag llama.cpp's mtmd writes for a
+7
View File
@@ -509,6 +509,13 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Min: f64(0),
Order: 66,
},
"pipeline.diarization": {
Section: "pipeline",
Label: "Speaker Diarization",
Description: "Label speakers on each committed utterance and emit every labelled segment as a conversation.item.input_audio_transcription.segment event. Needs a transcription model that diarizes (e.g. parakeet-cpp with a diarization_model companion). Speaker labels are per turn.",
Component: "toggle",
Order: 67,
},
"pipeline.reasoning_effort": {
Section: "pipeline",
Label: "Reasoning Effort",
+28 -4
View File
@@ -833,6 +833,14 @@ type Pipeline struct {
SoundDetectionWindowMs int `yaml:"sound_detection_window_ms,omitempty" json:"sound_detection_window_ms,omitempty"`
SoundDetectionHopMs int `yaml:"sound_detection_hop_ms,omitempty" json:"sound_detection_hop_ms,omitempty"`
// Diarization asks the transcription model for speaker labels on each
// VAD-committed utterance and emits every labelled segment as a
// conversation.item.input_audio_transcription.segment event. It needs a
// transcription model that diarizes (e.g. parakeet-cpp with a
// diarization_model companion); off by default because some backends fail
// a diarize request they cannot serve. Speaker labels are per turn.
Diarization bool `yaml:"diarization,omitempty" json:"diarization,omitempty"`
// ReasoningEffort sets the reasoning effort (none|minimal|low|medium|high) for
// the pipeline's LLM without editing the LLM model config. Overrides the LLM's
// own reasoning_effort. Unset leaves the LLM model config in charge.
@@ -2048,6 +2056,13 @@ const (
FLAG_3D ModelConfigUsecase = 0b100000000000000000000000
FLAG_3D_ANIMATION ModelConfigUsecase = 1 << 24
// Marks a model as a decision model: it answers typed choice / noul /
// score questions over a state (served by POST /v1/systemone).
// Explicit only, like FLAG_SCORE: a decision model never generates
// text, so guessing chat or embeddings for it would surface it in
// pickers it cannot serve.
FLAG_DECISIONS ModelConfigUsecase = 1 << 25
// Common Subsets
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
)
@@ -2110,6 +2125,7 @@ func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
"FLAG_TOKEN_CLASSIFY": FLAG_TOKEN_CLASSIFY,
"FLAG_3D": FLAG_3D,
"FLAG_3D_ANIMATION": FLAG_3D_ANIMATION,
"FLAG_DECISIONS": FLAG_DECISIONS,
}
}
@@ -2138,9 +2154,9 @@ func GetUsecasesFromYAML(input []string) *ModelConfigUsecase {
//
// Declared known_usecases are normally additive — the guessing heuristic
// still adds whatever it can infer from backend/templates. The exceptions
// are FLAG_SCORE and FLAG_TOKEN_CLASSIFY: when the operator declared
// either, they reserved the model for an internal direct-decode primitive
// (the router classifier, or the PII NER tier). Letting GuessUsecases
// are FLAG_SCORE, FLAG_TOKEN_CLASSIFY and FLAG_DECISIONS: when the operator
// declared any of them, they reserved the model for a direct-decode primitive
// (the router classifier, the PII NER tier, or a decision head). Letting GuessUsecases
// paint chat/completion/embeddings on top would surface it in pickers it
// was deliberately kept out of. So a declared score or token_classify
// list is authoritative; declare the generation usecases explicitly
@@ -2150,7 +2166,7 @@ func (c *ModelConfig) HasUsecases(u ModelConfigUsecase) bool {
if (u & *c.KnownUsecases) == u {
return true
}
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY)) != 0 {
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY | FLAG_DECISIONS)) != 0 {
return false
}
}
@@ -2373,6 +2389,14 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
return false
}
if (u & FLAG_DECISIONS) == FLAG_DECISIONS {
// No heuristic: decisions intent is a deliberate operator choice
// (the model is a non-generative decision head), so
// HasUsecases(FLAG_DECISIONS) is true only when KnownUsecases
// declares it explicitly.
return false
}
return true
}
+33
View File
@@ -955,3 +955,36 @@ var _ = Describe("ModelConfig alias", func() {
Expect(err).To(MatchError(ContainSubstring("alias")))
})
})
var _ = Describe("decisions usecase", func() {
// A decision model never generates text, so a declared decisions list
// must stay authoritative and the heuristic must never guess the flag.
It("is authoritative when declared and never guessed", func() {
declared := GetUsecasesFromYAML([]string{"decisions"})
Expect(declared).NotTo(BeNil())
Expect(*declared).NotTo(Equal(FLAG_ANY))
cfg := ModelConfig{
Name: "laya",
Backend: "vllm-cpp",
KnownUsecases: declared,
TemplateConfig: TemplateConfig{
Chat: "inherited from chatml",
ChatMessage: "inherited from chatml",
Completion: "inherited from chatml",
},
}
Expect(cfg.HasUsecases(*declared)).To(BeTrue())
Expect(cfg.HasUsecases(FLAG_CHAT)).To(BeFalse())
Expect(cfg.HasUsecases(FLAG_COMPLETION)).To(BeFalse())
Expect(cfg.HasUsecases(FLAG_EMBEDDINGS)).To(BeFalse())
undeclared := ModelConfig{Name: "laya", Backend: "vllm-cpp"}
Expect(undeclared.HasUsecases(*declared)).To(BeFalse())
})
It("is a reserved usecase for the GGUF importer chat-default guard", func() {
declared := GetUsecasesFromYAML([]string{"decisions"})
Expect(reservedNonChatModel(&ModelConfig{Backend: "vllm-cpp", KnownUsecases: declared})).To(BeTrue())
})
})
+28 -3
View File
@@ -108,6 +108,11 @@ func (i *ParakeetCppImporter) Import(details Details) (gallery.ModelConfig, erro
uri := downloader.URI(details.URI)
directGGUF := isParakeetGGUF(filepath.Base(details.URI))
// A speaker diarization GGUF is served by the same backend but answers
// /v1/audio/diarization, not transcription.
if directGGUF && isParakeetDiarGGUF(filepath.Base(details.URI)) {
modelConfig.KnownUsecaseStrings = []string{"diarization"}
}
switch {
case uri.LooksLikeURL() && directGGUF:
// Direct file URL (e.g. .../resolve/main/tdt_ctc-110m-f16.gguf). The
@@ -128,12 +133,23 @@ func (i *ParakeetCppImporter) Import(details Details) (gallery.ModelConfig, erro
// HF repo: collect every parakeet GGUF, pick the preferred quant, and
// nest under parakeet-cpp/models/<name>/ so a multi-quant repo doesn't
// collide on disk.
var ggufFiles []hfapi.ModelFile
// Prefer ASR weights: a repo that also ships the diarization model
// (mudler/parakeet-cpp-gguf) imports as a transcription model, and the
// diarization GGUF is imported by its direct URL. A repo with only
// diarization weights imports as a diarization model.
var ggufFiles, diarFiles []hfapi.ModelFile
for _, f := range details.HuggingFace.Files {
if isParakeetGGUF(filepath.Base(f.Path)) {
switch base := filepath.Base(f.Path); {
case isParakeetDiarGGUF(base):
diarFiles = append(diarFiles, f)
case isParakeetGGUF(base):
ggufFiles = append(ggufFiles, f)
}
}
if len(ggufFiles) == 0 && len(diarFiles) > 0 {
ggufFiles = diarFiles
modelConfig.KnownUsecaseStrings = []string{"diarization"}
}
if chosen, ok := pickPreferredGGMLFile(ggufFiles, quants); ok {
target := filepath.Join("parakeet-cpp", "models", name, filepath.Base(chosen.Path))
cfg.Files = append(cfg.Files, gallery.File{
@@ -176,5 +192,14 @@ func isParakeetGGUF(name string) bool {
return true
}
}
return false
return isParakeetDiarGGUF(name)
}
// isParakeetDiarGGUF reports whether name is the parakeet.cpp speaker
// diarization GGUF (nemotron-3-diarization-<quant>.gguf). Matched by its
// published name only, so diarization weights for other backends are not
// claimed.
func isParakeetDiarGGUF(name string) bool {
lower := strings.ToLower(name)
return strings.HasSuffix(lower, ".gguf") && strings.Contains(lower, "nemotron-3-diarization")
}
@@ -50,6 +50,11 @@ var _ = Describe("ParakeetCppImporter", func() {
Expect(imp.Match(d)).To(BeTrue())
})
It("matches a direct URL to the diarization GGUF", func() {
d := parakeetDetails("https://huggingface.co/mudler/parakeet-cpp-gguf/resolve/main/nemotron-3-diarization-q8_0.gguf", `{}`)
Expect(imp.Match(d)).To(BeTrue())
})
It("does NOT claim a generic llama-style GGUF", func() {
d := parakeetDetails("huggingface://someorg/some-llm-gguf", `{}`,
hfapi.ModelFile{Path: "llama-3-8b-instruct-q4_k_m.gguf"},
@@ -66,6 +71,43 @@ var _ = Describe("ParakeetCppImporter", func() {
})
Context("import (Import)", func() {
It("imports the diarization GGUF as a diarization model", func() {
d := parakeetDetails("https://huggingface.co/mudler/parakeet-cpp-gguf/resolve/main/nemotron-3-diarization-q8_0.gguf",
`{"name":"nemotron-diarization"}`)
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.ConfigFile).To(ContainSubstring("backend: parakeet-cpp"))
Expect(cfg.ConfigFile).To(ContainSubstring("diarization"))
Expect(cfg.ConfigFile).ToNot(ContainSubstring("transcript"))
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].Filename).To(HaveSuffix("nemotron-3-diarization-q8_0.gguf"))
})
It("keeps picking ASR weights from a repo that also ships the diarization model", func() {
d := parakeetDetails("huggingface://mudler/parakeet-cpp-gguf", `{"name":"parakeet-110m"}`,
hfapi.ModelFile{Path: "nemotron-3-diarization-f16.gguf", URL: "https://hf/diar-f16", SHA256: "ddd"},
hfapi.ModelFile{Path: "tdt_ctc-110m-f16.gguf", URL: "https://hf/f16", SHA256: "aaa"},
hfapi.ModelFile{Path: "nemotron-3-diarization-q8_0.gguf", URL: "https://hf/diar-q8", SHA256: "eee"},
)
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://hf/f16"))
Expect(cfg.ConfigFile).To(ContainSubstring("transcript"))
})
It("imports a diarization-only repo as a diarization model", func() {
d := parakeetDetails("huggingface://someone/diar-gguf", `{"name":"diar"}`,
hfapi.ModelFile{Path: "nemotron-3-diarization-f16.gguf", URL: "https://hf/diar-f16", SHA256: "ddd"},
hfapi.ModelFile{Path: "nemotron-3-diarization-q8_0.gguf", URL: "https://hf/diar-q8", SHA256: "eee"},
)
cfg, err := imp.Import(d)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Files).To(HaveLen(1))
Expect(cfg.Files[0].URI).To(Equal("https://hf/diar-q8"), "default quant ladder picks q8_0 before f16")
Expect(cfg.ConfigFile).To(ContainSubstring("diarization"))
})
It("picks the default quant (q4_k) from a multi-quant HF repo", func() {
d := parakeetDetails("huggingface://mudler/parakeet-cpp-gguf", `{"name":"parakeet-110m"}`,
hfapi.ModelFile{Path: "tdt_ctc-110m-f16.gguf", URL: "https://hf/f16", SHA256: "aaa"},
+73
View File
@@ -0,0 +1,73 @@
package gallery_test
import (
"fmt"
"os"
"path/filepath"
"slices"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
"github.com/mudler/LocalAI/core/config"
)
// A gallery tag that names a capability is what users filter on, and
// known_usecases is what the server routes on. When they disagree, the entry
// is listed under a filter it cannot serve, or is hidden from one it can.
var _ = Describe("gallery/index.yaml vllm-cpp capability tags", func() {
It("keeps capability tags and known_usecases in agreement", func() {
entries, err := loadGalleryIndex()
Expect(err).ToNot(HaveOccurred())
tagToFlag := map[string]config.ModelConfigUsecase{
"decisions": config.FLAG_DECISIONS,
"vision": config.FLAG_VISION,
"token-classify": config.FLAG_TOKEN_CLASSIFY,
"scoring": config.FLAG_SCORE,
}
var violations []string
seen := 0
for i := range entries {
e := &entries[i]
if backend, _ := e.Overrides["backend"].(string); backend != "vllm-cpp" {
continue
}
seen++
declared := e.GetKnownUsecases()
for tag, flag := range tagToFlag {
tagged := slices.Contains(e.Tags, tag)
has := declared != nil && *declared&flag == flag
if tagged != has {
violations = append(violations, fmt.Sprintf("%s: tag %q present=%v but known_usecases declares it=%v", e.Name, tag, tagged, has))
}
}
}
Expect(seen).To(BeNumerically(">", 0))
Expect(violations).To(BeEmpty())
})
})
// artifacts: is a model-config key, so the installer only sees it inside
// overrides:. At the top level of an entry it is silently dropped, the
// installed config keeps a bare HF repo id as its model, and a backend that
// does not infer artifacts (vllm-cpp among them) fails the first load with
// "model path not found" while the install itself reported success.
var _ = Describe("gallery/index.yaml artifacts placement", func() {
It("declares artifacts under overrides, never at the entry top level", func() {
data, err := os.ReadFile(filepath.Join("..", "..", "gallery", "index.yaml"))
Expect(err).ToNot(HaveOccurred())
var raw []map[string]any
Expect(yaml.Unmarshal(data, &raw)).To(Succeed())
var misplaced []string
for _, e := range raw {
if _, ok := e["artifacts"]; ok {
misplaced = append(misplaced, fmt.Sprint(e["name"]))
}
}
Expect(misplaced).To(BeEmpty())
})
})
+6
View File
@@ -71,6 +71,11 @@ var RouteFeatureRegistry = []RouteFeature{
// Detection
{"POST", "/v1/detection", FeatureDetection},
// Decisions API (SystemOne wire contract)
{"POST", "/v1/systemone", FeatureDecisions},
{"POST", "/v1/systemone/permute", FeatureDecisions},
{"POST", "/v1/systemone/separate", FeatureDecisions},
// Face recognition
{"POST", "/v1/face/verify", FeatureFaceRecognition},
{"POST", "/v1/face/analyze", FeatureFaceRecognition},
@@ -209,5 +214,6 @@ func APIFeatureMetas() []FeatureMeta {
{FeatureVoiceRecognition, "Voice Recognition", true},
{FeatureAudioTransform, "Audio Transform", true},
{FeaturePIIFilter, "PII Analyze / Redact", true},
{FeatureDecisions, "Decisions", true},
}
}
+24
View File
@@ -0,0 +1,24 @@
package auth_test
import (
. "github.com/mudler/LocalAI/core/http/auth"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Decisions feature registration", func() {
It("gates the three decision routes behind one default-on API feature", func() {
Expect(APIFeatures).To(ContainElement(FeatureDecisions))
patterns := []string{}
for _, route := range RouteFeatureRegistry {
if route.Feature == FeatureDecisions {
Expect(route.Method).To(Equal("POST"))
patterns = append(patterns, route.Pattern)
}
}
Expect(patterns).To(ConsistOf("/v1/systemone", "/v1/systemone/permute", "/v1/systemone/separate"))
Expect(APIFeatureMetas()).To(ContainElement(FeatureMeta{Key: FeatureDecisions, Label: "Decisions", DefaultValue: true}))
})
})
+2 -1
View File
@@ -59,6 +59,7 @@ const (
FeatureFaceRecognition = "face_recognition"
FeatureVoiceRecognition = "voice_recognition"
FeatureAudioTransform = "audio_transform"
FeatureDecisions = "decisions"
// FeaturePIIFilter gates the synchronous PII analyze/redact service
// (POST /api/pii/{analyze,redact}). Default ON like the other API
// features; the admin-only events log is gated separately in-handler.
@@ -78,7 +79,7 @@ var APIFeatures = []string{
FeatureVAD, FeatureDetection, FeatureVideo, Feature3D, FeatureEmbeddings, FeatureSound,
FeatureRealtime, FeatureModeration, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
FeaturePIIFilter,
FeaturePIIFilter, FeatureDecisions,
}
// AllFeatures lists all known features (used by UI and validation).
@@ -105,6 +105,12 @@ var instructionDefs = []instructionDef{
Tags: []string{"voice-recognition"},
Intro: "Voice (speaker) recognition — the audio analog to /v1/face/*. Use /v1/voice/verify for 1:1 speaker comparison, /v1/voice/identify for 1:N match against the registered store, /v1/voice/{register,forget} to manage that store, /v1/voice/embed for a raw speaker-encoder vector, and /v1/voice/analyze for age / gender / emotion inferred from speech. Registrations are in-memory by default and lost on restart. Audio inputs accept URL, base64, or data-URI; /v1/embeddings remains text-only.",
},
{
Name: "decisions",
Description: "Typed decisions (choice, noul, score) over a state text with calibrated confidence",
Tags: []string{"systemone"},
Intro: "POST /v1/systemone answers every question in one pass; /v1/systemone/permute re-runs one choice question under n_perm option orders; /v1/systemone/separate answers each question in its own pass. Request: { model, state, questions: { <id>: { type: choice|noul|score, instructions, criteria } } }. A decision model declares known_usecases: [decisions] and serves only /v1/systemone; a zero-shot NER model declares token_classify and serves all three routes (through the NER path); /permute and /separate return 400 for decision models. A vllm-cpp config that declares no usecases is treated as a decision model. Responses carry per-question answers with confidence and probabilities plus token usage. Field names and question types follow Ollama's /v1/systemone, with differences in confidence, error shape and keep_alive (see the Decisions API docs). A request over 64 KiB, with more than 64 questions, or with a malformed question is refused.",
},
{
Name: "branding",
Description: "Whitelabel the instance: configure name, tagline, logo, and favicon",
@@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {
instructions, ok := resp["instructions"].([]any)
Expect(ok).To(BeTrue())
Expect(instructions).To(HaveLen(20))
Expect(instructions).To(HaveLen(21))
// Verify each instruction has required fields and correct URL format
for _, s := range instructions {
@@ -82,6 +82,7 @@ var _ = Describe("API Instructions Endpoints", func() {
"voice-library",
"3d",
"failover",
"decisions",
))
})
})
@@ -136,6 +137,17 @@ var _ = Describe("API Instructions Endpoints", func() {
Expect(string(body)).NotTo(ContainSubstring("/v1/3d/generations"))
})
It("should advertise the Decisions API", func() {
req := httptest.NewRequest(http.MethodGet, "/api/instructions/decisions", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
body, _ := io.ReadAll(rec.Body)
Expect(string(body)).To(ContainSubstring("POST /v1/systemone"))
Expect(string(body)).To(ContainSubstring("known_usecases: [decisions]"))
})
It("should return JSON fragment when format=json", func() {
req := httptest.NewRequest(http.MethodGet, "/api/instructions/chat-inference?format=json", nil)
rec := httptest.NewRecorder()
+217 -7
View File
@@ -2,6 +2,7 @@ package localai
import (
"encoding/json"
"errors"
"fmt"
"math"
"math/rand"
@@ -371,6 +372,191 @@ func systemOneError(c echo.Context, status int, msg string) error {
})
}
// systemOneModelAllowed keeps chat and embedding models out of the decision
// API with an actionable error instead of a backend failure. A config that
// declares no usecases predates the flag and stays allowed, and a
// token_classify model is allowed because the NER path serves it.
func systemOneModelAllowed(cfg config.ModelConfig) error {
if cfg.KnownUsecases == nil {
return nil
}
if *cfg.KnownUsecases&(config.FLAG_DECISIONS|config.FLAG_TOKEN_CLASSIFY) != 0 {
return nil
}
return fmt.Errorf("model %q does not declare the decisions usecase (known_usecases: [decisions])", cfg.Name)
}
// checkSystemOneModel applies systemOneModelAllowed to a model looked up by
// name. An unknown model passes here so the existing not-found handling
// downstream keeps its status code.
func checkSystemOneModel(app *application.Application, modelName string) error {
cl := app.ModelConfigLoader()
if cl == nil {
return nil
}
cfg, ok := cl.GetModelConfig(modelName)
if !ok {
return nil
}
return systemOneModelAllowed(cfg)
}
// systemOneUsesDecisionPipeline reports whether /v1/systemone forwards the
// request to the backend's Score RPC (the decision pipeline) for this model.
// A model that declares token_classify without systemone is a zero-shot NER
// model: the backend's decision entry point refuses those architectures, so it
// goes to the NER path instead. A config that declares nothing keeps the
// decision pipeline, which is what setups that predate the decisions usecase
// relied on.
func systemOneUsesDecisionPipeline(cfg config.ModelConfig) bool {
if !backendSupportsScore(cfg.Backend) {
return false
}
if cfg.KnownUsecases == nil {
return true
}
declared := *cfg.KnownUsecases
if declared&config.FLAG_DECISIONS != 0 {
return true
}
return declared&config.FLAG_TOKEN_CLASSIFY == 0
}
// systemOneNERAllowed guards /permute and /separate, which always run the NER
// path. A decision model cannot serve them: the backend's NER entry point
// refuses its architecture, and the caller would see a backend error.
func systemOneNERAllowed(cfg config.ModelConfig) error {
if cfg.KnownUsecases == nil {
return nil
}
declared := *cfg.KnownUsecases
if declared&config.FLAG_DECISIONS != 0 && declared&config.FLAG_TOKEN_CLASSIFY == 0 {
return fmt.Errorf("model %q is a decision model: /permute and /separate use the NER path, use POST /v1/systemone instead", cfg.Name)
}
return nil
}
// checkSystemOneNERModel applies systemOneNERAllowed to a model looked up by
// name; an unknown model passes so the not-found handling keeps its status.
func checkSystemOneNERModel(app *application.Application, modelName string) error {
cl := app.ModelConfigLoader()
if cl == nil {
return nil
}
cfg, ok := cl.GetModelConfig(modelName)
if !ok {
return nil
}
return systemOneNERAllowed(cfg)
}
// systemOneMaxBody and systemOneMaxQuestions bound one request. They keep a
// single call from pinning a decision model on an unbounded prompt, and match
// the limits Ollama documents for the same wire contract, so a client written
// for one server behaves the same on the other. The engine enforces any
// per-model option cap (letter-answer models refuse more than 26 options).
const (
systemOneMaxBody = 64 << 10
systemOneMaxQuestions = 64
)
// systemOneBind binds the JSON body with a size cap. Bind reads the whole body
// first, so the cap has to be on the reader.
func systemOneBind(c echo.Context, v any) error {
c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, systemOneMaxBody)
return c.Bind(v)
}
// systemOneBindStatus maps a bind failure to its status: 413 when the body
// exceeded the cap, 400 for anything else.
func systemOneBindStatus(err error) int {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return http.StatusRequestEntityTooLarge
}
return http.StatusBadRequest
}
func systemOneBindMessage(err error) string {
if systemOneBindStatus(err) == http.StatusRequestEntityTooLarge {
return fmt.Sprintf("request body exceeds %d KiB", systemOneMaxBody>>10)
}
return "invalid request body"
}
// validateSystemOneRequest checks the structure every path needs, before the
// request is forwarded to a decision model or run through the NER path. The
// forwarded path never sees parseSystemOneRequest, so without this a malformed
// question would surface as a backend error instead of a 400.
func validateSystemOneRequest(req *schema.SystemOneRequest) error {
if len(req.State) == 0 || string(req.State) == "null" {
return fmt.Errorf("state is required")
}
var state any
if err := json.Unmarshal(req.State, &state); err != nil {
return fmt.Errorf("state is not valid JSON: %w", err)
}
if s, ok := state.(string); ok && strings.TrimSpace(s) == "" {
return fmt.Errorf("state is required")
}
if len(req.Questions) == 0 {
return fmt.Errorf("questions is required and must contain at least one question")
}
if len(req.Questions) > systemOneMaxQuestions {
return fmt.Errorf("questions must contain at most %d questions", systemOneMaxQuestions)
}
qids := make([]string, 0, len(req.Questions))
for id := range req.Questions {
qids = append(qids, id)
}
sort.Strings(qids)
for _, id := range qids {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("question ids must not be blank")
}
q := req.Questions[id]
switch q.Type {
case "choice":
var criteria map[string]json.RawMessage
if err := json.Unmarshal(q.Criteria, &criteria); err != nil {
return fmt.Errorf("question %q (choice) requires a criteria object", id)
}
if len(criteria) < 2 {
return fmt.Errorf("question %q (choice) requires at least 2 options", id)
}
for k := range criteria {
if strings.TrimSpace(k) == "" {
return fmt.Errorf("question %q (choice) has a blank option key", id)
}
}
case "score":
var criteria []json.RawMessage
if err := json.Unmarshal(q.Criteria, &criteria); err != nil {
return fmt.Errorf("question %q (score) requires a criteria array", id)
}
if len(criteria) < 2 {
return fmt.Errorf("question %q (score) requires at least 2 levels", id)
}
case "noul":
if len(q.Criteria) == 0 || string(q.Criteria) == "null" {
continue
}
var criteria map[string]json.RawMessage
if err := json.Unmarshal(q.Criteria, &criteria); err != nil {
return fmt.Errorf("question %q (noul) criteria must be an object with \"false\" and \"true\" descriptions", id)
}
for k := range criteria {
if k != "false" && k != "true" {
return fmt.Errorf("question %q (noul) criteria may only have \"false\" and \"true\" keys", id)
}
}
default:
return fmt.Errorf("question %q has unknown type: %s", id, q.Type)
}
}
return nil
}
// backendSupportsScore reports whether the named backend implements the
// Score gRPC RPC. vllm-cpp does (kev/laya decision pipeline and cua-s1-forms
// scoring via the unified vllm_decide C ABI); other backends fall through to
@@ -402,18 +588,24 @@ func backendSupportsScore(backendName string) bool {
func SystemOneEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.SystemOneRequest
if err := c.Bind(&req); err != nil {
return systemOneError(c, http.StatusBadRequest, "invalid request body")
if err := systemOneBind(c, &req); err != nil {
return systemOneError(c, systemOneBindStatus(err), systemOneBindMessage(err))
}
if req.Model == "" {
return systemOneError(c, http.StatusBadRequest, "model is required")
}
if err := checkSystemOneModel(app, req.Model); err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
if err := validateSystemOneRequest(&req); err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
// vllm-cpp models (kev/laya) implement the decision pipeline natively
// via the vllm_decide C ABI. Forward the raw request JSON through the
// Score RPC and return the backend's response as-is.
cl := app.ModelConfigLoader()
if cl != nil {
if cfg, ok := cl.GetModelConfig(req.Model); ok && backendSupportsScore(cfg.Backend) {
if cfg, ok := cl.GetModelConfig(req.Model); ok && systemOneUsesDecisionPipeline(cfg) {
reqJSON, err := json.Marshal(req)
if err != nil {
return systemOneError(c, http.StatusInternalServerError, "failed to marshal request: "+err.Error())
@@ -468,12 +660,21 @@ func SystemOneEndpoint(app *application.Application) echo.HandlerFunc {
func SystemOnePermuteEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.SystemOnePermuteRequest
if err := c.Bind(&req); err != nil {
return systemOneError(c, http.StatusBadRequest, "invalid request body")
if err := systemOneBind(c, &req); err != nil {
return systemOneError(c, systemOneBindStatus(err), systemOneBindMessage(err))
}
if req.Request.Model == "" {
return systemOneError(c, http.StatusBadRequest, "model is required")
}
if err := checkSystemOneModel(app, req.Request.Model); err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
if err := checkSystemOneNERModel(app, req.Request.Model); err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
if err := validateSystemOneRequest(&req.Request); err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
if req.Question == "" {
return systemOneError(c, http.StatusBadRequest, "question is required")
}
@@ -604,12 +805,21 @@ func SystemOnePermuteEndpoint(app *application.Application) echo.HandlerFunc {
func SystemOneSeparateEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.SystemOneRequest
if err := c.Bind(&req); err != nil {
return systemOneError(c, http.StatusBadRequest, "invalid request body")
if err := systemOneBind(c, &req); err != nil {
return systemOneError(c, systemOneBindStatus(err), systemOneBindMessage(err))
}
if req.Model == "" {
return systemOneError(c, http.StatusBadRequest, "model is required")
}
if err := checkSystemOneModel(app, req.Model); err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
if err := checkSystemOneNERModel(app, req.Model); err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
if err := validateSystemOneRequest(&req); err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
}
parsed, err := parseSystemOneRequest(&req)
if err != nil {
return systemOneError(c, http.StatusBadRequest, err.Error())
@@ -0,0 +1,74 @@
package localai
import (
"github.com/mudler/LocalAI/core/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("systemOneModelAllowed", func() {
mk := func(usecases ...string) config.ModelConfig {
return config.ModelConfig{
Name: "m",
Backend: "vllm-cpp",
KnownUsecases: config.GetUsecasesFromYAML(usecases),
}
}
It("accepts a declared decisions model", func() {
Expect(systemOneModelAllowed(mk("decisions"))).To(Succeed())
})
It("accepts a token_classify model, which the NER path serves", func() {
Expect(systemOneModelAllowed(mk("token_classify"))).To(Succeed())
})
It("keeps configs that declare no usecases working", func() {
Expect(systemOneModelAllowed(config.ModelConfig{Name: "laya", Backend: "vllm-cpp"})).To(Succeed())
})
It("refuses a chat-only model with an actionable message", func() {
Expect(systemOneModelAllowed(mk("chat"))).To(MatchError(ContainSubstring("known_usecases: [decisions]")))
})
})
var _ = Describe("systemone routing by model kind", func() {
mk := func(backend string, usecases ...string) config.ModelConfig {
c := config.ModelConfig{Name: "m", Backend: backend}
if len(usecases) > 0 {
c.KnownUsecases = config.GetUsecasesFromYAML(usecases)
}
return c
}
Describe("systemOneUsesDecisionPipeline", func() {
It("sends a declared decision model to the decision pipeline", func() {
Expect(systemOneUsesDecisionPipeline(mk("vllm-cpp", "decisions"))).To(BeTrue())
})
It("sends a token_classify model to the NER path, since vllm_decide refuses NER architectures", func() {
Expect(systemOneUsesDecisionPipeline(mk("vllm-cpp", "token_classify"))).To(BeFalse())
})
It("keeps configs that declare nothing on the decision pipeline", func() {
Expect(systemOneUsesDecisionPipeline(mk("vllm-cpp"))).To(BeTrue())
})
It("prefers the decision pipeline when both usecases are declared", func() {
Expect(systemOneUsesDecisionPipeline(mk("vllm-cpp", "decisions", "token_classify"))).To(BeTrue())
})
It("never uses it for a backend without the Score RPC", func() {
Expect(systemOneUsesDecisionPipeline(mk("no-such-backend", "decisions"))).To(BeFalse())
})
})
Describe("systemOneNERAllowed", func() {
It("refuses a decision model on the NER-only routes with an actionable message", func() {
Expect(systemOneNERAllowed(mk("vllm-cpp", "decisions"))).To(MatchError(ContainSubstring("/v1/systemone")))
})
It("accepts a token_classify model", func() {
Expect(systemOneNERAllowed(mk("vllm-cpp", "token_classify"))).To(Succeed())
})
It("accepts configs that declare nothing", func() {
Expect(systemOneNERAllowed(mk("vllm-cpp"))).To(Succeed())
})
})
})
@@ -0,0 +1,96 @@
package localai
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("validateSystemOneRequest", func() {
req := func(state string, questions string) *schema.SystemOneRequest {
r := &schema.SystemOneRequest{Model: "m", State: json.RawMessage(state)}
Expect(json.Unmarshal([]byte(questions), &r.Questions)).To(Succeed())
return r
}
It("accepts the three question types", func() {
r := req(`"ticket text"`, `{
"team": {"type":"choice","instructions":"which","criteria":{"a":"A","b":null}},
"refund": {"type":"noul","instructions":"refund?","criteria":{"false":"No refund","true":"Refund asked"}},
"urgency": {"type":"score","instructions":"how urgent","criteria":["low","high"]}
}`)
Expect(validateSystemOneRequest(r)).To(Succeed())
})
It("accepts a noul question with no criteria", func() {
Expect(validateSystemOneRequest(req(`"x"`, `{"q":{"type":"noul","instructions":"i"}}`))).To(Succeed())
})
DescribeTable("refuses a malformed request with a message that names the problem",
func(state, questions, want string) {
Expect(validateSystemOneRequest(req(state, questions))).To(MatchError(ContainSubstring(want)))
},
Entry("missing state", ``, `{"q":{"type":"noul","instructions":"i"}}`, "state is required"),
Entry("null state", `null`, `{"q":{"type":"noul","instructions":"i"}}`, "state is required"),
Entry("blank string state", `" "`, `{"q":{"type":"noul","instructions":"i"}}`, "state is required"),
Entry("no questions", `"x"`, `{}`, "at least one question"),
Entry("blank question id", `"x"`, `{" ":{"type":"noul","instructions":"i"}}`, "blank"),
Entry("unknown type", `"x"`, `{"q":{"type":"rank","instructions":"i"}}`, "unknown type"),
Entry("choice with one option", `"x"`, `{"q":{"type":"choice","instructions":"i","criteria":{"a":"A"}}}`, "at least 2"),
Entry("choice with a blank option key", `"x"`, `{"q":{"type":"choice","instructions":"i","criteria":{"a":"A"," ":"B"}}}`, "blank"),
Entry("score with one level", `"x"`, `{"q":{"type":"score","instructions":"i","criteria":["only"]}}`, "at least 2"),
Entry("noul criteria with a stray key", `"x"`, `{"q":{"type":"noul","instructions":"i","criteria":{"maybe":"M"}}}`, `"false" and "true"`),
)
It("refuses more than 64 questions", func() {
var b strings.Builder
b.WriteString("{")
for i := 0; i < 65; i++ {
if i > 0 {
b.WriteString(",")
}
b.WriteString(`"q` + strings.Repeat("x", i) + `":{"type":"noul","instructions":"i"}`)
}
b.WriteString("}")
Expect(validateSystemOneRequest(req(`"x"`, b.String()))).To(MatchError(ContainSubstring("at most 64")))
})
})
var _ = Describe("systemOneBind", func() {
bind := func(body string) (int, error) {
e := echo.New()
r := httptest.NewRequest(http.MethodPost, "/v1/systemone", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
c := e.NewContext(r, httptest.NewRecorder())
var out schema.SystemOneRequest
if err := systemOneBind(c, &out); err != nil {
return systemOneBindStatus(err), err
}
return http.StatusOK, nil
}
It("binds a normal body", func() {
status, err := bind(`{"model":"m","state":"x","questions":{}}`)
Expect(err).ToNot(HaveOccurred())
Expect(status).To(Equal(http.StatusOK))
})
It("answers 413 for a body over 64 KiB", func() {
status, err := bind(`{"model":"m","state":"` + strings.Repeat("a", 65*1024) + `"}`)
Expect(err).To(HaveOccurred())
Expect(status).To(Equal(http.StatusRequestEntityTooLarge))
})
It("answers 400 for malformed JSON", func() {
status, err := bind(`{not json`)
Expect(err).To(HaveOccurred())
Expect(status).To(Equal(http.StatusBadRequest))
})
})
@@ -99,6 +99,7 @@ type fakeModel struct {
transcribeDeltas []string
transcribeFinal *schema.TranscriptionResult
transcribeErr error
lastDiarize bool // diarize flag of the last Transcribe/TranscribeStream call
// TranscribeLive scripting: liveErr makes the open fail (degrade path);
// liveEvents are delivered to onEvent synchronously at open;
@@ -200,7 +201,8 @@ func (m *fakeModel) VAD(_ context.Context, req *schema.VADRequest) (*schema.VADR
return &schema.VADResponse{Segments: m.vadSegments}, nil
}
func (m *fakeModel) Transcribe(context.Context, string, string, bool, bool, string) (*schema.TranscriptionResult, error) {
func (m *fakeModel) Transcribe(_ context.Context, _, _ string, _, diarize bool, _ string) (*schema.TranscriptionResult, error) {
m.lastDiarize = diarize
return m.transcribeFinal, m.transcribeErr
}
@@ -247,7 +249,8 @@ func (m *fakeModel) TTSStream(_ context.Context, _, _, _ string, onAudio func(pc
return nil
}
func (m *fakeModel) TranscribeStream(_ context.Context, _, _ string, _, _ bool, _ string, onDelta func(text string)) (*schema.TranscriptionResult, error) {
func (m *fakeModel) TranscribeStream(_ context.Context, _, _ string, _, diarize bool, _ string, onDelta func(text string)) (*schema.TranscriptionResult, error) {
m.lastDiarize = diarize
for _, d := range m.transcribeDeltas {
onDelta(d)
}
@@ -209,6 +209,35 @@ func (l *liveTurnState) drainEvents(audioSec float64) {
if ev.Final != nil && strings.TrimSpace(ev.Final.Text) != "" {
l.finalText = ev.Final.Text
}
// Speaker and sound events from a companion diarization/scene
// stream: forward each as its own event under the turn's item
// id, same as caption deltas. Text is empty — the event exists
// to carry the speaker/segment boundary, not transcript text.
if l.transport != nil && l.itemID != "" {
for _, seg := range ev.Speakers {
sendEvent(l.transport, types.ConversationItemInputAudioTranscriptionSegmentEvent{
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
ItemID: l.itemID,
ContentIndex: 0,
Speaker: seg.Speaker,
Start: seg.Start,
End: seg.End,
})
}
for _, sound := range ev.Sounds {
start, end := sound.Start, sound.End
sendEvent(l.transport, types.ConversationItemSoundDetectionEvent{
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
ItemID: l.itemID,
ContentIndex: 0,
Detections: []types.SoundDetectionTag{
{Label: sound.Label, Score: sound.Peak, Index: sound.Index},
},
Start: &start,
End: &end,
})
}
}
default:
return
}
@@ -291,6 +291,67 @@ var _ = Describe("liveTurnState", func() {
Expect(ftr.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionFailed)).To(Equal(0))
})
})
Describe("scene events (speakers and sounds)", func() {
It("emits a segment event per speaker with empty text under the turn's item id", func() {
Expect(lts.openTurn(context.Background(), "item1")).To(BeTrue())
turnID := lts.itemID
m.liveSession.onEvent(backend.LiveTranscriptionEvent{
Speakers: []backend.LiveSpeakerSegment{{Speaker: "1", Start: 1.2, End: 3.4}},
})
lts.drainEvents(3.4)
var got []types.ConversationItemInputAudioTranscriptionSegmentEvent
for _, e := range ftr.events() {
if seg, ok := e.(types.ConversationItemInputAudioTranscriptionSegmentEvent); ok {
got = append(got, seg)
}
}
Expect(got).To(HaveLen(1))
Expect(got[0].ItemID).To(Equal(turnID))
Expect(got[0].Speaker).To(Equal("1"))
Expect(got[0].Start).To(BeNumerically("~", 1.2, 1e-9))
Expect(got[0].End).To(BeNumerically("~", 3.4, 1e-9))
Expect(got[0].Text).To(BeEmpty())
})
It("emits a sound_detection event per sound with one tag and start/end", func() {
Expect(lts.openTurn(context.Background(), "item1")).To(BeTrue())
turnID := lts.itemID
m.liveSession.onEvent(backend.LiveTranscriptionEvent{
Sounds: []backend.LiveSoundEvent{{Label: "Dog bark", Index: 5, Peak: 0.8, Start: 0.5, End: 0.9}},
})
lts.drainEvents(1.0)
var got []types.ConversationItemSoundDetectionEvent
for _, e := range ftr.events() {
if sd, ok := e.(types.ConversationItemSoundDetectionEvent); ok {
got = append(got, sd)
}
}
Expect(got).To(HaveLen(1))
Expect(got[0].ItemID).To(Equal(turnID))
Expect(got[0].Detections).To(HaveLen(1))
Expect(got[0].Detections[0].Label).To(Equal("Dog bark"))
Expect(got[0].Detections[0].Score).To(BeNumerically("~", 0.8, 1e-6))
Expect(got[0].Detections[0].Index).To(Equal(5))
Expect(got[0].Start).NotTo(BeNil())
Expect(*got[0].Start).To(BeNumerically("~", 0.5, 1e-9))
Expect(got[0].End).NotTo(BeNil())
Expect(*got[0].End).To(BeNumerically("~", 0.9, 1e-9))
})
It("sends neither event when a live event carries no speakers or sounds", func() {
Expect(lts.openTurn(context.Background(), "item1")).To(BeTrue())
m.liveSession.onEvent(backend.LiveTranscriptionEvent{Delta: "hi"})
lts.drainEvents(1.0)
Expect(ftr.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionSegment)).To(Equal(0))
Expect(ftr.countEvents(types.ServerEventTypeConversationItemSoundDetection)).To(Equal(0))
})
})
})
// commitUtteranceWithTranscript routes the three transcript sources: the
@@ -3,6 +3,7 @@ package openai
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"os"
@@ -14,6 +15,69 @@ import (
"github.com/mudler/LocalAI/core/schema"
)
// ConversationItemSoundDetectionEvent gained optional Start/End (seconds)
// for the live scene-event path; the unary/windowed paths never set them,
// so existing consumers must see no start/end keys at all.
var _ = Describe("ConversationItemSoundDetectionEvent JSON", func() {
It("omits start and end when nil", func() {
ev := types.ConversationItemSoundDetectionEvent{
ItemID: "item1",
Detections: []types.SoundDetectionTag{{Label: "Speech", Score: 0.5, Index: 7}},
}
b, err := json.Marshal(ev)
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(json.Unmarshal(b, &got)).To(Succeed())
_, hasStart := got["start"]
_, hasEnd := got["end"]
Expect(hasStart).To(BeFalse())
Expect(hasEnd).To(BeFalse())
})
It("includes start and end when set", func() {
start, end := 0.5, 0.9
ev := types.ConversationItemSoundDetectionEvent{
ItemID: "item1",
Start: &start,
End: &end,
}
b, err := json.Marshal(ev)
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(json.Unmarshal(b, &got)).To(Succeed())
Expect(got["start"]).To(BeNumerically("~", 0.5, 1e-9))
Expect(got["end"]).To(BeNumerically("~", 0.9, 1e-9))
})
})
// ConversationItemInputAudioTranscriptionSegmentEvent.Start/End are plain
// float64 (no omitempty): a speaker segment starting at 0.0s must still
// carry "start" in the JSON, unlike the sound-detection event's optional
// pointer fields above.
var _ = Describe("ConversationItemInputAudioTranscriptionSegmentEvent JSON", func() {
It("marshals start:0 and end:1.5 even when start is the zero value", func() {
ev := types.ConversationItemInputAudioTranscriptionSegmentEvent{
ItemID: "item1",
Speaker: "1",
Start: 0,
End: 1.5,
}
b, err := json.Marshal(ev)
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(json.Unmarshal(b, &got)).To(Succeed())
_, hasStart := got["start"]
_, hasEnd := got["end"]
Expect(hasStart).To(BeTrue())
Expect(hasEnd).To(BeTrue())
Expect(got["start"]).To(BeNumerically("~", 0.0, 1e-9))
Expect(got["end"]).To(BeNumerically("~", 1.5, 1e-9))
})
})
// emitSoundDetection classifies a committed utterance and emits a single
// conversation.item.sound_detection event carrying the scored AudioSet tags.
var _ = Describe("emitSoundDetection", func() {
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
"github.com/mudler/LocalAI/core/schema"
)
// emitPrecomputedTranscription emits the transcription events for a turn
@@ -42,9 +43,10 @@ func emitPrecomputedTranscription(t Transport, itemID string, deltas []string, t
// a single completed event. delta and completed events share itemID.
func emitTranscription(ctx context.Context, t Transport, session *Session, itemID, audioPath string) (string, error) {
cfg := session.InputAudioTranscription
diarize := session.ModelConfig != nil && session.ModelConfig.Pipeline.Diarization
if session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamTranscription() {
final, err := session.ModelInterface.TranscribeStream(ctx, audioPath, cfg.Language, false, false, cfg.Prompt, func(delta string) {
final, err := session.ModelInterface.TranscribeStream(ctx, audioPath, cfg.Language, false, diarize, cfg.Prompt, func(delta string) {
_ = t.SendEvent(types.ConversationItemInputAudioTranscriptionDeltaEvent{
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
ItemID: itemID,
@@ -58,6 +60,11 @@ func emitTranscription(ctx context.Context, t Transport, session *Session, itemI
transcript := ""
if final != nil {
transcript = final.Text
if diarize {
if err := emitSpeakerSegments(t, itemID, final); err != nil {
return "", err
}
}
}
if err := t.SendEvent(types.ConversationItemInputAudioTranscriptionCompletedEvent{
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
@@ -71,13 +78,18 @@ func emitTranscription(ctx context.Context, t Transport, session *Session, itemI
}
// Unary fallback: transcribe the whole utterance, emit one completed event.
tr, err := session.ModelInterface.Transcribe(ctx, audioPath, cfg.Language, false, false, cfg.Prompt)
tr, err := session.ModelInterface.Transcribe(ctx, audioPath, cfg.Language, false, diarize, cfg.Prompt)
if err != nil {
return "", err
}
if tr == nil {
return "", fmt.Errorf("transcribe result is nil")
}
if diarize {
if err := emitSpeakerSegments(t, itemID, tr); err != nil {
return "", err
}
}
if err := t.SendEvent(types.ConversationItemInputAudioTranscriptionCompletedEvent{
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
ItemID: itemID,
@@ -88,3 +100,29 @@ func emitTranscription(ctx context.Context, t Transport, session *Session, itemI
}
return tr.Text, nil
}
// emitSpeakerSegments forwards each speaker-labelled segment of a committed
// turn's transcript as a conversation.item.input_audio_transcription.segment
// event (pipeline.diarization), before the turn's completed event. Times are
// relative to the turn's audio and speaker labels are only consistent within
// the turn, as on the live path. Segments without a speaker are skipped.
func emitSpeakerSegments(t Transport, itemID string, tr *schema.TranscriptionResult) error {
for _, seg := range tr.Segments {
if seg.Speaker == "" {
continue
}
if err := t.SendEvent(types.ConversationItemInputAudioTranscriptionSegmentEvent{
ServerEventBase: types.ServerEventBase{EventID: "event_TODO"},
ItemID: itemID,
ContentIndex: 0,
ID: fmt.Sprintf("seg_%d", seg.Id),
Speaker: seg.Speaker,
Start: seg.Start.Seconds(),
End: seg.End.Seconds(),
Text: seg.Text,
}); err != nil {
return err
}
}
return nil
}
@@ -2,6 +2,7 @@ package openai
import (
"context"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -51,4 +52,86 @@ var _ = Describe("emitTranscription", func() {
Expect(t.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionDelta)).To(Equal(0))
Expect(t.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionCompleted)).To(Equal(1))
})
Context("pipeline.diarization", func() {
labelled := &schema.TranscriptionResult{
Text: "hi there. hello",
Segments: []schema.TranscriptionSegment{
{Id: 0, Text: "hi there.", Start: 0, End: 600 * time.Millisecond, Speaker: "0"},
{Id: 1, Text: "hello", Start: time.Second, End: 1400 * time.Millisecond, Speaker: "1"},
{Id: 2, Text: "unlabelled"},
},
}
segmentEvents := func(t *fakeTransport) []types.ConversationItemInputAudioTranscriptionSegmentEvent {
var out []types.ConversationItemInputAudioTranscriptionSegmentEvent
for _, e := range t.sent {
if seg, ok := e.(types.ConversationItemInputAudioTranscriptionSegmentEvent); ok {
out = append(out, seg)
}
}
return out
}
It("requests speakers and emits one segment event per labelled segment", func() {
m := &fakeModel{transcribeFinal: labelled}
session := &Session{
InputAudioTranscription: &types.AudioTranscription{},
ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{Diarization: true}},
ModelInterface: m,
}
t := &fakeTransport{}
transcript, err := emitTranscription(context.Background(), t, session, "item1", "/tmp/x.wav")
Expect(err).ToNot(HaveOccurred())
Expect(transcript).To(Equal("hi there. hello"))
Expect(m.lastDiarize).To(BeTrue())
segs := segmentEvents(t)
Expect(segs).To(HaveLen(2))
Expect(segs[0].ItemID).To(Equal("item1"))
Expect(segs[0].Speaker).To(Equal("0"))
Expect(segs[0].Text).To(Equal("hi there."))
Expect(segs[1].Speaker).To(Equal("1"))
Expect(segs[1].Start).To(BeNumerically("~", 1.0, 1e-9))
Expect(segs[1].End).To(BeNumerically("~", 1.4, 1e-9))
Expect(t.countEvents(types.ServerEventTypeConversationItemInputAudioTranscriptionCompleted)).To(Equal(1))
})
It("also emits segment events on the streaming transcription path", func() {
on := true
m := &fakeModel{transcribeDeltas: []string{"hi"}, transcribeFinal: labelled}
session := &Session{
InputAudioTranscription: &types.AudioTranscription{},
ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{
Diarization: true,
Streaming: config.PipelineStreaming{Transcription: &on},
}},
ModelInterface: m,
}
t := &fakeTransport{}
_, err := emitTranscription(context.Background(), t, session, "item1", "/tmp/x.wav")
Expect(err).ToNot(HaveOccurred())
Expect(m.lastDiarize).To(BeTrue())
Expect(segmentEvents(t)).To(HaveLen(2))
})
It("neither asks for speakers nor emits segments when off", func() {
m := &fakeModel{transcribeFinal: labelled}
session := &Session{
InputAudioTranscription: &types.AudioTranscription{},
ModelConfig: &config.ModelConfig{},
ModelInterface: m,
}
t := &fakeTransport{}
_, err := emitTranscription(context.Background(), t, session, "item1", "/tmp/x.wav")
Expect(err).ToNot(HaveOccurred())
Expect(m.lastDiarize).To(BeFalse())
Expect(segmentEvents(t)).To(BeEmpty())
})
})
})
+14 -8
View File
@@ -210,18 +210,20 @@ func TranscriptEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, app
}
for _, word := range tr.Words {
trs.Words = append(trs.Words, schema.TranscriptionWordSeconds{
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Speaker: word.Speaker,
})
}
for _, seg := range tr.Segments {
segWords := []schema.TranscriptionWordSeconds{}
for _, word := range seg.Words {
segWords = append(segWords, schema.TranscriptionWordSeconds{
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Speaker: word.Speaker,
})
}
trs.Segments = append(trs.Segments, schema.TranscriptionSegmentSeconds{
@@ -338,12 +340,16 @@ func streamTranscription(c echo.Context, req backend.TranscriptionRequest, ml *m
if len(finalResult.Segments) > 0 {
segs := make([]map[string]any, 0, len(finalResult.Segments))
for _, seg := range finalResult.Segments {
segs = append(segs, map[string]any{
entry := map[string]any{
"id": seg.Id,
"start": seg.Start.Seconds(),
"end": seg.End.Seconds(),
"text": seg.Text,
})
}
if seg.Speaker != "" {
entry["speaker"] = seg.Speaker
}
segs = append(segs, entry)
}
doneEvent["segments"] = segs
}
@@ -512,6 +512,15 @@ type ConversationItemSoundDetectionEvent struct {
// The scored sound-event tags, in score-descending order.
Detections []SoundDetectionTag `json:"detections"`
// The start time of the detection window in seconds, when known. Set by
// the live scene-event path (a companion sound stream alongside live
// transcription); omitted by the unary/windowed sound-detection paths,
// which have no per-event timing.
Start *float64 `json:"start,omitempty"`
// The end time of the detection window in seconds, when known.
End *float64 `json:"end,omitempty"`
}
func (m ConversationItemSoundDetectionEvent) ServerEventType() ServerEventType {
@@ -586,11 +595,13 @@ type ConversationItemInputAudioTranscriptionSegmentEvent struct {
// The speaker label for the segment, if available.
Speaker string `json:"speaker,omitempty"`
// The start time of the segment in seconds.
Start float64 `json:"start,omitempty"`
// The start time of the segment in seconds. Always present (not
// omitempty: a segment starting at 0.0s must still carry "start").
Start float64 `json:"start"`
// The end time of the segment in seconds.
End float64 `json:"end,omitempty"`
// The end time of the segment in seconds. Always present (not
// omitempty: see Start).
End float64 `json:"end"`
// The text content of the segment.
Text string `json:"text,omitempty"`
@@ -0,0 +1,97 @@
import { test, expect } from './coverage-fixtures.js'
// Single-page PDF with a real text layer, built byte by byte so the xref
// offsets are valid and the spec needs no binary fixture.
function buildPdf(text) {
const stream = `BT /F1 18 Tf 20 100 Td (${text}) Tj ET`
const objs = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
]
let out = '%PDF-1.4\n'
const offsets = []
objs.forEach((body, i) => {
offsets.push(out.length)
out += `${i + 1} 0 obj\n${body}\nendobj\n`
})
const xref = out.length
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`
for (const o of offsets) out += `${String(o).padStart(10, '0')} 00000 n \n`
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
return Buffer.from(out, 'latin1')
}
async function openChat(page) {
await page.route('**/api/models/capabilities', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }),
})
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
}
test.describe('Chat - PDF attachments', () => {
test('sends the extracted text layer, not the raw PDF bytes', async ({ page }) => {
let requestBody = ''
await page.route('**/v1/chat/completions', (route) => {
requestBody = route.request().postData() || ''
route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'stop' } }) })
})
await openChat(page)
await page.locator('input[type=file]').setInputFiles({
name: 'report.pdf',
mimeType: 'application/pdf',
buffer: buildPdf('Quarterly revenue grew 42 percent'),
})
await expect(page.locator('.chat-file-name', { hasText: 'report.pdf' })).toBeVisible()
await page.locator('.chat-input').fill('Summarize')
await page.locator('.chat-send-btn').click()
await expect.poll(() => requestBody).toContain('Quarterly revenue grew 42 percent')
expect(requestBody).toContain('File: report.pdf')
expect(requestBody).not.toContain('%PDF')
})
test('rejects a PDF that cannot be parsed instead of attaching garbage', async ({ page }) => {
await openChat(page)
await page.locator('input[type=file]').setInputFiles({
name: 'broken.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 this is not a real document'),
})
await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.chat-file-name', { hasText: 'broken.pdf' })).toHaveCount(0)
})
})
test.describe('Home - PDF attachments', () => {
test('attaches a PDF that has a text layer', async ({ page }) => {
await page.goto('/app')
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'notes.pdf',
mimeType: 'application/pdf',
buffer: buildPdf('Meeting notes for Tuesday'),
})
await expect(page.locator('.home-file-tag', { hasText: 'notes.pdf' })).toBeVisible({ timeout: 10_000 })
})
test('rejects a PDF that cannot be parsed', async ({ page }) => {
await page.goto('/app')
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'broken.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 this is not a real document'),
})
await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.home-file-tag')).toHaveCount(0)
})
})
@@ -172,6 +172,19 @@ test.describe('Models lifecycle', () => {
await expect(installedPane(page)).toContainText('Worker one')
})
test('shows the decisions use case on a decision model', async ({ page }) => {
await page.route('**/api/models/capabilities', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
data: [...installedModels, { id: 'decider', backend: 'vllm-cpp', capabilities: ['FLAG_DECISIONS'] }],
}),
}))
await page.goto('/app/models?view=installed&model=decider')
await expect(installedPane(page)).toContainText('decider')
await expect(installedPane(page)).toContainText('Decisions')
})
test('stops a running model with confirmation', async ({ page }) => {
await page.goto('/app/models?view=installed&model=alpha')
+271
View File
@@ -29,6 +29,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.6",
"marked": "^15.0.7",
"pdfjs-dist": "^5.6.205",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^17.0.6",
@@ -1021,6 +1022,256 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -5219,6 +5470,13 @@
"node": ">=8"
}
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
@@ -5752,6 +6010,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pdfjs-dist": {
"version": "5.6.205",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.96",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -45,6 +45,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.6",
"marked": "^15.0.7",
"pdfjs-dist": "^5.6.205",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^17.0.6",
@@ -46,7 +46,7 @@
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
@@ -117,7 +117,8 @@
"copied": "Copied to clipboard",
"copyFailed": "Could not copy to clipboard",
"chatCopied": "Chat copied to clipboard",
"forked": "Created a new chat"
"forked": "Created a new chat",
"pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged."
},
"menu": {
"trigger": "Chats",
@@ -35,7 +35,8 @@
"enterToSend": "Enter to send",
"selectModelFirst": "Select a model first",
"sendMessage": "Send message",
"selectModelToast": "Please select a model first"
"selectModelToast": "Please select a model first",
"pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged."
},
"quickLinks": {
"manageByChat": "Manage by chat",
@@ -46,7 +46,7 @@
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
@@ -46,7 +46,7 @@
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
@@ -46,7 +46,7 @@
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
@@ -46,7 +46,7 @@
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
@@ -46,7 +46,7 @@
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
@@ -46,7 +46,7 @@
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
@@ -46,7 +46,7 @@
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
"rerank": "Rerank", "vad": "VAD", "score": "Score", "decisions": "Decisions"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
+8 -2
View File
@@ -9,6 +9,7 @@ import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/arti
import CanvasPanel from '../components/CanvasPanel'
import Toggle from '../components/Toggle'
import { fileToBase64, modelsApi, mcpApi } from '../utils/api'
import { readAttachmentText } from '../utils/pdf'
import { CAP_CHAT } from '../utils/capabilities'
import { useMCPClient } from '../hooks/useMCPClient'
import MCPAppFrame from '../components/MCPAppFrame'
@@ -842,13 +843,18 @@ export default function Chat() {
const base64 = await fileToBase64(file)
const entry = { name: file.name, type: file.type, base64 }
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) {
entry.textContent = await file.text().catch(() => '')
try {
entry.textContent = await readAttachmentText(file)
} catch {
addToast(t('toasts.pdfReadFailed', { name: file.name }), 'error')
continue
}
}
newFiles.push(entry)
}
setFiles(prev => [...prev, ...newFiles])
e.target.value = ''
}, [])
}, [addToast, t])
const handlePaste = useCallback(async (e) => {
const items = e.clipboardData?.items
+8 -2
View File
@@ -12,6 +12,7 @@ import HomeConnect from '../components/HomeConnect'
import { useResources } from '../hooks/useResources'
import { usePolling } from '../hooks/usePolling'
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
import { readAttachmentText } from '../utils/pdf'
import { API_CONFIG } from '../utils/config'
import { greetingKey } from '../utils/greeting'
import StatusPill from '../components/StatusPill'
@@ -158,12 +159,17 @@ export default function Home() {
const base64 = await fileToBase64(file)
const entry = { name: file.name, type: file.type, base64 }
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/')) {
entry.textContent = await file.text().catch(() => '')
try {
entry.textContent = await readAttachmentText(file)
} catch {
addToast(t('input.pdfReadFailed', { name: file.name }), 'error')
continue
}
}
newFiles.push(entry)
}
setter(prev => [...prev, ...newFiles])
}, [])
}, [addToast, t])
const removeFile = useCallback((file) => {
const removeFn = (prev) => prev.filter(f => f !== file)
@@ -22,7 +22,7 @@ import {
CAP_CHAT, CAP_COMPLETION, CAP_IMAGE, CAP_VIDEO, CAP_TTS,
CAP_TRANSCRIPT, CAP_SOUND_GENERATION, CAP_FACE_RECOGNITION,
CAP_SPEAKER_RECOGNITION, CAP_EMBEDDINGS, CAP_RERANK,
CAP_VAD, CAP_SCORE,
CAP_VAD, CAP_SCORE, CAP_DECISIONS,
} from '../utils/capabilities'
const USE_CASES = [
@@ -39,6 +39,7 @@ const USE_CASES = [
{ cap: CAP_RERANK, labelKey: 'rerank' },
{ cap: CAP_VAD, labelKey: 'vad' },
{ cap: CAP_SCORE, labelKey: 'score' },
{ cap: CAP_DECISIONS, labelKey: 'decisions' },
]
export function modelUseCases(model) {
+1
View File
@@ -29,4 +29,5 @@ export const CAP_SPEAKER_RECOGNITION = 'FLAG_SPEAKER_RECOGNITION'
export const CAP_AUDIO_TRANSFORM = 'FLAG_AUDIO_TRANSFORM'
export const CAP_REALTIME_AUDIO = 'FLAG_REALTIME_AUDIO'
export const CAP_SCORE = 'FLAG_SCORE'
export const CAP_DECISIONS = 'FLAG_DECISIONS'
export const CAP_TOKEN_CLASSIFY = 'FLAG_TOKEN_CLASSIFY'
+48
View File
@@ -0,0 +1,48 @@
export function isPdf(file) {
return file?.type === 'application/pdf' || /\.pdf$/i.test(file?.name || '')
}
// pdf.js and its worker are loaded on first use so the main bundle does not
// pay for them when nobody attaches a PDF.
async function loadPdfjs() {
const [pdfjs, worker] = await Promise.all([
import('pdfjs-dist'),
import('pdfjs-dist/build/pdf.worker.min.mjs?url'),
])
pdfjs.GlobalWorkerOptions.workerSrc = worker.default
return pdfjs
}
// Returns the text layer of every page, one block per page. Throws when the
// file cannot be parsed or has no text layer (scanned PDFs): sending an empty
// attachment to the model would look like success and silently lose the file.
export async function extractPdfText(file) {
const pdfjs = await loadPdfjs()
const data = new Uint8Array(await file.arrayBuffer())
const doc = await pdfjs.getDocument({ data }).promise
try {
const pages = []
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i)
const content = await page.getTextContent()
let text = ''
for (const item of content.items) {
text += item.str
text += item.hasEOL ? '\n' : ''
}
pages.push(text.trim())
}
const text = pages.filter(Boolean).join('\n\n')
if (!text) throw new Error('PDF has no extractable text')
return text
} finally {
await doc.destroy()
}
}
// Text of an attached non-media file. PDFs go through pdf.js; everything else
// is read as UTF-8.
export async function readAttachmentText(file) {
if (isPdf(file)) return extractPdfText(file)
return file.text().catch(() => '')
}
+8 -6
View File
@@ -13,9 +13,10 @@ type TranscriptionSegment struct {
}
type TranscriptionWord struct {
Start time.Duration `json:"start"`
End time.Duration `json:"end"`
Text string `json:"text"`
Start time.Duration `json:"start"`
End time.Duration `json:"end"`
Text string `json:"text"`
Speaker string `json:"speaker,omitempty"`
}
type TranscriptionResult struct {
@@ -42,9 +43,10 @@ type TranscriptionSegmentSeconds struct {
}
type TranscriptionWordSeconds struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Speaker string `json:"speaker,omitempty"`
}
type TranscriptionResultSeconds struct {
@@ -0,0 +1,304 @@
package agentpool_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"github.com/mudler/LocalAGI/core/sse"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/agentpool"
. "github.com/onsi/gomega"
)
type fakeLLMRequest struct {
Model string
Stream bool
Messages []map[string]any
Tools []string
ToolChoice any
}
// fakeLLM is an OpenAI-compatible chat endpoint. The standalone pool reaches its
// LLM over HTTP (apiURL), so a real server is the only seam that exercises the
// whole agent loop without a model.
type fakeLLM struct {
srv *httptest.Server
mu sync.Mutex
reply string
requests []fakeLLMRequest
toolName string
toolArgs string
// failStatus, when non-zero, makes every chat completion fail with that
// HTTP status so a spec can drive the agent's error path.
failStatus int
}
func newFakeLLM(reply string) *fakeLLM {
f := &fakeLLM{reply: reply}
f.srv = httptest.NewServer(http.HandlerFunc(f.handle))
return f
}
func (f *fakeLLM) URL() string { return f.srv.URL }
func (f *fakeLLM) Close() { f.srv.Close() }
func (f *fakeLLM) SetReply(r string) {
f.mu.Lock()
defer f.mu.Unlock()
f.reply = r
}
// SetToolCall makes the fake answer with one call to the named function until
// the conversation carries a tool result, then with the plain reply. Keying on
// the tool message rather than a request counter keeps the fake independent of
// how many planning requests the agent makes before it runs the tool. Only
// the LocalAGI counter-action specs use it today; P2 keeps it for the MCP
// tool fixture that replaces them, since the native executor ignores
// Actions. The
// flip side: tool mode stays on until a request carries a role "tool"
// message, so a client that restarts with trimmed history would get the
// tool call again and loop until its iteration cap.
func (f *fakeLLM) SetToolCall(name, argsJSON string) {
f.mu.Lock()
defer f.mu.Unlock()
f.toolName = name
f.toolArgs = argsJSON
}
// SetFailure makes every chat completion answer with the given HTTP status and
// an OpenAI-style error body, which is what an unreachable or broken backend
// looks like to the agent. Requests are still recorded so a spec can count
// retries.
func (f *fakeLLM) SetFailure(status int) {
f.mu.Lock()
defer f.mu.Unlock()
f.failStatus = status
}
func (f *fakeLLM) Requests() []fakeLLMRequest {
f.mu.Lock()
defer f.mu.Unlock()
return append([]fakeLLMRequest(nil), f.requests...)
}
func (f *fakeLLM) handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
Model string `json:"model"`
Stream bool `json:"stream"`
Messages []map[string]any `json:"messages"`
Tools []struct {
Function struct {
Name string `json:"name"`
} `json:"function"`
} `json:"tools"`
ToolChoice any `json:"tool_choice"`
}
_ = json.Unmarshal(body, &req)
var tools []string
for _, t := range req.Tools {
tools = append(tools, t.Function.Name)
}
hasToolResult := false
for _, m := range req.Messages {
if m["role"] == "tool" {
hasToolResult = true
}
}
f.mu.Lock()
f.requests = append(f.requests, fakeLLMRequest{Model: req.Model, Stream: req.Stream, Messages: req.Messages, Tools: tools, ToolChoice: req.ToolChoice})
reply := f.reply
failStatus := f.failStatus
var toolCall map[string]any
if f.toolName != "" && !hasToolResult {
toolCall = map[string]any{
"index": 0, "id": "call_fake", "type": "function",
"function": map[string]any{"name": f.toolName, "arguments": f.toolArgs},
}
}
f.mu.Unlock()
if failStatus != 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(failStatus)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"message": "fake backend failure", "type": "server_error", "code": failStatus},
})
return
}
message := map[string]any{"role": "assistant", "content": reply}
finish := "stop"
if toolCall != nil {
// "index" belongs only to streaming deltas, so the non-streaming
// message carries a copy without it.
plain := map[string]any{}
for k, v := range toolCall {
if k != "index" {
plain[k] = v
}
}
message = map[string]any{"role": "assistant", "content": "", "tool_calls": []map[string]any{plain}}
finish = "tool_calls"
}
if !req.Stream {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-fake", "object": "chat.completion", "model": req.Model,
"choices": []map[string]any{{
"index": 0,
"message": message,
"finish_reason": finish,
}},
"usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
return
}
w.Header().Set("Content-Type", "text/event-stream")
// A write error means the client went away mid-stream; the handler just
// stops writing so a disconnect never blocks or fails the fake.
write := func(s string) bool {
_, err := io.WriteString(w, s)
return err == nil
}
chunk := func(delta map[string]any, finish any) bool {
b, _ := json.Marshal(map[string]any{
"id": "chatcmpl-fake", "object": "chat.completion.chunk", "model": req.Model,
"choices": []map[string]any{{"index": 0, "delta": delta, "finish_reason": finish}},
})
return write(fmt.Sprintf("data: %s\n\n", b))
}
first := map[string]any{"role": "assistant", "content": reply}
if toolCall != nil {
first = map[string]any{"role": "assistant", "tool_calls": []map[string]any{toolCall}}
}
if !chunk(first, nil) || !chunk(map[string]any{}, finish) || !write("data: [DONE]\n\n") {
return
}
if fl, ok := w.(http.Flusher); ok {
fl.Flush()
}
}
// startStandalone boots a real standalone AgentPoolService on stateDir with its
// LLM pointed at llmURL. It registers no cleanup itself: callers own Stop().
func startStandalone(stateDir, llmURL string) *agentpool.AgentPoolService {
cfg := config.NewApplicationConfig()
cfg.AgentPool = config.AgentPoolConfig{
Enabled: true,
StateDir: stateDir,
APIURL: llmURL,
DefaultModel: "fake-model",
Timeout: "30s",
}
svc, err := agentpool.NewAgentPoolService(cfg)
Expect(err).ToNot(HaveOccurred())
Expect(svc.Start(context.Background())).To(Succeed())
return svc
}
func newAgentConfig(name string) *state.AgentConfig {
return &state.AgentConfig{
Name: name,
Model: "fake-model",
Description: "contract test agent",
SystemPrompt: "You are a test agent.",
}
}
// Engine-specific: awaitRunning and collectSSE reach into LocalAGI types
// (agent.Agent via GetAgentForUser, types.NewJob, sse.Manager and
// sse.NewClient). P1 must re-seat them when LocalAGI types leave the service
// signatures; the specs that call them should not need to change.
// awaitRunning blocks until the agent's Run loop is serving jobs. The pool
// starts Run in a goroutine and LocalAGI's Scheduler.Start and Scheduler.Stop
// are unsynchronized: a Stop (update, delete, svc.Stop) that lands while Start
// is still running can nil the scheduler context under the poll goroutine and
// crash the test binary. Run starts its workers only after Scheduler.Start has
// returned, and jobQueue is unbuffered, so Execute returning proves Start is
// done. The job's context is already cancelled, so the worker finishes it as
// expired without calling the LLM or recording an observable. This depends on
// LocalAGI not short-circuiting a cancelled job before a worker receives it,
// so re-check it when LocalAGI is bumped; the timeout turns a hang into a
// failure if that ever changes.
func awaitRunning(svc *agentpool.AgentPoolService, userID, name string) {
a := svc.GetAgentForUser(userID, name)
Expect(a).ToNot(BeNil())
ctx, cancel := context.WithCancel(context.Background())
cancel()
done := make(chan struct{})
go func() {
defer close(done)
a.Execute(types.NewJob(types.WithContext(ctx)))
}()
Eventually(done, "10s").Should(BeClosed())
}
type sseEvent struct {
Name string
Data map[string]any
}
// collectSSE registers a listener on the agent's SSE manager and records every
// event until stop is called. Subscribe before Chat so nothing is missed.
// The manager replays its last 10 events on Register, so a spec that chats
// twice with a fresh collector sees the first turn's completed status too.
func collectSSE(svc *agentpool.AgentPoolService, userID, name string) (events func() []sseEvent, stop func()) {
mgr := svc.GetSSEManagerForUser(userID, name)
Expect(mgr).ToNot(BeNil())
// Include the user: the manager keys listeners by ID, so two users'
// same-named agents must never share one if a manager is ever shared.
client := sse.NewClient("contract-" + userID + "-" + name)
mgr.Register(client)
var mu sync.Mutex
var got []sseEvent
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case env, ok := <-client.Chan():
if !ok {
return
}
ev := sseEvent{}
for _, line := range strings.Split(env.String(), "\n") {
switch {
case strings.HasPrefix(line, "event:"):
ev.Name = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
// The parse error is ignored because the hud event's
// data is not JSON; those events keep only their name.
_ = json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &ev.Data)
}
}
mu.Lock()
got = append(got, ev)
mu.Unlock()
}
}
}()
return func() []sseEvent {
mu.Lock()
defer mu.Unlock()
return append([]sseEvent(nil), got...)
}, func() {
close(done)
mgr.Unregister(client.ID())
}
}
@@ -0,0 +1,157 @@
package agentpool_test
import (
"net/http"
"github.com/mudler/LocalAI/core/services/agentpool"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("standalone chat contract", func() {
var (
llm *fakeLLM
svc *agentpool.AgentPoolService
)
BeforeEach(func() {
llm = newFakeLLM("pong")
// Ginkgo runs cleanups LIFO: registering Close first stops the pool
// before its LLM goes away.
DeferCleanup(llm.Close)
svc = startStandalone(GinkgoT().TempDir(), llm.URL())
DeferCleanup(svc.Stop)
Expect(svc.CreateAgentForUser("alice", newAgentConfig("chatty"))).To(Succeed())
awaitRunning(svc, "alice", "chatty")
})
It("streams the user message, processing, agent reply and completed status over SSE", func() {
events, stop := collectSSE(svc, "alice", "chatty")
defer stop()
msgID, err := svc.ChatForUser("alice", "chatty", "ping")
Expect(err).ToNot(HaveOccurred())
Expect(msgID).ToNot(BeEmpty())
Eventually(func() []sseEvent { return statusEvents(events(), "completed") }, "30s", "100ms").
ShouldNot(BeEmpty(), "no completed status event")
var user, agent, processing bool
for _, e := range events() {
switch {
case e.Name == "json_message" && e.Data["sender"] == "user":
Expect(e.Data["content"]).To(Equal("ping"))
user = true
case e.Name == "json_message" && e.Data["sender"] == "agent":
Expect(e.Data["content"]).To(ContainSubstring("pong"))
// Current standalone shape: the reply id is the id ChatForUser
// returned plus "-agent". The UI correlates on message_id
// (AgentChat.jsx), which the distributed dispatcher sends; a
// native engine may send either, so flip this deliberately.
Expect(e.Data).To(HaveKeyWithValue("id", msgID+"-agent"))
agent = true
case e.Name == "json_message_status" && e.Data["status"] == "processing":
processing = true
}
}
Expect(user).To(BeTrue(), "user json_message")
Expect(processing).To(BeTrue(), "processing status")
Expect(agent).To(BeTrue(), "agent json_message")
})
It("sends the user's message to the LLM under the configured model", func() {
_, err := svc.ChatForUser("alice", "chatty", "ping")
Expect(err).ToNot(HaveOccurred())
Eventually(llm.Requests, "30s", "100ms").ShouldNot(BeEmpty())
req := llm.Requests()[0]
Expect(req.Model).To(Equal("fake-model"))
// Observed shape: content is a plain string, not a parts array. A
// switch to parts would change what an OpenAI-compatible backend sees.
Expect(req.Messages).To(ContainElement(And(
HaveKeyWithValue("role", "user"),
HaveKeyWithValue("content", "ping"),
)))
})
// The chat page clears its "processing" state only on an agent
// json_message or a json_error, so a failed turn must end in json_error
// followed by completed, never in silence. The fake fails every request;
// cogito retries the decision 5 times with a linear 1s..5s backoff, so the
// turn settles after about 15s, hence the 60s budget. The error text is
// cogito's wrapped chain and is not pinned.
It("reports a failing LLM as json_error then completed, with no agent reply", func() {
llm.SetFailure(http.StatusInternalServerError)
events, stop := collectSSE(svc, "alice", "chatty")
defer stop()
_, err := svc.ChatForUser("alice", "chatty", "ping")
Expect(err).ToNot(HaveOccurred())
Eventually(func() []sseEvent { return statusEvents(events(), "completed") }, "60s", "100ms").
ShouldNot(BeEmpty(), "no completed status event")
errorAt, completedAt := -1, -1
for i, e := range events() {
switch {
case e.Name == "json_error" && errorAt < 0:
errorAt = i
Expect(e.Data).To(HaveKeyWithValue("error", And(BeAssignableToTypeOf(""), Not(BeEmpty()))))
case e.Name == "json_message_status" && e.Data["status"] == "completed" && completedAt < 0:
completedAt = i
case e.Name == "json_message" && e.Data["sender"] == "agent":
Fail("a failed turn must not produce an agent json_message")
}
}
Expect(errorAt).To(BeNumerically(">=", 0), "no json_error event")
Expect(errorAt).To(BeNumerically("<", completedAt), "json_error must precede completed")
Expect(llm.Requests()).ToNot(BeEmpty())
})
It("reports chat with an unknown agent as ErrAgentNotFound", func() {
_, err := svc.ChatForUser("alice", "ghost", "hi")
Expect(err).To(MatchError(agentpool.ErrAgentNotFound))
})
It("reports chat with a deleted agent as ErrAgentNotFound", func() {
Expect(svc.DeleteAgentForUser("alice", "chatty")).To(Succeed())
_, err := svc.ChatForUser("alice", "chatty", "hi")
Expect(err).To(MatchError(agentpool.ErrAgentNotFound))
})
It("does not deliver one user's chat events to another user's agent of the same name", func() {
Expect(svc.CreateAgentForUser("bob", newAgentConfig("chatty"))).To(Succeed())
awaitRunning(svc, "bob", "chatty")
aliceEvents, stopAlice := collectSSE(svc, "alice", "chatty")
defer stopAlice()
bobEvents, stopBob := collectSSE(svc, "bob", "chatty")
defer stopBob()
_, err := svc.ChatForUser("alice", "chatty", "ping")
Expect(err).ToNot(HaveOccurred())
Eventually(func() []sseEvent { return statusEvents(aliceEvents(), "completed") }, "30s", "100ms").ShouldNot(BeEmpty())
// LocalAGI pushes a "hud" snapshot of each agent's own state every
// second, so bob's stream is not silent; what must never reach it is
// anything produced by alice's chat.
Consistently(func() []string {
var names []string
for _, e := range bobEvents() {
if e.Name != "hud" {
names = append(names, e.Name)
}
}
return names
}, "1s", "100ms").Should(BeEmpty())
})
})
func statusEvents(events []sseEvent, status string) []sseEvent {
var out []sseEvent
for _, e := range events {
if e.Name == "json_message_status" && e.Data["status"] == status {
out = append(out, e)
}
}
return out
}
@@ -0,0 +1,186 @@
package agentpool_test
import (
"encoding/json"
"github.com/mudler/LocalAI/core/services/agentpool"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("standalone agent service contract", func() {
var (
llm *fakeLLM
dir string
)
BeforeEach(func() {
llm = newFakeLLM("hello from the fake model")
// DeferCleanup runs after AfterEach and in LIFO order, so registering
// here lets a pool started later stop before its LLM goes away.
DeferCleanup(llm.Close)
dir = GinkgoT().TempDir()
})
It("boots against a fake LLM and lists no agents", func() {
svc := startStandalone(dir, llm.URL())
DeferCleanup(svc.Stop)
Expect(svc.ListAgentsForUser("")).To(BeEmpty())
})
Context("agent CRUD", func() {
var svc *agentpool.AgentPoolService
BeforeEach(func() {
svc = startStandalone(dir, llm.URL())
DeferCleanup(svc.Stop)
})
It("creates, reads back, updates and deletes an agent", func() {
Expect(svc.CreateAgentForUser("alice", newAgentConfig("helper"))).To(Succeed())
awaitRunning(svc, "alice", "helper")
got := svc.GetAgentConfigForUser("alice", "helper")
Expect(got).ToNot(BeNil())
// The pool stores the key ("alice:helper"); the API must show the bare name.
Expect(got.Name).To(Equal("helper"))
Expect(got.Model).To(Equal("fake-model"))
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("helper", true))
updated := newAgentConfig("helper")
updated.Description = "changed"
Expect(svc.UpdateAgentForUser("alice", "helper", updated)).To(Succeed())
// Update restarts the agent, so the new instance needs the same wait.
awaitRunning(svc, "alice", "helper")
Expect(svc.GetAgentConfigForUser("alice", "helper").Description).To(Equal("changed"))
Expect(svc.DeleteAgentForUser("alice", "helper")).To(Succeed())
Expect(svc.GetAgentConfigForUser("alice", "helper")).To(BeNil())
Expect(svc.ListAgentsForUser("alice")).ToNot(HaveKey("helper"))
})
It("reports an update of a missing agent as ErrAgentNotFound", func() {
err := svc.UpdateAgentForUser("alice", "ghost", newAgentConfig("ghost"))
Expect(err).To(MatchError(agentpool.ErrAgentNotFound))
})
It("keeps two users' agents with the same name apart", func() {
a := newAgentConfig("shared-name")
a.Description = "alice's"
b := newAgentConfig("shared-name")
b.Description = "bob's"
Expect(svc.CreateAgentForUser("alice", a)).To(Succeed())
Expect(svc.CreateAgentForUser("bob", b)).To(Succeed())
awaitRunning(svc, "alice", "shared-name")
awaitRunning(svc, "bob", "shared-name")
Expect(svc.GetAgentConfigForUser("alice", "shared-name").Description).To(Equal("alice's"))
Expect(svc.GetAgentConfigForUser("bob", "shared-name").Description).To(Equal("bob's"))
Expect(svc.DeleteAgentForUser("alice", "shared-name")).To(Succeed())
Expect(svc.GetAgentConfigForUser("alice", "shared-name")).To(BeNil())
Expect(svc.GetAgentConfigForUser("bob", "shared-name")).ToNot(BeNil())
grouped := svc.ListAllAgentsGrouped()
Expect(grouped).To(HaveKey("bob"))
Expect(grouped).ToNot(HaveKey("alice"))
})
It("round-trips a config through export and import without a user", func() {
cfg := newAgentConfig("portable")
cfg.Description = "carry me"
Expect(svc.CreateAgentForUser("", cfg)).To(Succeed())
awaitRunning(svc, "", "portable")
data, err := svc.ExportAgentForUser("", "portable")
Expect(err).ToNot(HaveOccurred())
Expect(svc.DeleteAgentForUser("", "portable")).To(Succeed())
Expect(svc.ImportAgentForUser("", data)).To(Succeed())
awaitRunning(svc, "", "portable")
got := svc.GetAgentConfigForUser("", "portable")
Expect(got).ToNot(BeNil())
Expect(got.Description).To(Equal("carry me"))
})
// Known defect pinned on purpose: export returns the stored config, whose
// name is the pool key, and import refuses ":" in names. A rewrite that
// fixes this must flip this spec rather than silently change behavior.
It("known defect: exports a user's agent under its pool key, which import then rejects", func() {
cfg := newAgentConfig("portable")
cfg.Description = "carry me"
Expect(svc.CreateAgentForUser("alice", cfg)).To(Succeed())
awaitRunning(svc, "alice", "portable")
data, err := svc.ExportAgentForUser("alice", "portable")
Expect(err).ToNot(HaveOccurred())
var out map[string]any
Expect(json.Unmarshal(data, &out)).To(Succeed())
Expect(out["name"]).To(Equal("alice:portable"))
Expect(svc.DeleteAgentForUser("alice", "portable")).To(Succeed())
Expect(svc.ImportAgentForUser("alice", data)).To(MatchError(ContainSubstring("invalid characters")))
Expect(svc.GetAgentConfigForUser("alice", "portable")).To(BeNil())
})
// Engine-specific: P2/P5 flips this because the native import drops
// unknown fields (connectors, actions) with a warning, so the export
// will no longer carry them.
// P5 strips connectors and actions and the P2 migration reads old configs,
// so record what a config that carries them looks like today. LocalAGI
// logs "Failed to create IRC client" for this fixture because the IRC
// config has no nickname; that is expected and is not a failure.
It("accepts and returns a config that carries connectors and actions", func() {
raw := []byte(`{
"name": "legacy",
"model": "fake-model",
"description": "old style",
"connectors": [{"type": "irc", "config": "{}"}],
"actions": [{"name": "search", "config": "{}"}]
}`)
Expect(svc.ImportAgentForUser("alice", raw)).To(Succeed())
awaitRunning(svc, "alice", "legacy")
data, err := svc.ExportAgentForUser("alice", "legacy")
Expect(err).ToNot(HaveOccurred())
var out map[string]any
Expect(json.Unmarshal(data, &out)).To(Succeed())
Expect(out["connectors"]).To(HaveLen(1))
Expect(out["actions"]).To(HaveLen(1))
// The P2 migration reads these element shapes. The action name is
// what LocalAGI actually stores, observed as the name sent in, not a
// resolved alias.
Expect(out["connectors"]).To(ConsistOf(And(
HaveKeyWithValue("type", "irc"),
HaveKeyWithValue("config", BeAssignableToTypeOf("")),
)))
Expect(out["actions"]).To(ConsistOf(And(
HaveKeyWithValue("name", "search"),
HaveKeyWithValue("config", BeAssignableToTypeOf("")),
)))
})
})
Context("pause and resume", func() {
It("toggles the active flag reported by the list", func() {
svc := startStandalone(dir, llm.URL())
DeferCleanup(svc.Stop)
Expect(svc.CreateAgentForUser("alice", newAgentConfig("napper"))).To(Succeed())
awaitRunning(svc, "alice", "napper")
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("napper", true))
Expect(svc.PauseAgentForUser("alice", "napper")).To(Succeed())
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("napper", false))
Expect(svc.ResumeAgentForUser("alice", "napper")).To(Succeed())
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("napper", true))
})
It("reports pausing a missing agent as ErrAgentNotFound", func() {
svc := startStandalone(dir, llm.URL())
DeferCleanup(svc.Stop)
Expect(svc.PauseAgentForUser("alice", "ghost")).To(MatchError(agentpool.ErrAgentNotFound))
})
})
})
@@ -0,0 +1,101 @@
package agentpool_test
import (
"encoding/json"
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("standalone persistence contract", func() {
var (
llm *fakeLLM
dir string
)
BeforeEach(func() {
llm = newFakeLLM("ok")
dir = GinkgoT().TempDir()
DeferCleanup(llm.Close)
})
// The P2 migration imports this file, so its layout is an interface.
It("writes pool.json keyed by userID:name with the agent config as value", func() {
svc := startStandalone(dir, llm.URL())
Expect(svc.CreateAgentForUser("alice", newAgentConfig("keeper"))).To(Succeed())
awaitRunning(svc, "alice", "keeper")
Expect(svc.CreateAgentForUser("", newAgentConfig("anon"))).To(Succeed())
awaitRunning(svc, "", "anon")
svc.Stop()
raw, err := os.ReadFile(filepath.Join(dir, "pool.json"))
Expect(err).ToNot(HaveOccurred())
var pool map[string]map[string]any
Expect(json.Unmarshal(raw, &pool)).To(Succeed())
Expect(pool).To(HaveKey("alice:keeper"))
Expect(pool).To(HaveKey("anon"))
Expect(pool["alice:keeper"]["model"]).To(Equal("fake-model"))
// The stored name repeats the key, prefix included: the P2 importer
// strips the prefix, so it depends on this.
Expect(pool["alice:keeper"]["name"]).To(Equal("alice:keeper"))
Expect(pool["anon"]["name"]).To(Equal("anon"))
})
It("restores agents after a restart on the same state dir", func() {
svc := startStandalone(dir, llm.URL())
Expect(svc.CreateAgentForUser("alice", newAgentConfig("survivor"))).To(Succeed())
awaitRunning(svc, "alice", "survivor")
svc.Stop()
again := startStandalone(dir, llm.URL())
DeferCleanup(again.Stop)
awaitRunning(again, "alice", "survivor")
Expect(again.GetAgentConfigForUser("alice", "survivor")).ToNot(BeNil())
Expect(again.ListAgentsForUser("alice")).To(HaveKey("survivor"))
})
// Pause is only an in-memory flag on the running agent: pool.json has no
// status field and no per-agent file records pause, so a restart brings
// the agent back active. Pinned as a known gap for the native-store migration to
// close on purpose rather than by accident.
It("known gap: does not keep a paused agent paused across a restart", func() {
svc := startStandalone(dir, llm.URL())
Expect(svc.CreateAgentForUser("alice", newAgentConfig("sleeper"))).To(Succeed())
awaitRunning(svc, "alice", "sleeper")
Expect(svc.PauseAgentForUser("alice", "sleeper")).To(Succeed())
Expect(svc.ListAgentsForUser("alice")).To(HaveKeyWithValue("sleeper", false))
svc.Stop()
again := startStandalone(dir, llm.URL())
DeferCleanup(again.Stop)
awaitRunning(again, "alice", "sleeper")
Expect(again.ListAgentsForUser("alice")).To(HaveKeyWithValue("sleeper", true))
})
// The /v1/responses interceptor decides "is this model an agent" with
// GetAgent(name) using the raw pool key, with no user prefix.
// Known gap, not a contract: any caller who sends model "alice:mine" runs
// alice's agent (the interceptor has no user check), while alice's own
// request for "mine" falls through; a later fix must not read as a break.
It("known gap: resolves an agent by its raw key for the responses interceptor", func() {
svc := startStandalone(dir, llm.URL())
DeferCleanup(svc.Stop)
Expect(svc.CreateAgentForUser("", newAgentConfig("global-agent"))).To(Succeed())
awaitRunning(svc, "", "global-agent")
Expect(svc.CreateAgentForUser("alice", newAgentConfig("mine"))).To(Succeed())
awaitRunning(svc, "alice", "mine")
Expect(svc.GetAgent("global-agent")).ToNot(BeNil())
Expect(svc.GetAgent("alice:mine")).ToNot(BeNil())
Expect(svc.GetAgent("mine")).To(BeNil(), "current gap: a user's own agent is not found by its bare name, only by its pool key")
Expect(svc.GetAgent("nope")).To(BeNil())
})
It("exposes the state dir it was started with", func() {
svc := startStandalone(dir, llm.URL())
DeferCleanup(svc.Stop)
Expect(svc.StateDir()).To(Equal(dir))
})
})
@@ -0,0 +1,232 @@
package agentpool_test
import (
"encoding/json"
"fmt"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAI/core/services/agentpool"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("standalone status and observables contract", func() {
var (
llm *fakeLLM
svc *agentpool.AgentPoolService
)
BeforeEach(func() {
llm = newFakeLLM("done")
// Cleanups run in reverse, so the pool stops before the fake LLM goes away.
DeferCleanup(llm.Close)
svc = startStandalone(GinkgoT().TempDir(), llm.URL())
DeferCleanup(svc.Stop)
Expect(svc.CreateAgentForUser("alice", newAgentConfig("observed"))).To(Succeed())
awaitRunning(svc, "alice", "observed")
})
// runOnce chats once and waits for the completed status. ChatForUser sends
// that status as soon as Ask returns, before the job finalizers have written
// the finished observable, so callers that read observables use
// settledObservable instead.
runOnce := func() {
events, stop := collectSSE(svc, "alice", "observed")
defer stop()
_, err := svc.ChatForUser("alice", "observed", "go")
Expect(err).ToNot(HaveOccurred())
Eventually(func() []sseEvent { return statusEvents(events(), "completed") }, "30s", "100ms").
ShouldNot(BeEmpty(), "no completed status event")
}
// settleRun chats once with the named agent and returns its observables
// and SSE events after the job finalizers are done. Three observer.Update
// calls follow Finish (the Execute finalizer, the consumeJob finalizer and
// the deferred MakeLastProgressCompletion update, LocalAGI agent.go
// 1182-1187), and Update re-appends an observable whose id is gone, so
// clearing while one is still pending would bring the observable back. Each Update also sends an observable_update event, so the run is
// settled once the root observable carries a completion, the completed
// status went out, and no further observable_update arrives.
settleRun := func(name string) ([]map[string]any, []sseEvent) {
events, stop := collectSSE(svc, "alice", name)
defer stop()
_, err := svc.ChatForUser("alice", name, "go")
Expect(err).ToNot(HaveOccurred())
Eventually(func() []sseEvent { return statusEvents(events(), "completed") }, "30s", "100ms").
ShouldNot(BeEmpty(), "no completed status event")
Eventually(func(g Gomega) {
raw, err := svc.GetAgentObservablesForUser("alice", name)
g.Expect(err).ToNot(HaveOccurred())
rootDone := false
for _, r := range raw {
var o map[string]any
g.Expect(json.Unmarshal(r, &o)).To(Succeed())
if _, child := o["parent_id"]; !child {
_, rootDone = o["completion"]
}
}
g.Expect(rootDone).To(BeTrue())
}, "30s", "100ms").Should(Succeed())
updates := func() int {
n := 0
for _, e := range events() {
if e.Name == "observable_update" {
n++
}
}
return n
}
var last int
Eventually(func() bool {
n := updates()
stable := n == last
last = n
return stable
}, "10s", "300ms").Should(BeTrue())
Consistently(updates, "300ms", "50ms").Should(Equal(last))
raw, err := svc.GetAgentObservablesForUser("alice", name)
Expect(err).ToNot(HaveOccurred())
obs := make([]map[string]any, len(raw))
for i, r := range raw {
Expect(json.Unmarshal(r, &obs[i])).To(Succeed())
}
return obs, events()
}
// settledObservable returns the first observable of a settled plain run.
settledObservable := func() map[string]any {
obs, _ := settleRun("observed")
Expect(obs).ToNot(BeEmpty())
return obs[0]
}
It("returns an empty observable list before any run", func() {
obs, err := svc.GetAgentObservablesForUser("alice", "observed")
Expect(err).ToNot(HaveOccurred())
Expect(obs).To(BeEmpty())
})
// Discovery: a plain-content reply (no tool call) is enough for LocalAGI
// to record a "job" observable, so the action fallback was not needed.
// parent_id is not pinned: it is omitempty and a root job has none.
It("records observables after a run with the fields the agent status UI reads", func() {
first := settledObservable()
Expect(first).To(HaveKey("id"))
Expect(first).To(HaveKey("creation"))
Expect(first).To(HaveKey("completion"))
})
It("clears observables", func() {
settledObservable()
Expect(svc.ClearAgentObservablesForUser("alice", "observed")).To(Succeed())
obs, err := svc.GetAgentObservablesForUser("alice", "observed")
Expect(err).ToNot(HaveOccurred())
Expect(obs).To(BeEmpty())
})
It("reports observables of a missing agent as ErrAgentNotFound", func() {
_, err := svc.GetAgentObservablesForUser("alice", "ghost")
Expect(err).To(MatchError(agentpool.ErrAgentNotFound))
Expect(svc.ClearAgentObservablesForUser("alice", "ghost")).To(MatchError(agentpool.ErrAgentNotFound))
})
// LocalAGI only creates a status entry when an action result is recorded,
// so an agent whose runs never called an action looks the same as a
// missing one. Pinned as current behavior, not as a desirable contract.
It("returns a nil status for an agent with no action results, as for a missing one", func() {
Expect(svc.GetAgentStatusForUser("alice", "observed")).To(BeNil())
runOnce()
Consistently(func() any { return svc.GetAgentStatusForUser("alice", "observed") }, "500ms", "100ms").Should(BeNil())
Expect(svc.GetAgentStatusForUser("alice", "ghost")).To(BeNil())
})
// Engine-specific: P2 rewrites these three specs because they depend on
// the LocalAGI counter action and the native executor ignores Actions;
// P2 swaps in an MCP tool fixture. The status spec also reads
// types.ActionState, a LocalAGI type that P1 must re-seat when LocalAGI
// types leave the service signatures.
Context("after a run that calls a tool", func() {
BeforeEach(func() {
cfg := newAgentConfig("tooled")
// counter is pure and in-memory, so the action result is
// deterministic without any external service.
cfg.Actions = []state.ActionsConfig{{Name: "counter", Config: "{}"}}
Expect(svc.CreateAgentForUser("alice", cfg)).To(Succeed())
awaitRunning(svc, "alice", "tooled")
llm.SetToolCall("counter", `{"name":"contract","adjustment":1}`)
})
It("records a status entry the status endpoint renders with action, params and result", func() {
settleRun("tooled")
st := svc.GetAgentStatusForUser("alice", "tooled")
Expect(st).ToNot(BeNil())
// Select by action name rather than position: the order of
// status entries is a LocalAGI detail, not part of the contract.
var h types.ActionState
Expect(st.Results()).To(ContainElement(Satisfy(func(s types.ActionState) bool {
return s.ActionCurrentState.Action != nil &&
s.ActionCurrentState.Action.Definition().Name.String() == "counter"
}), &h))
Expect(h.ActionCurrentState.Params).To(HaveKeyWithValue("name", "contract"))
Expect(h.Result).To(ContainSubstring("Created counter 'contract'"))
// Same format string as GetAgentStatusEndpoint: this text is what
// the agent status page shows, so the params must render as JSON
// through ActionParams.String rather than as a Go map.
rendered := fmt.Sprintf("Reasoning: %s\nAction taken: %s\nParameters: %+v\nResult: %s",
h.Reasoning, h.ActionCurrentState.Action.Definition().Name.String(), h.ActionCurrentState.Params, h.Result)
Expect(rendered).To(ContainSubstring("Action taken: counter\n"))
Expect(rendered).To(ContainSubstring(`"name":"contract"`))
Expect(rendered).To(ContainSubstring("Result: Created counter 'contract'"))
})
// The observables tree nests the action under the job that ran it.
// Only the link is pinned: ids, ordering and conversation contents are
// LocalAGI internals.
It("records a child action observable linked to the root job by parent_id", func() {
obs, _ := settleRun("tooled")
Expect(len(obs)).To(BeNumerically(">", 1))
var roots, children []map[string]any
for _, o := range obs {
if _, ok := o["parent_id"]; ok {
children = append(children, o)
} else {
roots = append(roots, o)
}
}
Expect(roots).To(HaveLen(1))
Expect(children).ToNot(BeEmpty())
for _, c := range children {
// Compared as decoded JSON, whatever type the ids have: the id
// type is a LocalAGI detail, the parent link is the contract.
Expect(c["parent_id"]).To(Equal(roots[0]["id"]))
}
// "action" is the LocalAGI name, visible in AgentStatus.jsx; a
// native engine may name it differently, so flip deliberately.
Expect(children).To(ContainElement(And(
HaveKeyWithValue("name", "action"),
HaveKeyWithValue("completion", HaveKeyWithValue("action_result", ContainSubstring("Created counter"))),
)))
})
It("still delivers the agent's final reply over SSE", func() {
_, events := settleRun("tooled")
var replies []sseEvent
for _, e := range events {
if e.Name == "json_message" && e.Data["sender"] == "agent" {
replies = append(replies, e)
}
}
Expect(replies).ToNot(BeEmpty())
Expect(replies[0].Data["content"]).To(ContainSubstring("done"))
})
})
})