Bug Description
SynthesizeStream._input_task in livekit-plugins-soniox silently discards FlushSentinel:
# livekit/plugins/soniox/tts.py (1.6.5)
async def _input_task() -> None:
async for data in self._input_ch:
if self._cancelled.is_set():
break
if isinstance(data, self._FlushSentinel):
continue # <-- dropped, nothing sent to Soniox
self._mark_started()
connection.send_text(self._stream_id, data, text_end=False)
if not self._cancelled.is_set():
connection.send_text(self._stream_id, "", text_end=True)
text_end is only sent when the input channel is exhausted, so stream.flush() is a wire-level no-op for this plugin: no text_end, no segment completion, and Soniox's per-stream request timer keeps running.
Why it matters
Soniox's per-stream 408 (request_timeout) fires when a stream "stopped sending intermediate text chunks and never sent text_end" (errors reference). The connection-level keepalive the plugin already sends does not reset it β Soniox is explicit that keepalive "applies to the whole WebSocket connection, not to a specific stream_id" (connection keepalive).
That makes flush() the natural tool for ending a segment before a known pause (FlushSentinel from llm_node is the documented segment-boundary mechanism in Pipeline nodes). The AgentSession llm_node path happens to work because AgentActivity splits segments above the plugin and closes the per-segment input channel β but anyone using tts.stream() + flush() directly (custom tts_node, standalone synthesis per the plugin guide) silently gets one unbounded stream. That unbounded stream is the substrate of the 408s reported in #6225.
Expected Behavior
On FlushSentinel: if the current stream_id has sent text, send {"stream_id": ..., "text_end": true}, complete the current segment, and lazily start a new stream_id for the next text chunk. The connection already multiplexes stream_ids, and Soniox documents exactly this model for gaps: keep the connection open between streams "so you can start a new stream_id without reconnecting."
Package Versions
livekit-agents==1.6.5
livekit-plugins-soniox==1.6.5
Additional Context
Production evidence of the resulting 408 mid-call (with keepalive flowing) is in #6225 β this issue is the plugin-level building block that would let users and the framework mitigate it.
Bug Description
SynthesizeStream._input_taskinlivekit-plugins-sonioxsilently discardsFlushSentinel:text_endis only sent when the input channel is exhausted, sostream.flush()is a wire-level no-op for this plugin: notext_end, no segment completion, and Soniox's per-stream request timer keeps running.Why it matters
Soniox's per-stream 408 (
request_timeout) fires when a stream "stopped sending intermediate text chunks and never senttext_end" (errors reference). The connection-level keepalive the plugin already sends does not reset it β Soniox is explicit that keepalive "applies to the whole WebSocket connection, not to a specificstream_id" (connection keepalive).That makes
flush()the natural tool for ending a segment before a known pause (FlushSentinelfromllm_nodeis the documented segment-boundary mechanism in Pipeline nodes). TheAgentSessionllm_node path happens to work becauseAgentActivitysplits segments above the plugin and closes the per-segment input channel β but anyone usingtts.stream()+flush()directly (customtts_node, standalone synthesis per the plugin guide) silently gets one unbounded stream. That unbounded stream is the substrate of the 408s reported in #6225.Expected Behavior
On
FlushSentinel: if the currentstream_idhas sent text, send{"stream_id": ..., "text_end": true}, complete the current segment, and lazily start a newstream_idfor the next text chunk. The connection already multiplexes stream_ids, and Soniox documents exactly this model for gaps: keep the connection open between streams "so you can start a newstream_idwithout reconnecting."Package Versions
Additional Context
Production evidence of the resulting 408 mid-call (with keepalive flowing) is in #6225 β this issue is the plugin-level building block that would let users and the framework mitigate it.