Skip to content

fix(gradium): identify the TTS plugin to the API with x-api-source (1/4) - #7815

Merged
longcw merged 1 commit into
livekit:mainfrom
hhugo-gradium:fix/gradium-api-source
Oct 20, 2026
Merged

longcw merged 1 commit into
livekit:mainfrom
hhugo-gradium:fix/gradium-api-source

Conversation

@hhugo-gradium

@hhugo-gradium hhugo-gradium commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The TTS streams opened their websockets with only the API key, so the Gradium API recorded TTS sessions with no source, while the STT stream has sent x-api-source: livekit-plugin since #7873. The _connect_ws helper on the TTS class that did set the header was never called.

TTS now sends the same value through the ws_headers(api_key) helper that #7873 added in api.py. _connect_ws becomes the single connect path for both TTS streams, keeping the receive timeout the call sites set and the asyncio.wait_for bound on the handshake the helper already had, and the unused _close_ws is deleted, so this supersedes #7220. The value identifies sessions opened by this plugin, as distinct from direct API traffic on the same key and from other LiveKit integrations. The header is attribution only; the API does not change behaviour on it.

Testing

  • tests/test_plugin_gradium_api_source.py (unit category, from fix(gradium): identify the STT plugin to the API with x-api-source: livekit-plugin #7873) gains a recording session that asserts the headers of both TTS connect paths (synthesize() and stream()), next to the STT test already there. The same module gains two tests for a hung handshake, which now fails with APITimeoutError after conn_options.timeout on both paths.
  • Existing tests/test_plugin_gradium_stt.py still passes.
  • ruff, ruff format --check and mypy --strict clean on the plugin.
  • Live against api.gradium.ai: one-shot TTS and streaming TTS sessions recorded with source = livekit-plugin.

Base of the stack

This PR sits directly on main. #7672 (error handling), #7817 (heartbeat) and #7818 (multiplexing) are stacked on it in that order, because all of them build on the single _connect_ws path this PR introduces.

🤖 Generated with Claude Code

@CLAassistant

CLAassistant commented Oct 14, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@hhugo-gradium
hhugo-gradium force-pushed the fix/gradium-api-source branch 3 times, most recently from 28c51f2 to e28149a Compare October 14, 2026 11:45
@hhugo-gradium
hhugo-gradium marked this pull request as ready for review October 14, 2026 12:11
@hhugo-gradium
hhugo-gradium requested a review from a team as a code owner October 14, 2026 12:11

@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.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@hhugo-gradium
hhugo-gradium force-pushed the fix/gradium-api-source branch from e588b1a to 02e1d2e Compare October 14, 2026 15:57
devin-ai-integration[bot]

This comment was marked as resolved.

@hhugo-gradium
hhugo-gradium force-pushed the fix/gradium-api-source branch 3 times, most recently from 364926d to f522e3f Compare October 14, 2026 16:15
@hhugo-gradium hhugo-gradium changed the title fix(gradium): identify the plugin to the API with x-api-source fix(gradium): identify the plugin to the API with x-api-source (2/4) Oct 15, 2026
@hhugo-gradium
hhugo-gradium force-pushed the fix/gradium-api-source branch from f522e3f to 6379d2f Compare October 15, 2026 10:50
@hhugo-gradium
hhugo-gradium force-pushed the fix/gradium-api-source branch from 6379d2f to 8f6a87d Compare October 19, 2026 09:09
@hhugo-gradium hhugo-gradium changed the title fix(gradium): identify the plugin to the API with x-api-source (2/4) fix(gradium): identify the TTS plugin to the API with x-api-source (2/4) Oct 19, 2026
The TTS streams opened their websockets with only the API key, so TTS
sessions were recorded by the API with no source, while the STT stream
has sent `x-api-source: livekit-plugin` since livekit#7873. The `_connect_ws`
helper on the TTS class that did set the header was never called.

Send the same value from TTS through the shared `ws_headers(api_key)`
in `api.py`. Make `_connect_ws` the single connect path for both TTS
streams, keeping both the receive timeout the call sites set and the
handshake bound the helper already had, and delete the unused
`_close_ws` (supersedes livekit#7220).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hhugo-gradium
hhugo-gradium force-pushed the fix/gradium-api-source branch from 8f6a87d to 78c21af Compare October 19, 2026 09:19
@hhugo-gradium hhugo-gradium changed the title fix(gradium): identify the TTS plugin to the API with x-api-source (2/4) fix(gradium): identify the TTS plugin to the API with x-api-source (1/4) Oct 19, 2026

@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 new potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

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.

🔴 Premature closure delivers truncated speech

When Gradium closes after partial audio, ChunkedStream returns success without an end-of-stream frame. The TTS base checks for some audio, not complete audio, so callers receive truncated speech.

(Refers to this code)

Learn more

Both TTS paths consume WebSocket frames until synthesis is complete. A close before the end_of_stream response can also mean a worker failure or an interrupted connection. This branch now exits normally and flushes already-received audio; the streaming branch also ends its segment normally. The base synthesis checks and base stream checks accept a partial result once audio has been emitted, so neither detects the truncated response.

Example: Gradium sends 0.5 seconds of a two-second utterance and closes with code 1011. The caller receives 0.5 seconds as a successful synthesis instead of a failure.

Recommended fix: Track protocol completion separately from socket closure. Raise an API error on a premature close in both receive loops, retaining the close code and retryability where applicable; only treat a close as success if the API contract explicitly guarantees it marks completion.

Devin Review


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

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.

🔴 Provider failures lose their error details

When Gradium sends a type: error frame, ChunkedStream and SynthesizeStream ignore its message and code. Callers get a generic failure without the provider's reason or correct retry policy.

(Refers to this code)

Learn more

Gradium reports synthesis failures in a JSON text frame with type: error, a message, and a WebSocket close code. Both receive loops now pass this frame to the unknown-message warning instead of surfacing it. If no audio arrives, the base synthesis check or base stream check supplies a generic no-audio failure. A request rejected for an invalid model can then be retried rather than failing immediately; if partial audio already arrived, it can even appear successful.

Example: An error frame with message Unknown model 'nope' and code 1003 followed by a close produces a generic no-audio failure, with no model name and potentially unnecessary retries.

Recommended fix: Handle type: error explicitly in both receive loops. Preserve its message, code, and non-retryable protocol-error policy when raising the provider error.

Devin Review


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

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.

🔴 Closed streaming socket leaves synthesis waiting

When Gradium closes before the caller ends input, recv_task completes while send_task waits for more words. The surrounding gather waits for send_task, leaving synthesis stuck on a closed socket.

(Refers to this code)

Learn more

Streaming synthesis runs a sender that reads text from a word stream alongside a WebSocket receiver. The sender waits for further input until the application flushes it, and gather waits for both tasks. On a close, the receiver now ends the segment and returns normally, leaving a sender with no input waiting indefinitely; previously the close raised an error and the pending sender was cancelled.

Example: An agent pushes hello , pauses for more LLM tokens, and Gradium closes the socket. The streaming TTS operation keeps waiting for tokens rather than reporting the lost connection.

Recommended fix: Raise a provider/connection error on unexpected closure so the gather exits and the finally block cancels the sender. If normal close before input termination is supported, explicitly cancel the sender and decide how to account for unsent input.

Devin Review


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

@hhugo-gradium

Copy link
Copy Markdown
Contributor Author

@tinalenguyen, this is now rebased on top of #7873

@hhugo-gradium

Copy link
Copy Markdown
Contributor Author

cc @longcw

@longcw
longcw merged commit b7919c6 into livekit:main Oct 20, 2026
17 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.

3 participants