Skip to content

fix(gradium): STT model delay and clean session end (1/2) - #7870

Draft
hhugo-gradium wants to merge 4 commits into
livekit:mainfrom
hhugo-gradium:fix/gradium-stt-eos
Draft

hhugo-gradium wants to merge 4 commits into
livekit:mainfrom
hhugo-gradium:fix/gradium-stt-eos

Conversation

@hhugo-gradium

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

Copy link
Copy Markdown
Contributor

Summary

Three commits on the Gradium STT stream's session end, plus the test harness they share.

  • The model delay was read from a key the server never sends. The ready message carries delay_in_frames; the stream looked up delay_in_tokens and always fell back to 6. Production models run with a delay of 10, so a turn was finalized 4 steps (320 ms) before the model had emitted its last words, which then surfaced as a spurious new turn: a START_OF_SPEECH, a one-word FINAL_TRANSCRIPT, an END_OF_SPEECH.
  • The session never ended on its own. When the input channel closed, send_task returned without telling the server and the closing_ws flag was never set, so recv_task polled until the server dropped the idle socket two minutes later, which was then reported as an unexpected close and retried against a closed input. The unused _CLOSE_MSG (terminate_session) was another provider's protocol. Now {"type": "end_of_stream"} is sent once the input ends, and on the server's end_of_stream the buffered words are emitted as the final transcript before the stream returns. Server error frames raise APIError with the server's message instead of being logged as an unknown type, and the VAD silence flush is skipped once the input channel is closed.
  • Every way a session ends keeps the transcript. Only the server's end_of_stream path emitted the words buffered since the last VAD finalization; a close frame in its place, or the receive timeout while closing, dropped them. All three exits finalize now. A server-initiated end_of_stream while audio is still being sent (a duration limit, for instance) used to be treated as a clean end, after which the next audio write failed with a raw, non-retryable connection error; it now finalizes and raises a retryable APIStatusError so the stream reconnects. The final end_of_stream send is guarded against the server closing first.

Testing

  • tests/test_plugin_gradium_stt.py gains a scripted fake websocket that replays ready, text and step frames, blocks until the client's end_of_stream like a live socket, then replies with the server's EOS and a normal close. Six tests: a positive VAD finalizes after the delay the ready message announced; end_input() sends end_of_stream and the stream ends with the pending words as a final transcript; an error frame raises with the server message; a close in place of the server's reply still yields the final transcript; a server end_of_stream mid-input finalizes then raises a retryable error; the final send on an already-closed socket is not an error. All fail on main. The module is hermetic and now carries the unit marker so CI runs it.
  • ruff, ruff format --check and mypy --strict clean on the plugin.
  • Live against api.gradium.ai with the repository's 46 s speech sample streamed in real time: one turn, and the stream ended 0.5 s after end_input(). On main the same run split the speech into two turns and, after end_input(), hung until the server closed the socket with 1008 "Session produced no output for 120 seconds". WER against the reference was 0.034 on 2026-10-14 and 0.074 on 2026-10-16, when the server returned lowercase, unpunctuated text that the scorer counts word by word; the plugin relays the text unchanged.

Related

#7814 reports the configured model name for STT as well. The remaining STT fixes (cumulative interim transcripts, vad_bucket=0, connect timeout) are stacked on this PR as the next one.

🤖 Generated with Claude Code

hhugo-gradium and others added 3 commits October 16, 2026 06:21
The STT stream looked up `delay_in_tokens` in the ready message, a key
the server never sends (it is `delay_in_frames`), so the hard-coded
fallback of 6 steps was always used. Production models run with a delay
of 10, so a turn was finalized 4 steps (320ms) before the model had
emitted its last words, which then showed up as a spurious new turn.

Adds a scripted fake websocket harness for the STT tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When the input channel closed, send_task returned without telling the
server; `closing_ws` was never set, so recv_task polled until the server
dropped the idle socket, which was then reported as an unexpected close
and retried against a closed input. The unused `_CLOSE_MSG`
(`terminate_session`) was another provider's protocol.

Send `{"type": "end_of_stream"}` once the input ends, mark the socket as
closing, and on the server's end_of_stream emit the buffered words as
the final transcript before returning. Raise APIError with the server's
message on `error` frames instead of logging them as unknown. Skip the
VAD silence flush once the input channel is closed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
They only talk to fake websockets, so they belong in the hermetic
category that CI runs rather than the provider category.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Only the server's end_of_stream path emitted the words buffered since
the last VAD finalization. The two other clean exits after end_input(),
a close frame instead of the reply and the receive timeout, dropped them
without a FINAL_TRANSCRIPT. Finalize on all three.

A server-initiated end_of_stream while audio is still being sent (a
session duration limit, for instance) was treated as a clean end: the
receive task returned and the next audio write failed with a raw
connection error the base class does not retry. Finalize, then raise a
retryable APIStatusError so the stream reconnects.

The final end_of_stream send is guarded: if the server closed the
socket at the same moment, that is not an error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant