Skip to content

Commit f7db51b

Browse files
feat(realtime): add shared WebRTC UDP port (#11436)
* feat(realtime): add shared WebRTC UDP port Allow realtime WebRTC peer connections to reuse one configurable UDP mux, and surface listener bind failures through signaling. Assisted-by: Codex:gpt-5 * test(realtime): keep UDP mux alive during bind check The returned SettingEngine owns the UDP listener. Retain it through the duplicate-bind assertion so macOS cannot finalize the listener early and make the exclusivity check spuriously pass. Assisted-by: Codex:gpt-5 [systematic-debugging] * test(realtime): use IPv4 for UDP mux checks Match the socket family used by the WebRTC UDP mux so macOS does not allocate an IPv6 probe that can coexist with the IPv4 listener.\n\nAssisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
1 parent e1b1a25 commit f7db51b

6 files changed

Lines changed: 86 additions & 8 deletions

File tree

core/cli/run.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type RunCMD struct {
3838
ExternalBackends []string `env:"LOCALAI_EXTERNAL_BACKENDS,EXTERNAL_BACKENDS" help:"A list of external backends to load from gallery on boot" group:"backends"`
3939
WebRTCNAT1To1IPs []string `env:"LOCALAI_WEBRTC_NAT_1TO1_IPS,WEBRTC_NAT_1TO1_IPS" help:"IPs advertised as the host ICE candidates for /v1/realtime WebRTC instead of every local interface. Set to the reachable host/LAN IP when running under Docker host networking or NAT, where pion otherwise offers unreachable bridge addresses and the connection drops after ICE consent checks fail." group:"api"`
4040
WebRTCICEInterfaces []string `env:"LOCALAI_WEBRTC_ICE_INTERFACES,WEBRTC_ICE_INTERFACES" help:"Restrict /v1/realtime WebRTC ICE candidate gathering to these network interfaces (e.g. eth0), filtering out docker0/veth noise." group:"api"`
41+
WebRTCUDPPort int `env:"LOCALAI_WEBRTC_UDP_PORT" help:"Shared UDP port for /v1/realtime WebRTC ICE traffic. Publish this port as UDP and allow it through the firewall." group:"api" name:"web-rtc-udp-port"`
4142
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
4243
BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH,BACKEND_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends used for inferencing" group:"backends"`
4344
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
@@ -311,6 +312,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
311312
config.WithExternalBackends(r.ExternalBackends...),
312313
config.WithWebRTCNAT1To1IPs(r.WebRTCNAT1To1IPs...),
313314
config.WithWebRTCICEInterfaces(r.WebRTCICEInterfaces...),
315+
config.WithWebRTCUDPPort(r.WebRTCUDPPort),
314316
config.WithOpaqueErrors(r.OpaqueErrors),
315317
config.WithEnforcedPredownloadScans(!r.DisablePredownloadScan),
316318
config.WithSubtleKeyComparison(r.UseSubtleKeyComparison),

core/config/application_config.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ type ApplicationConfig struct {
3333
WebRTCNAT1To1IPs []string
3434
// WebRTCICEInterfaces, when set, restricts ICE candidate gathering to these
3535
// network interfaces (e.g. eth0), filtering out docker0/veth noise.
36-
WebRTCICEInterfaces []string
36+
WebRTCICEInterfaces []string
37+
// WebRTCUDPPort, when positive, is the shared UDP port used by all WebRTC
38+
// peer connections. Zero keeps pion's default ephemeral-port behavior.
39+
WebRTCUDPPort int
3740
UploadLimitMB, Threads, ContextSize int
3841
ArtifactDownloadConcurrency int
3942
F16 bool
@@ -367,6 +370,12 @@ func WithWebRTCICEInterfaces(interfaces ...string) AppOption {
367370
}
368371
}
369372

373+
func WithWebRTCUDPPort(port int) AppOption {
374+
return func(o *ApplicationConfig) {
375+
o.WebRTCUDPPort = port
376+
}
377+
}
378+
370379
func WithMachineTag(tag string) AppOption {
371380
return func(o *ApplicationConfig) {
372381
o.MachineTag = tag

core/http/endpoints/openai/realtime_webrtc.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@ type RealtimeCallResponse struct {
2929

3030
// RealtimeCalls handles POST /v1/realtime/calls for WebRTC signaling.
3131
func RealtimeCalls(application *application.Application) echo.HandlerFunc {
32+
se, settingEngineErr := webRTCSettingEngine(application.ApplicationConfig())
33+
if settingEngineErr != nil {
34+
xlog.Error("failed to configure realtime WebRTC UDP listener", "error", settingEngineErr)
35+
}
36+
3237
return func(c echo.Context) error {
38+
if settingEngineErr != nil {
39+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": settingEngineErr.Error()})
40+
}
3341
var req RealtimeCallRequest
3442
if err := c.Bind(&req); err != nil {
3543
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
@@ -48,7 +56,6 @@ func RealtimeCalls(application *application.Application) echo.HandlerFunc {
4856
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "codec registration failed"})
4957
}
5058

51-
se := webRTCSettingEngine(application.ApplicationConfig())
5259
api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithSettingEngine(se))
5360

5461
pc, err := api.NewPeerConnection(webrtc.Configuration{})

core/http/endpoints/openai/realtime_webrtc_ice.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package openai
22

33
import (
4+
"fmt"
5+
"net"
6+
47
"github.com/mudler/LocalAI/core/config"
58
"github.com/mudler/xlog"
69
"github.com/pion/webrtc/v4"
@@ -14,10 +17,10 @@ import (
1417
// connection often establishes on a good pair and then drops once ICE consent
1518
// checks fail on the unreachable ones. The two opt-in knobs below let an
1619
// operator advertise only the reachable address.
17-
func webRTCSettingEngine(cfg *config.ApplicationConfig) webrtc.SettingEngine {
20+
func webRTCSettingEngine(cfg *config.ApplicationConfig) (webrtc.SettingEngine, error) {
1821
s := webrtc.SettingEngine{}
1922
if cfg == nil {
20-
return s
23+
return s, nil
2124
}
2225
if len(cfg.WebRTCNAT1To1IPs) > 0 {
2326
s.SetNAT1To1IPs(cfg.WebRTCNAT1To1IPs, webrtc.ICECandidateTypeHost)
@@ -27,7 +30,14 @@ func webRTCSettingEngine(cfg *config.ApplicationConfig) webrtc.SettingEngine {
2730
s.SetInterfaceFilter(filter)
2831
xlog.Debug("realtime webrtc: restricting ICE interfaces", "interfaces", cfg.WebRTCICEInterfaces)
2932
}
30-
return s
33+
if cfg.WebRTCUDPPort > 0 {
34+
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: cfg.WebRTCUDPPort})
35+
if err != nil {
36+
return s, fmt.Errorf("bind WebRTC UDP port %d: %w", cfg.WebRTCUDPPort, err)
37+
}
38+
s.SetICEUDPMux(webrtc.NewICEUDPMux(nil, conn))
39+
}
40+
return s, nil
3141
}
3242

3343
// iceInterfaceFilter returns an interface allow-list predicate for pion, or nil

core/http/endpoints/openai/realtime_webrtc_ice_test.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package openai
22

33
import (
4+
"net"
5+
"runtime"
6+
47
"github.com/mudler/LocalAI/core/config"
58
. "github.com/onsi/ginkgo/v2"
69
. "github.com/onsi/gomega"
@@ -24,16 +27,46 @@ var _ = Describe("webRTC ICE settings", func() {
2427
})
2528

2629
Describe("webRTCSettingEngine", func() {
27-
It("does not panic on a nil config", func() {
28-
Expect(func() { webRTCSettingEngine(nil) }).NotTo(Panic())
30+
It("uses pion's ephemeral-port behavior by default", func() {
31+
_, err := webRTCSettingEngine(nil)
32+
Expect(err).NotTo(HaveOccurred())
2933
})
3034

3135
It("builds an engine with NAT 1:1 IPs and an interface filter configured", func() {
3236
cfg := &config.ApplicationConfig{
3337
WebRTCNAT1To1IPs: []string{"192.168.1.10"},
3438
WebRTCICEInterfaces: []string{"eth0"},
3539
}
36-
Expect(func() { webRTCSettingEngine(cfg) }).NotTo(Panic())
40+
_, err := webRTCSettingEngine(cfg)
41+
Expect(err).NotTo(HaveOccurred())
42+
})
43+
44+
It("binds the configured UDP port exclusively for reuse", func() {
45+
probe, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
46+
Expect(err).NotTo(HaveOccurred())
47+
port := probe.LocalAddr().(*net.UDPAddr).Port
48+
Expect(probe.Close()).To(Succeed())
49+
50+
engine, err := webRTCSettingEngine(&config.ApplicationConfig{WebRTCUDPPort: port})
51+
Expect(err).NotTo(HaveOccurred())
52+
53+
duplicate, bindErr := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: port})
54+
if duplicate != nil {
55+
Expect(duplicate.Close()).To(Succeed())
56+
}
57+
Expect(bindErr).To(HaveOccurred())
58+
runtime.KeepAlive(engine)
59+
})
60+
61+
It("returns a bind error when the configured UDP port is occupied", func() {
62+
occupied, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
63+
Expect(err).NotTo(HaveOccurred())
64+
DeferCleanup(occupied.Close)
65+
66+
_, err = webRTCSettingEngine(&config.ApplicationConfig{
67+
WebRTCUDPPort: occupied.LocalAddr().(*net.UDPAddr).Port,
68+
})
69+
Expect(err).To(MatchError(ContainSubstring("bind WebRTC UDP port")))
3770
})
3871
})
3972
})

docs/content/features/openai-realtime.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,23 @@ container, set `LOCALAI_WEBRTC_NAT_1TO1_IPS` to the host's LAN IP. This is the
323323
most reliable fix for WebRTC connections that establish and then drop.
324324
{{% /notice %}}
325325

326+
#### Fixed WebRTC UDP port
327+
328+
By default, each WebRTC peer connection uses an ephemeral UDP port. To route
329+
all realtime WebRTC ICE traffic through one shared port, start LocalAI with
330+
`--web-rtc-udp-port 3478` or set `LOCALAI_WEBRTC_UDP_PORT=3478`.
331+
332+
When running in a container, publish the same port with the UDP protocol:
333+
334+
```bash
335+
docker run -p 8080:8080 -p 3478:3478/udp \
336+
-e LOCALAI_WEBRTC_UDP_PORT=3478 localai/localai:latest
337+
```
338+
339+
Allow the selected UDP port through the host and network firewalls. If LocalAI
340+
cannot bind it, WebRTC signaling requests return an HTTP 500 error describing
341+
the bind failure.
342+
326343
## Protocol
327344

328345
The API follows the OpenAI Realtime API protocol for handling sessions, audio buffers, and conversation items.

0 commit comments

Comments
 (0)