Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions core/http/endpoints/openai/realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"maps"
"math"
"os"
"regexp"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -2017,6 +2018,19 @@ func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Co
commitUtteranceWithTranscript(ctx, utt, nil, nil, "", session, conv, t, slot)
}

// eouLangTagRe matches the end-of-utterance language tags that Nemotron-3.5
// ASR appends to transcripts (BCP-47 style: "<de-DE>", "<en-US>", ...).
var eouLangTagRe = regexp.MustCompile(`\s*<[a-z]{2,3}(?:-[A-Za-z]{2,3})?>\s*`)

// stripEOULanguageTags removes the STT EOU language tags from a transcript
// (see the call site in commitUtteranceWithTranscript).
func stripEOULanguageTags(s string) string {
if !strings.Contains(s, "<") {
return s
}
return strings.Join(strings.Fields(eouLangTagRe.ReplaceAllString(s, " ")), " ")
}

// commitUtteranceWithTranscript commits one user turn. live carries the
// transcript semantic_vad's live stream already produced (its caption deltas
// were streamed to the client during the turn, so only the completed event
Expand Down Expand Up @@ -2184,6 +2198,15 @@ func commitUtteranceWithTranscript(ctx context.Context, utt []byte, live *liveUt
return
}

// EOU language tags (e.g. "<de-DE>") are STT metadata: they stay in the
// transcription event emitted above (debugging, client display), but are
// stripped from the LLM context — the per-utterance tag is unreliable for
// short/ambiguous utterances (a German "Hallo." tagged <en-US>), and the
// LLM treats it as a language signal (the agent's language rule then
// switches the reply language; see CallcenterAiAgent agent.py,
// 17.10.2026).
transcript = stripEOULanguageTags(transcript)

// Sound-event detection is additive to transcription: classify the same
// committed window and emit its scored AudioSet tags as a separate event.
// A failure here is logged but must never abort the turn.
Expand Down
105 changes: 105 additions & 0 deletions core/http/endpoints/openai/realtime_eou_langtag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package openai

import (
"context"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
)

// Regression test: ASR backends like Nemotron-3.5 append end-of-utterance
// language tags to transcripts ("<de-DE>", "<en-US>", ...). The committed
// transcript feeds the LLM conversation (the user item's Transcript field).
// The per-utterance tag is unreliable for short/ambiguous utterances (a
// German "Hallo." tagged <en-US>) and the LLM treats it as a language
// signal, switching the reply language against the session instructions.
// The tags must be stripped from the LLM context while the emitted
// transcription event keeps the raw transcript (debugging / client display).
var _ = Describe("stripEOULanguageTags", func() {
It("removes a trailing EOU tag", func() {
Expect(stripEOULanguageTags("Hallo. <en-US>")).To(Equal("Hallo."))
})

It("removes mid-text EOU tags and normalizes whitespace", func() {
Expect(stripEOULanguageTags("My name is Benjamin Zimmer. <en-US> Ich hatte gerne eine Festnetzrufnummer. <de-DE>")).
To(Equal("My name is Benjamin Zimmer. Ich hatte gerne eine Festnetzrufnummer."))
})

It("handles 3-letter primary subtags", func() {
Expect(stripEOULanguageTags("Hello. <eng>")).To(Equal("Hello."))
})

It("leaves plain text untouched", func() {
Expect(stripEOULanguageTags("Guten Tag, wie kann ich helfen?")).
To(Equal("Guten Tag, wie kann ich helfen?"))
})
})

var _ = Describe("client commit with an EOU-tagged transcript", func() {
utt := []byte{1, 2, 3, 4}

newModel := func(text string) *heldTranscribeModel {
return &heldTranscribeModel{
fakeModel: &fakeModel{
cfg: &config.ModelConfig{},
predictTokens: []string{"ok"},
predictResp: backend.LLMResponse{Response: "ok"},
ttsStreamChunks: [][]byte{{1}},
ttsStreamRate: 24000,
},
texts: []string{text},
release: make(chan struct{}),
started: make(chan int32, 8),
finished: make(chan int32, 8),
}
}

newSession := func(m *heldTranscribeModel) *Session {
on := true
session := &Session{
OutputSampleRate: 24000,
InputAudioTranscription: &types.AudioTranscription{},
ModelInterface: m,
ModelConfig: &config.ModelConfig{
Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &on}},
},
respSink: newResponseSink(),
}
session.sessionCtx, session.sessionCancel = context.WithCancel(context.Background())
return session
}

It("keeps the raw tag in the emitted event but strips it from the LLM context", func() {
m := newModel("Hallo. <en-US>")
session := newSession(m)
tr := &fakeTransport{}
conv := &Conversation{}

session.issueClientCommit(utt, conv, tr)
Eventually(m.started, "2s").Should(Receive(Equal(int32(1))))
close(m.release)
session.respSink.wait()

// The emitted transcription event keeps the raw transcript.
var emitted string
for _, ev := range tr.events() {
if c, ok := ev.(types.ConversationItemInputAudioTranscriptionCompletedEvent); ok {
emitted = c.Transcript
}
}
Expect(emitted).To(Equal("Hallo. <en-US>"))

// The LLM context (user message) must be tag-free.
var llmUser string
for _, msg := range m.lastMessages {
if msg.Role == string(types.MessageRoleUser) {
llmUser = msg.StringContent
}
}
Expect(llmUser).To(Equal("Hallo."))
})
})
5 changes: 5 additions & 0 deletions docs/content/features/openai-realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ Make sure all referenced models (`silero-vad-ggml`, `whisper-large-turbo`, `qwen

A pipeline stage can name a [failover chain]({{%relref "features/model-failover" %}}); the stage then switches targets without closing the session.

Transcription backends can include end-of-utterance language tags, such as `<en-US>` or `<de-DE>`.
LocalAI removes these tags from the user transcript sent to the LLM.
The transcription events sent to the client retain the original tags.
This prevents a tag from overriding the intended conversation language in the LLM context.

### Install the Qwen streaming assistant preset

The gallery entry `qwen3-streaming-assistant` configures an English voice
Expand Down