@@ -20,6 +20,8 @@ import (
2020 "github.com/mudler/LocalAI/core/backend"
2121
2222 "github.com/mudler/LocalAI/core/config"
23+ "github.com/mudler/LocalAI/core/http/auth"
24+ mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
2325 "github.com/mudler/LocalAI/core/http/endpoints/openai/types"
2426 "github.com/mudler/LocalAI/core/schema"
2527 "github.com/mudler/LocalAI/core/templates"
@@ -86,6 +88,14 @@ type Session struct {
8688 // pairs are kept together so we never feed an orphaned tool result.
8789 MaxHistoryItems int
8890
91+ // AssistantExecutor is non-nil when the session opted into the in-process
92+ // LocalAI Assistant tool surface. Tool calls whose name matches this
93+ // executor's catalog are run inproc and their output is fed back to the
94+ // model server-side; the client never sees a function_call_arguments
95+ // event for those. Mirrors the chat handler's metadata.localai_assistant
96+ // path.
97+ AssistantExecutor mcpTools.ToolExecutor
98+
8999 // Response cancellation: protects activeResponseCancel/activeResponseDone
90100 responseMu sync.Mutex
91101 activeResponseCancel context.CancelFunc
@@ -211,6 +221,19 @@ func RealtimeTranscriptionSession(application *application.Application) echo.Han
211221 }
212222}
213223
224+ // RealtimeSessionOptions bundles per-session knobs decoded from the WS query
225+ // string (or the WebRTC handshake body). Mirrors what chat.go pulls off
226+ // `metadata.localai_assistant` — admin-only opt-in to the in-process
227+ // management tool surface.
228+ type RealtimeSessionOptions struct {
229+ LocalAIAssistant bool
230+ // AuthEnabled mirrors chat.go's requireAssistantAccess gate. We resolve
231+ // admin role at handshake time (where the echo.Context has the auth
232+ // cookie/Bearer) and drop the result here so runRealtimeSession can
233+ // decide without holding onto the request.
234+ IsAdmin bool
235+ }
236+
214237func Realtime (application * application.Application ) echo.HandlerFunc {
215238 return func (c echo.Context ) error {
216239 ws , err := upgrader .Upgrade (c .Response (), c .Request (), nil )
@@ -224,18 +247,33 @@ func Realtime(application *application.Application) echo.HandlerFunc {
224247
225248 // Extract query parameters from Echo context before passing to websocket handler
226249 model := c .QueryParam ("model" )
250+ opts := RealtimeSessionOptions {
251+ LocalAIAssistant : c .QueryParam ("localai_assistant" ) == "1" || c .QueryParam ("localai_assistant" ) == "true" ,
252+ IsAdmin : isCurrentUserAdmin (c , application ),
253+ }
227254
228- registerRealtime (application , model )(ws )
255+ registerRealtime (application , model , opts )(ws )
229256 return nil
230257 }
231258}
232259
233- func registerRealtime (application * application.Application , model string ) func (c * websocket.Conn ) {
260+ // isCurrentUserAdmin replicates the chat-side admin check at the realtime
261+ // handshake. When auth is disabled, every caller is treated as admin (same
262+ // as chat's requireAssistantAccess).
263+ func isCurrentUserAdmin (c echo.Context , application * application.Application ) bool {
264+ if application == nil || application .ApplicationConfig () == nil || ! application .ApplicationConfig ().Auth .Enabled {
265+ return true
266+ }
267+ user := auth .GetUser (c )
268+ return user != nil && user .Role == auth .RoleAdmin
269+ }
270+
271+ func registerRealtime (application * application.Application , model string , opts RealtimeSessionOptions ) func (c * websocket.Conn ) {
234272 return func (conn * websocket.Conn ) {
235273 t := NewWebSocketTransport (conn )
236274 evaluator := application .TemplatesEvaluator ()
237275 xlog .Debug ("Realtime WebSocket connection established" , "address" , conn .RemoteAddr ().String (), "model" , model )
238- runRealtimeSession (application , t , model , evaluator )
276+ runRealtimeSession (application , t , model , evaluator , opts )
239277 }
240278}
241279
@@ -306,7 +344,7 @@ func prepareRealtimeConfig(cfg *config.ModelConfig) (errCode, errMsg string, ok
306344
307345// runRealtimeSession runs the main event loop for a realtime session.
308346// It is transport-agnostic and works with both WebSocket and WebRTC.
309- func runRealtimeSession (application * application.Application , t Transport , model string , evaluator * templates.Evaluator ) {
347+ func runRealtimeSession (application * application.Application , t Transport , model string , evaluator * templates.Evaluator , opts RealtimeSessionOptions ) {
310348 cl := application .ModelConfigLoader ()
311349 cfg , err := cl .LoadModelConfigFileByNameDefaultOptions (model , application .ApplicationConfig ())
312350 if err != nil {
@@ -321,16 +359,72 @@ func runRealtimeSession(application *application.Application, t Transport, model
321359 return
322360 }
323361
362+ // LocalAI Assistant opt-in: gate on admin (same rule as chat.go's
363+ // requireAssistantAccess) and grab the process-wide holder's executor.
364+ // We collect tools + system prompt here and merge them into the session
365+ // below so they're live from the first response.create.
366+ var assistantTools []types.ToolUnion
367+ var assistantSystemPrompt string
368+ var assistantExecutor mcpTools.ToolExecutor
369+ if opts .LocalAIAssistant {
370+ if ! opts .IsAdmin {
371+ sendError (t , "forbidden" , "localai_assistant requires admin" , "" , "" )
372+ return
373+ }
374+ appCfg := application .ApplicationConfig ()
375+ if appCfg != nil && appCfg .DisableLocalAIAssistant {
376+ sendError (t , "unavailable" , "LocalAI Assistant is disabled on this server" , "" , "" )
377+ return
378+ }
379+ holder := application .LocalAIAssistant ()
380+ if holder == nil || ! holder .HasTools () {
381+ sendError (t , "unavailable" , "LocalAI Assistant is not available on this server" , "" , "" )
382+ return
383+ }
384+ exec := holder .Executor ()
385+ fns , discErr := exec .DiscoverTools (context .Background ())
386+ if discErr != nil {
387+ xlog .Error ("realtime: failed to discover LocalAI Assistant tools" , "error" , discErr )
388+ sendError (t , "tool_discovery_failed" , "failed to discover assistant tools: " + discErr .Error (), "" , "" )
389+ return
390+ }
391+ assistantExecutor = exec
392+ assistantSystemPrompt = holder .SystemPrompt ()
393+ assistantTools = make ([]types.ToolUnion , 0 , len (fns ))
394+ for _ , fn := range fns {
395+ fnCopy := fn
396+ assistantTools = append (assistantTools , types.ToolUnion {
397+ Function : & types.ToolFunction {
398+ Name : fnCopy .Name ,
399+ Description : fnCopy .Description ,
400+ Parameters : fnCopy .Parameters ,
401+ },
402+ })
403+ }
404+ xlog .Debug ("realtime: LocalAI Assistant tools injected" , "count" , len (fns ))
405+ }
406+
324407 sttModel := cfg .Pipeline .Transcription
325408
409+ // Compose the system prompt: prepend the assistant prompt when we have
410+ // one (it teaches the model the safety rules and tool recipes), then the
411+ // session's default voice instructions. Order matches chat.go's
412+ // hasSystemMessage check — assistant prompt comes first.
413+ instructions := defaultInstructions
414+ if assistantSystemPrompt != "" {
415+ instructions = assistantSystemPrompt + "\n \n " + defaultInstructions
416+ }
417+
326418 sessionID := generateSessionID ()
327419 session := & Session {
328420 ID : sessionID ,
329421 TranscriptionOnly : false ,
330422 Model : model ,
331423 Voice : cfg .TTSConfig .Voice ,
332- Instructions : defaultInstructions ,
424+ Instructions : instructions ,
333425 ModelConfig : cfg ,
426+ Tools : assistantTools ,
427+ AssistantExecutor : assistantExecutor ,
334428 TurnDetection : & types.TurnDetectionUnion {
335429 ServerVad : & types.ServerVad {
336430 Threshold : 0.5 ,
@@ -1647,8 +1741,16 @@ func triggerResponse(ctx context.Context, session *Session, conv *Conversation,
16471741 })
16481742 }
16491743
1650- // Handle Tool Calls
1744+ // Handle Tool Calls. Two paths:
1745+ // - LocalAI Assistant tools (session.AssistantExecutor.IsTool) run
1746+ // server-side; we append both the call and its output to conv.Items
1747+ // and re-trigger a follow-up response so the model can speak the
1748+ // result. The client only sees observability events.
1749+ // - All other tools follow the standard OpenAI flow: emit
1750+ // function_call_arguments.done and wait for the client to send
1751+ // conversation.item.create back.
16511752 xlog .Debug ("About to handle tool calls" , "finalToolCallsCount" , len (finalToolCalls ))
1753+ executedAssistantTool := false
16521754 for i , tc := range finalToolCalls {
16531755 toolCallID := generateItemID ()
16541756 callID := "call_" + generateUniqueID () // OpenAI uses call_xyz
@@ -1680,6 +1782,34 @@ func triggerResponse(ctx context.Context, session *Session, conv *Conversation,
16801782 Item : fcItem ,
16811783 })
16821784
1785+ serverSide := session .AssistantExecutor != nil && session .AssistantExecutor .IsTool (tc .Name )
1786+ if serverSide {
1787+ output , execErr := session .AssistantExecutor .ExecuteTool (ctx , tc .Name , tc .Arguments )
1788+ if execErr != nil {
1789+ output = "Error: " + execErr .Error ()
1790+ xlog .Error ("realtime: assistant tool execution failed" , "tool" , tc .Name , "error" , execErr )
1791+ }
1792+ foItem := types.MessageItemUnion {
1793+ FunctionCallOutput : & types.MessageItemFunctionCallOutput {
1794+ ID : generateItemID (),
1795+ CallID : callID ,
1796+ Output : output ,
1797+ Status : types .ItemStatusCompleted ,
1798+ },
1799+ }
1800+ conv .Lock .Lock ()
1801+ conv .Items = append (conv .Items , & foItem )
1802+ conv .Lock .Unlock ()
1803+ sendEvent (t , types.ResponseOutputItemDoneEvent {
1804+ ServerEventBase : types.ServerEventBase {},
1805+ ResponseID : responseID ,
1806+ OutputIndex : outputIndex ,
1807+ Item : fcItem ,
1808+ })
1809+ executedAssistantTool = true
1810+ continue
1811+ }
1812+
16831813 sendEvent (t , types.ResponseFunctionCallArgumentsDeltaEvent {
16841814 ServerEventBase : types.ServerEventBase {},
16851815 ResponseID : responseID ,
@@ -1715,6 +1845,13 @@ func triggerResponse(ctx context.Context, session *Session, conv *Conversation,
17151845 Status : types .ResponseStatusCompleted ,
17161846 },
17171847 })
1848+
1849+ // If we executed any assistant tools inproc, run another response cycle
1850+ // so the model can speak the result. Mirrors the chat-side agentic loop
1851+ // but driven server-side rather than by client round-trip.
1852+ if executedAssistantTool {
1853+ triggerResponse (ctx , session , conv , t , nil )
1854+ }
17181855}
17191856
17201857// Helper functions to generate unique IDs
0 commit comments