99 "github.com/mudler/LocalAI/core/config"
1010 "github.com/mudler/LocalAI/core/http/endpoints/openai/types"
1111 "github.com/mudler/LocalAI/core/schema"
12+ "github.com/mudler/LocalAI/pkg/functions"
1213 "github.com/mudler/LocalAI/pkg/reasoning"
1314)
1415
@@ -25,6 +26,12 @@ type transcriptStreamer struct {
2526 responseID string
2627 itemID string
2728 extractor * reasoning.ReasoningExtractor
29+
30+ // announce, if set, is invoked once just before the first transcript delta.
31+ // It lets the caller create the assistant item lazily, so a content-less
32+ // tool-call turn never emits a spurious empty assistant item.
33+ announce func ()
34+ announced bool
2835}
2936
3037func newTranscriptStreamer (ctx context.Context , t Transport , responseID , itemID , thinkingStartToken string , reasoningCfg reasoning.Config ) * transcriptStreamer {
@@ -46,6 +53,12 @@ func (s *transcriptStreamer) onToken(token string) {
4653 if content == "" {
4754 return
4855 }
56+ if ! s .announced {
57+ s .announced = true
58+ if s .announce != nil {
59+ s .announce ()
60+ }
61+ }
4962 _ = s .t .SendEvent (types.ResponseOutputAudioTranscriptDeltaEvent {
5063 ServerEventBase : types.ServerEventBase {},
5164 ResponseID : s .responseID ,
@@ -61,50 +74,61 @@ func (s *transcriptStreamer) content() string {
6174 return s .extractor .CleanedContent ()
6275}
6376
64- // streamLLMResponse drives a streamed, plain-content (no tools) realtime reply.
65- // It announces the assistant item before tokens arrive, streams transcript
66- // deltas as the LLM generates, then synthesizes the whole buffered message once
67- // (streaming the audio chunks when the TTS backend supports it, otherwise a
68- // single unary delta) and emits the terminal events. It returns true when it has
69- // fully handled the response so the caller can return; callers must only invoke
70- // it for turns with no tools and an audio modality (see triggerResponseAtTurn).
71- func streamLLMResponse (ctx context.Context , session * Session , conv * Conversation , t Transport , responseID string , history schema.Messages , images []string , llmCfg * config.ModelConfig ) bool {
72- // Announce the assistant item up front so streamed deltas target a known item.
77+ // streamLLMResponse drives a streamed realtime reply. It streams the assistant
78+ // transcript as the LLM generates, then synthesizes the whole buffered message
79+ // once (streaming the audio chunks when the TTS backend supports it, otherwise a
80+ // single unary delta). Tool calls parsed from the autoparser ChatDeltas are
81+ // emitted after the spoken content. The assistant content item is created lazily
82+ // on the first content delta, so a content-less tool-call turn emits only the
83+ // tool calls. It returns true when it has fully handled the response so the
84+ // caller can return; callers must only invoke it for an audio modality, and with
85+ // tools only when the model uses its tokenizer template (see triggerResponseAtTurn).
86+ func streamLLMResponse (ctx context.Context , session * Session , conv * Conversation , t Transport , responseID string , history schema.Messages , images []string , llmCfg * config.ModelConfig , tools []types.ToolUnion , toolChoice * types.ToolChoiceUnion , toolTurn int ) bool {
87+ itemID := generateItemID ()
7388 item := types.MessageItemUnion {
7489 Assistant : & types.MessageItemAssistant {
75- ID : generateItemID () ,
90+ ID : itemID ,
7691 Status : types .ItemStatusInProgress ,
7792 Content : []types.MessageContentOutput {{Type : types .MessageContentTypeOutputAudio }},
7893 },
7994 }
80- conv .Lock .Lock ()
81- conv .Items = append (conv .Items , & item )
82- conv .Lock .Unlock ()
8395
84- sendEvent (t , types.ResponseOutputItemAddedEvent {
85- ServerEventBase : types.ServerEventBase {},
86- ResponseID : responseID ,
87- OutputIndex : 0 ,
88- Item : item ,
89- })
90- sendEvent (t , types.ResponseContentPartAddedEvent {
91- ServerEventBase : types.ServerEventBase {},
92- ResponseID : responseID ,
93- ItemID : item .Assistant .ID ,
94- OutputIndex : 0 ,
95- ContentIndex : 0 ,
96- Part : item .Assistant .Content [0 ],
97- })
96+ // announce creates the assistant content item lazily, just before the first
97+ // transcript delta — a tool-only turn never produces content, so it stays out
98+ // of the conversation and the client sees only the tool calls.
99+ announced := false
100+ announce := func () {
101+ announced = true
102+ conv .Lock .Lock ()
103+ conv .Items = append (conv .Items , & item )
104+ conv .Lock .Unlock ()
105+ sendEvent (t , types.ResponseOutputItemAddedEvent {
106+ ServerEventBase : types.ServerEventBase {},
107+ ResponseID : responseID ,
108+ OutputIndex : 0 ,
109+ Item : item ,
110+ })
111+ sendEvent (t , types.ResponseContentPartAddedEvent {
112+ ServerEventBase : types.ServerEventBase {},
113+ ResponseID : responseID ,
114+ ItemID : itemID ,
115+ OutputIndex : 0 ,
116+ ContentIndex : 0 ,
117+ Part : item .Assistant .Content [0 ],
118+ })
119+ }
98120
99121 cancel := func () {
100- conv .Lock .Lock ()
101- for i := len (conv .Items ) - 1 ; i >= 0 ; i -- {
102- if conv .Items [i ].Assistant != nil && conv .Items [i ].Assistant .ID == item .Assistant .ID {
103- conv .Items = append (conv .Items [:i ], conv .Items [i + 1 :]... )
104- break
122+ if announced {
123+ conv .Lock .Lock ()
124+ for i := len (conv .Items ) - 1 ; i >= 0 ; i -- {
125+ if conv .Items [i ].Assistant != nil && conv .Items [i ].Assistant .ID == itemID {
126+ conv .Items = append (conv .Items [:i ], conv .Items [i + 1 :]... )
127+ break
128+ }
105129 }
130+ conv .Lock .Unlock ()
106131 }
107- conv .Lock .Unlock ()
108132 sendEvent (t , types.ResponseDoneEvent {
109133 ServerEventBase : types.ServerEventBase {},
110134 Response : types.Response {ID : responseID , Object : "realtime.response" , Status : types .ResponseStatusCancelled },
@@ -119,92 +143,111 @@ func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation
119143 }
120144 thinkingStartToken := reasoning .DetectThinkingStartToken (template , & llmCfg .ReasoningConfig )
121145
122- streamer := newTranscriptStreamer (ctx , t , responseID , item .Assistant .ID , thinkingStartToken , llmCfg .ReasoningConfig )
123- cb := func (token string , _ backend.TokenUsage ) bool {
146+ streamer := newTranscriptStreamer (ctx , t , responseID , itemID , thinkingStartToken , llmCfg .ReasoningConfig )
147+ streamer .announce = announce
148+ cb := func (token string , usage backend.TokenUsage ) bool {
124149 if ctx .Err () != nil {
125150 return false
126151 }
127- streamer .onToken (token )
152+ // Plain-content models stream text via the token; autoparser tool turns
153+ // clear the text and deliver content via ChatDeltas, so prefer the latter
154+ // when present. Either way only content reaches the transcript — tool-call
155+ // deltas are parsed from the final response below.
156+ text := token
157+ if len (usage .ChatDeltas ) > 0 {
158+ text = functions .ContentFromChatDeltas (usage .ChatDeltas )
159+ }
160+ streamer .onToken (text )
128161 return true
129162 }
130163
131- predFunc , err := session .ModelInterface .Predict (ctx , history , images , nil , nil , cb , nil , nil , nil , nil , nil )
164+ predFunc , err := session .ModelInterface .Predict (ctx , history , images , nil , nil , cb , tools , toolChoice , nil , nil , nil )
132165 if err != nil {
133- sendError (t , "inference_failed" , fmt .Sprintf ("backend error: %v" , err ), "" , item . Assistant . ID )
166+ sendError (t , "inference_failed" , fmt .Sprintf ("backend error: %v" , err ), "" , itemID )
134167 return true
135168 }
136- if _ , err := predFunc (); err != nil {
169+ pred , err := predFunc ()
170+ if err != nil {
137171 if ctx .Err () != nil {
138172 cancel ()
139173 return true
140174 }
141- sendError (t , "prediction_failed" , fmt .Sprintf ("backend error: %v" , err ), "" , item . Assistant . ID )
175+ sendError (t , "prediction_failed" , fmt .Sprintf ("backend error: %v" , err ), "" , itemID )
142176 return true
143177 }
144178 if ctx .Err () != nil {
145179 cancel ()
146180 return true
147181 }
148182
149- // Buffer the whole message, then synthesize it once. emitSpeech streams the
150- // audio chunks when the TTS backend supports TTSStream, otherwise it sends a
151- // single unary delta — no per-sentence segmentation either way.
152183 content := streamer .content ()
153- audio , err := emitSpeech (ctx , t , session , responseID , item .Assistant .ID , content )
154- if err != nil {
155- if ctx .Err () != nil {
156- cancel ()
184+ toolCalls := functions .ToolCallsFromChatDeltas (pred .ChatDeltas )
185+
186+ // Finalize the spoken content item only when the turn produced content. A
187+ // tool-only turn skips this entirely (no empty assistant item).
188+ if content != "" {
189+ if ! announced {
190+ announce ()
191+ }
192+ // Buffer the whole message, then synthesize it once. emitSpeech streams
193+ // the audio chunks when the TTS backend supports TTSStream, otherwise it
194+ // sends a single unary delta — no per-sentence segmentation either way.
195+ audio , err := emitSpeech (ctx , t , session , responseID , itemID , content )
196+ if err != nil {
197+ if ctx .Err () != nil {
198+ cancel ()
199+ return true
200+ }
201+ sendError (t , "tts_error" , fmt .Sprintf ("TTS generation failed: %v" , err ), "" , itemID )
157202 return true
158203 }
159- sendError (t , "tts_error" , fmt .Sprintf ("TTS generation failed: %v" , err ), "" , item .Assistant .ID )
160- return true
161- }
162204
163- _ , isWebRTC := t .(* WebRTCTransport )
205+ _ , isWebRTC := t .(* WebRTCTransport )
164206
165- sendEvent (t , types.ResponseOutputAudioTranscriptDoneEvent {
166- ServerEventBase : types.ServerEventBase {},
167- ResponseID : responseID ,
168- ItemID : item .Assistant .ID ,
169- OutputIndex : 0 ,
170- ContentIndex : 0 ,
171- Transcript : content ,
172- })
173- if ! isWebRTC {
174- sendEvent (t , types.ResponseOutputAudioDoneEvent {
207+ sendEvent (t , types.ResponseOutputAudioTranscriptDoneEvent {
175208 ServerEventBase : types.ServerEventBase {},
176209 ResponseID : responseID ,
177- ItemID : item . Assistant . ID ,
210+ ItemID : itemID ,
178211 OutputIndex : 0 ,
179212 ContentIndex : 0 ,
213+ Transcript : content ,
180214 })
181- }
215+ if ! isWebRTC {
216+ sendEvent (t , types.ResponseOutputAudioDoneEvent {
217+ ServerEventBase : types.ServerEventBase {},
218+ ResponseID : responseID ,
219+ ItemID : itemID ,
220+ OutputIndex : 0 ,
221+ ContentIndex : 0 ,
222+ })
223+ }
224+
225+ conv .Lock .Lock ()
226+ item .Assistant .Status = types .ItemStatusCompleted
227+ item .Assistant .Content [0 ].Transcript = content
228+ if ! isWebRTC {
229+ item .Assistant .Content [0 ].Audio = base64 .StdEncoding .EncodeToString (audio )
230+ }
231+ conv .Lock .Unlock ()
182232
183- conv .Lock .Lock ()
184- item .Assistant .Status = types .ItemStatusCompleted
185- item .Assistant .Content [0 ].Transcript = content
186- if ! isWebRTC {
187- item .Assistant .Content [0 ].Audio = base64 .StdEncoding .EncodeToString (audio )
233+ sendEvent (t , types.ResponseContentPartDoneEvent {
234+ ServerEventBase : types.ServerEventBase {},
235+ ResponseID : responseID ,
236+ ItemID : itemID ,
237+ OutputIndex : 0 ,
238+ ContentIndex : 0 ,
239+ Part : item .Assistant .Content [0 ],
240+ })
241+ sendEvent (t , types.ResponseOutputItemDoneEvent {
242+ ServerEventBase : types.ServerEventBase {},
243+ ResponseID : responseID ,
244+ OutputIndex : 0 ,
245+ Item : item ,
246+ })
188247 }
189- conv .Lock .Unlock ()
190248
191- sendEvent (t , types.ResponseContentPartDoneEvent {
192- ServerEventBase : types.ServerEventBase {},
193- ResponseID : responseID ,
194- ItemID : item .Assistant .ID ,
195- OutputIndex : 0 ,
196- ContentIndex : 0 ,
197- Part : item .Assistant .Content [0 ],
198- })
199- sendEvent (t , types.ResponseOutputItemDoneEvent {
200- ServerEventBase : types.ServerEventBase {},
201- ResponseID : responseID ,
202- OutputIndex : 0 ,
203- Item : item ,
204- })
205- sendEvent (t , types.ResponseDoneEvent {
206- ServerEventBase : types.ServerEventBase {},
207- Response : types.Response {ID : responseID , Object : "realtime.response" , Status : types .ResponseStatusCompleted },
208- })
249+ // Emit any tool calls, the terminal response.done, and (for server-side
250+ // assistant tools) the follow-up turn — shared with the buffered path.
251+ emitToolCallItems (ctx , session , conv , t , responseID , toolCalls , content != "" , toolTurn )
209252 return true
210253}
0 commit comments