Repository navigation
fix(gradium): STT model delay and clean session end (1/2) - #7870
hhugo-gradium wants to merge 4 commits into
Conversation
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>
davencyw
left a comment
There was a problem hiding this comment.
Thanks @hhugo-gradium! The changes make sense to me and are clean. There are two things that stood out:
Audio sends can still fail with a raw, non-retryable error:
The final end_of_stream send is guarded now, but the audio sends in the send_task loop aren't. If the server closes the socket and an audio write happens before recv_task processes the EOS/close frame, send_task raises ClientConnectionResetError. gather passes it up, and the base class treats any non-APIError as fatal, so it doesn't retry. That's the same failure the PR describes, just through a race. I'd catch aiohttp.ClientError / ConnectionError around the audio sends too, and either return (so recv_task reports the close) or raise a retryable APIConnectionError.
Every error frame is retried:
_api_error returns a plain APIError, which defaults to retryable=True. So errors like "Unsupported language" or "no credits" are retried max_retry times with backoff before failing, and the server message ends up behind a generic "failed to recognize speech after N attempts". Since we parse the frame now, could we mark config/billing errors as retryable=False, e.g. based on the code field?
Not sure if those two are worth to fix already since the code is already an upgrade, your choice!
|
|
||
| @property | ||
| def delay_in_tokens(self) -> int: | ||
| def delay_in_frames(self) -> int: |
There was a problem hiding this comment.
This changes the public API, not sure if it will break anything downstreams? Alternatively we could have an alias. But it does indeed change the semantics. Unsure what the right thing to do is here.
| if self._ready_msg is not None: | ||
| return int(self._ready_msg.get("delay_in_tokens", 6)) | ||
| return int(self._ready_msg.get("delay_in_frames", 6)) | ||
| return 6 |
There was a problem hiding this comment.
Does it make sense to have 6 hardcoded here? Do we know where it is coming from? Do we expect the key to not exist in certain cases? I know it's not your code, but I thought you might have found something along the way.
| close_code: int = 1000, | ||
| fail_send_on: str | None = None, | ||
| ) -> None: | ||
| import aiohttp |
There was a problem hiding this comment.
Nit: Top level import might be a bit cleaner.
Summary
Three commits on the Gradium STT stream's session end, plus the test harness they share.
delay_in_frames; the stream looked updelay_in_tokensand 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: aSTART_OF_SPEECH, a one-wordFINAL_TRANSCRIPT, anEND_OF_SPEECH.send_taskreturned without telling the server and theclosing_wsflag was never set, sorecv_taskpolled 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'send_of_streamthe buffered words are emitted as the final transcript before the stream returns. Servererrorframes raiseAPIErrorwith 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.end_of_streampath 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-initiatedend_of_streamwhile 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 retryableAPIStatusErrorso the stream reconnects. The finalend_of_streamsend is guarded against the server closing first.Testing
tests/test_plugin_gradium_stt.pygains a scripted fake websocket that replaysready,textandstepframes, blocks until the client'send_of_streamlike 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()sendsend_of_streamand the stream ends with the pending words as a final transcript; anerrorframe raises with the server message; a close in place of the server's reply still yields the final transcript; a serverend_of_streammid-input finalizes then raises a retryable error; the final send on an already-closed socket is not an error. All fail onmain. The module is hermetic and now carries theunitmarker so CI runs it.ruff,ruff format --checkandmypy --strictclean on the plugin.api.gradium.aiwith the repository's 46 s speech sample streamed in real time: one turn, and the stream ended 0.5 s afterend_input(). Onmainthe same run split the speech into two turns and, afterend_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