Skip to content

Commit bfea67e

Browse files
committed
feat(realtime): wire streamLLMResponse for token-streamed replies
triggerResponseAtTurn takes a streamed path when pipeline.streaming.llm is set, the turn has no tools, and audio is requested: streamLLMResponse announces the assistant item, drives the LLM token callback through a speechStreamer (reasoning-stripped transcript deltas + sentence-piped TTS), and emits the terminal events. Tool turns and non-streaming pipelines keep the existing buffered path unchanged, so this is strictly opt-in. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 2c5a055 commit bfea67e

2 files changed

Lines changed: 161 additions & 0 deletions

File tree

core/http/endpoints/openai/realtime.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,6 +1496,23 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
14961496
},
14971497
})
14981498

1499+
// Streamed LLM path: when the pipeline opts into LLM streaming and the turn
1500+
// cannot produce a tool call (no tools), stream tokens straight to the client
1501+
// as transcript deltas and sentence-pipe them into TTS. Tool turns fall
1502+
// through to the buffered path below, since partial tool-call output can't be
1503+
// safely spoken mid-stream.
1504+
if config != nil && session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamLLM() && len(tools) == 0 {
1505+
var respMods []types.Modality
1506+
if overrides != nil {
1507+
respMods = overrides.OutputModalities
1508+
}
1509+
if modalitiesContainAudio(resolveOutputModalities(session.OutputModalities, respMods)) {
1510+
if streamLLMResponse(ctx, session, conv, t, responseID, conversationHistory, images, config) {
1511+
return
1512+
}
1513+
}
1514+
}
1515+
14991516
predFunc, err := session.ModelInterface.Predict(ctx, conversationHistory, images, nil, nil, nil, tools, toolChoice, nil, nil, nil)
15001517
if err != nil {
15011518
sendError(t, "inference_failed", fmt.Sprintf("backend error: %v", err), "", "") // item.Assistant.ID is unknown here

core/http/endpoints/openai/realtime_stream.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package openai
22

33
import (
44
"context"
5+
"encoding/base64"
6+
"fmt"
57

8+
"github.com/mudler/LocalAI/core/backend"
9+
"github.com/mudler/LocalAI/core/config"
610
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
11+
"github.com/mudler/LocalAI/core/schema"
712
"github.com/mudler/LocalAI/pkg/reasoning"
813
)
914

@@ -84,3 +89,142 @@ func (s *speechStreamer) finish() (content string, audio []byte, err error) {
8489
}
8590
return s.extractor.CleanedContent(), s.audio, s.err
8691
}
92+
93+
// streamLLMResponse drives a streamed, plain-content (no tools) realtime reply.
94+
// It announces the assistant item before tokens arrive, feeds the LLM token
95+
// callback through a speechStreamer (transcript deltas + sentence-piped TTS),
96+
// then emits the terminal events. It returns true when it has fully handled the
97+
// response so the caller can return; callers must only invoke it for turns with
98+
// no tools and an audio modality (see triggerResponseAtTurn).
99+
func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation, t Transport, responseID string, history schema.Messages, images []string, llmCfg *config.ModelConfig) bool {
100+
// Announce the assistant item up front so streamed deltas target a known item.
101+
item := types.MessageItemUnion{
102+
Assistant: &types.MessageItemAssistant{
103+
ID: generateItemID(),
104+
Status: types.ItemStatusInProgress,
105+
Content: []types.MessageContentOutput{{Type: types.MessageContentTypeOutputAudio}},
106+
},
107+
}
108+
conv.Lock.Lock()
109+
conv.Items = append(conv.Items, &item)
110+
conv.Lock.Unlock()
111+
112+
sendEvent(t, types.ResponseOutputItemAddedEvent{
113+
ServerEventBase: types.ServerEventBase{},
114+
ResponseID: responseID,
115+
OutputIndex: 0,
116+
Item: item,
117+
})
118+
sendEvent(t, types.ResponseContentPartAddedEvent{
119+
ServerEventBase: types.ServerEventBase{},
120+
ResponseID: responseID,
121+
ItemID: item.Assistant.ID,
122+
OutputIndex: 0,
123+
ContentIndex: 0,
124+
Part: item.Assistant.Content[0],
125+
})
126+
127+
cancel := func() {
128+
conv.Lock.Lock()
129+
for i := len(conv.Items) - 1; i >= 0; i-- {
130+
if conv.Items[i].Assistant != nil && conv.Items[i].Assistant.ID == item.Assistant.ID {
131+
conv.Items = append(conv.Items[:i], conv.Items[i+1:]...)
132+
break
133+
}
134+
}
135+
conv.Lock.Unlock()
136+
sendEvent(t, types.ResponseDoneEvent{
137+
ServerEventBase: types.ServerEventBase{},
138+
Response: types.Response{ID: responseID, Object: "realtime.response", Status: types.ResponseStatusCancelled},
139+
})
140+
}
141+
142+
var template string
143+
if llmCfg.TemplateConfig.UseTokenizerTemplate {
144+
template = llmCfg.GetModelTemplate()
145+
} else {
146+
template = llmCfg.TemplateConfig.Chat
147+
}
148+
thinkingStartToken := reasoning.DetectThinkingStartToken(template, &llmCfg.ReasoningConfig)
149+
150+
streamer := newSpeechStreamer(ctx, t, session, responseID, item.Assistant.ID, thinkingStartToken, llmCfg.ReasoningConfig)
151+
cb := func(token string, _ backend.TokenUsage) bool {
152+
if ctx.Err() != nil {
153+
return false
154+
}
155+
streamer.onToken(token)
156+
return true
157+
}
158+
159+
predFunc, err := session.ModelInterface.Predict(ctx, history, images, nil, nil, cb, nil, nil, nil, nil, nil)
160+
if err != nil {
161+
sendError(t, "inference_failed", fmt.Sprintf("backend error: %v", err), "", item.Assistant.ID)
162+
return true
163+
}
164+
if _, err := predFunc(); err != nil {
165+
if ctx.Err() != nil {
166+
cancel()
167+
return true
168+
}
169+
sendError(t, "prediction_failed", fmt.Sprintf("backend error: %v", err), "", item.Assistant.ID)
170+
return true
171+
}
172+
if ctx.Err() != nil {
173+
cancel()
174+
return true
175+
}
176+
177+
content, audio, err := streamer.finish()
178+
if err != nil {
179+
sendError(t, "tts_error", fmt.Sprintf("TTS generation failed: %v", err), "", item.Assistant.ID)
180+
return true
181+
}
182+
183+
_, isWebRTC := t.(*WebRTCTransport)
184+
185+
sendEvent(t, types.ResponseOutputAudioTranscriptDoneEvent{
186+
ServerEventBase: types.ServerEventBase{},
187+
ResponseID: responseID,
188+
ItemID: item.Assistant.ID,
189+
OutputIndex: 0,
190+
ContentIndex: 0,
191+
Transcript: content,
192+
})
193+
if !isWebRTC {
194+
sendEvent(t, types.ResponseOutputAudioDoneEvent{
195+
ServerEventBase: types.ServerEventBase{},
196+
ResponseID: responseID,
197+
ItemID: item.Assistant.ID,
198+
OutputIndex: 0,
199+
ContentIndex: 0,
200+
})
201+
}
202+
203+
conv.Lock.Lock()
204+
item.Assistant.Status = types.ItemStatusCompleted
205+
item.Assistant.Content[0].Transcript = content
206+
if !isWebRTC {
207+
item.Assistant.Content[0].Audio = base64.StdEncoding.EncodeToString(audio)
208+
}
209+
conv.Lock.Unlock()
210+
211+
sendEvent(t, types.ResponseContentPartDoneEvent{
212+
ServerEventBase: types.ServerEventBase{},
213+
ResponseID: responseID,
214+
ItemID: item.Assistant.ID,
215+
OutputIndex: 0,
216+
ContentIndex: 0,
217+
Part: item.Assistant.Content[0],
218+
})
219+
sendEvent(t, types.ResponseOutputItemDoneEvent{
220+
ServerEventBase: types.ServerEventBase{},
221+
ResponseID: responseID,
222+
OutputIndex: 0,
223+
Item: item,
224+
})
225+
sendEvent(t, types.ResponseDoneEvent{
226+
ServerEventBase: types.ServerEventBase{},
227+
Response: types.Response{ID: responseID, Object: "realtime.response", Status: types.ResponseStatusCompleted},
228+
})
229+
return true
230+
}

0 commit comments

Comments
 (0)