Skip to content

Add connection pooling for xAI TTS#6317

Merged
tinalenguyen merged 2 commits into
livekit:mainfrom
godququ5-code:godququ5/xai-tts-connection-pool
Jul 7, 2026
Merged

Add connection pooling for xAI TTS#6317
tinalenguyen merged 2 commits into
livekit:mainfrom
godququ5-code:godququ5/xai-tts-connection-pool

Conversation

@godququ5-code

@godququ5-code godququ5-code commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a ConnectionPool to the xAI TTS plugin so streamed synthesis segments can reuse websocket connections.
  • Expose prewarm() through the pool and close pooled connections from aclose().
  • Invalidate pooled connections when websocket URL options change.
  • Add a plugin regression test that verifies two synthesized segments reuse one websocket and close it only when the TTS instance is closed.

Fixes #6078

Root cause

The xAI TTS stream opened a websocket with _connect_ws() for each synthesized segment and always closed it in _run_ws() cleanup. Unlike other websocket-backed TTS plugins, it did not use the shared utils.ConnectionPool, so repeated streamed segments could not reuse an existing connection or be prewarmed.

Notes

xAI documents the streaming TTS websocket as multi-utterance: after audio.done, clients can send another text.delta / text.done sequence on the same connection.

Test plan

  • uv run pytest tests/test_plugin_xai_tts.py --plugin xai -q
  • uv run pytest tests/test_realtime/test_xai_realtime_model.py --unit -q
  • uv run ruff check tests/test_plugin_xai_tts.py livekit-plugins/livekit-plugins-xai/livekit/plugins/xai/tts.py
  • uv run ruff format --check tests/test_plugin_xai_tts.py livekit-plugins/livekit-plugins-xai/livekit/plugins/xai/tts.py
  • uv run --group typing mypy --untyped-calls-exclude=smithy_aws_core -p livekit.plugins.xai

@CLAassistant

CLAassistant commented Jul 4, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@godququ5-code godququ5-code marked this pull request as ready for review July 4, 2026 13:26
@godququ5-code godququ5-code requested a review from a team as a code owner July 4, 2026 13:26

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread tests/test_plugin_xai_tts.py Outdated
Comment on lines +163 to +164
async def _connect_pooled_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
return await self._connect_ws(timeout, self._opts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Pooled connections now use TTS-level options instead of stream-level snapshot

The old code at the removed line ws = await self._tts._connect_ws(self._conn_options.timeout, self._opts) used self._opts which is the stream's snapshot copy (set at tts.py:256 via self._opts = replace(tts._opts)). The new _connect_pooled_ws at tts.py:163-164 uses self._opts referring to TTS._opts — the TTS instance's current (possibly mutated) options. This means if update_options() is called after a stream is created but before its _run_ws executes, the WebSocket connection parameters (voice, language, speed, etc.) will reflect the new options rather than the options the stream was created with. This is consistent with how Deepgram and Cartesia plugins handle pooled connections, so it appears intentional, but it is a behavioral change from the previous implementation.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. With pooled websockets, the connection parameters are scoped to the pool/TTS options rather than an individual stream snapshot; this matches the existing Deepgram/Cartesia pooling pattern. update_options() invalidates the pool when URL-affecting options change, so later requests connect with the updated params instead of reusing stale sockets. The stream snapshot remains useful for per-stream tokenizer state.

Comment on lines +360 to +370
async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
self._acquire_time = self._tts._pool.last_acquire_time
self._connection_reused = self._tts._pool.last_connection_reused
tasks = [
asyncio.create_task(_send_task(ws)),
asyncio.create_task(_recv_task(ws)),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.gracefully_cancel(*tasks)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Connection reuse assumes xAI WebSocket supports sequential request multiplexing

The connection pool returns the WebSocket to the available set after a successful _run_ws call (via the connection() context manager's put() at connection_pool.py:94). The next _run_ws call then reuses the same WebSocket, sending new text.delta messages. This assumes the xAI server supports receiving a new sequence of text.deltatext.done on the same WebSocket after a previous audio.done has been received. If the xAI server instead expects one request per WebSocket connection, the second segment would fail or produce incorrect audio. The test at test_plugin_xai_tts.py:110-116 validates this with a fake WebSocket but cannot confirm server-side behavior.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against xAI docs: the streaming TTS websocket supports multi-utterance on the same connection. After audio.done, clients can send another text.delta / text.done sequence, and xAI documents that the connection remains open for this pattern: https://docs.x.ai/developers/rest-api-reference/inference/voice. That is the provider behavior this pool relies on.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Pre-existing: last input stream not finalized when channel closes without flush

In _tokenize_input at lines 270-283, if the _input_ch closes (the async for loop ends) while an input_stream is active (i.e., text was pushed but no FlushSentinel was received), end_input() is never called on that stream. The segments_ch is closed, but the last input_stream remains open. This means _run_ws would iterate async for word in input_stream and never finish, causing the pooled WebSocket connection to be held indefinitely.

This is pre-existing behavior (not introduced by this PR), but the impact is amplified with connection pooling since the held connection blocks the pool rather than just leaking a single WebSocket. Other plugins like Deepgram have the same pattern (livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/tts.py:300-314), so this appears to be a framework-level convention where the caller is expected to always send a flush before closing.

(Refers to lines 270-283)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing and outside this PR scope. The normal public path is SynthesizeStream.end_input(), which calls flush() before closing _input_ch, so the active segment is finalized. Directly closing _input_ch without a flush would affect the same tokenizer pattern in other plugins such as Deepgram as well; this PR keeps the change scoped to xAI websocket connection reuse.

@tinalenguyen tinalenguyen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the PR!

@tinalenguyen tinalenguyen merged commit 4dda0c9 into livekit:main Jul 7, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

xai TTS: new WebSocket connection per synthesized segment — no connection pooling/prewarm

3 participants