Skip to content

Add missing realtime speech-to-text options - #830

Open
PaulAsjes wants to merge 3 commits into
mainfrom
fix/realtime-stt-missing-options
Open

Add missing realtime speech-to-text options#830
PaulAsjes wants to merge 3 commits into
mainfrom
fix/realtime-stt-missing-options

Conversation

@PaulAsjes

@PaulAsjes PaulAsjes commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Python companion to elevenlabs/elevenlabs-js#436, which fixes elevenlabs/elevenlabs-js#434. The hand-written realtime STT wrapper in src/elevenlabs/realtime/ only exposed a subset of the parameters the websocket endpoint accepts. asyncapi.yml is used as the contract throughout.

New options

Accepted by both RealtimeAudioOptions and RealtimeUrlOptions:

Option Query param
secondary_languages secondary_languages (repeated)
include_language_detection include_language_detection
entity_detection entity_detection (single value or repeated)
filter_background_audio filter_background_audio
enable_logging enable_logging
token token

token is the single-use token param. When supplied and no API key is configured, the xi-api-key header is omitted so the token authenticates on its own.

Server messages that were being silently dropped

The dispatcher resolves events with RealtimeEvents(message_type) inside a try/except ValueError that ignores unknown types, so any message type missing from the enum vanished:

  • final_transcript and final_transcript_with_timestamps
  • committed_transcript_entities

unaccepted_terms was unreachable

The enum only carried UNACCEPTED_TERMS_ERROR = "unaccepted_terms_error", which the server never sends — the spec declares unaccepted_terms. So RealtimeEvents(message_type) raised ValueError and the message was swallowed, meaning neither the specific event nor the generic ERROR event ever fired for it.

UNACCEPTED_TERMS is added with the correct literal. Both event names are emitted, so existing subscribers on the old name keep working; the old member is marked deprecated in a comment rather than removed.

Tidying the option types

RealtimeAudioOptions and RealtimeUrlOptions restated every shared field, which is what let them drift apart. They now inherit from a shared _RealtimeSharedOptions base and only declare what's specific to each mode (audio_format/sample_rate vs url). Similarly, _connect_audio and _connect_url each unpacked all the shared options by hand before passing them along; that's now one _shared_url_kwargs helper, so adding a parameter touches one place instead of three.

Behaviour change

Connecting with neither an api_key nor a token now raises ValueError instead of sending an empty xi-api-key header and waiting for the server to reject it. Note this path was already non-functional: ScribeRealtime only ever receives the extracted xi-api-key value, never any custom headers passed to the client, so custom-header auth was never supported for realtime.

Validation

Two checks now run before the handshake rather than failing after it: filter_background_audio combined with include_timestamps (the server rejects it, since dropping low-activity frames shifts the timestamp timeline), and the keyterms limits. On provenance: the max-50 limit is in asyncapi.yml; the 20-character-per-keyterm limit is not in the ws spec but is enforced by the endpoint.

Testing

tests/test_stt_realtime.py goes from 17 to 34 tests.

URL assertions are exhaustive rather than one-substring-per-parameter, so a renamed, dropped or duplicated parameter fails rather than passing unnoticed. Beyond that: explicitly False booleans surviving serialization (a truthiness check would silently revert enable_logging=False), list parameters repeating rather than joining, both keyterm limits inclusive at the boundary, options threading through connect(), and API key vs token vs neither asserted on the headers the socket is opened with.

Verified by mutation. Each of these fails at least two tests: comma-joining a list parameter, switching enable_logging to a truthiness check, appending audio_format twice, and always sending the api key header.

The dispatch tests use a real async-iterable fake websocket. The existing tests set __aiter__ = MagicMock(return_value=iter([])), which isn't an async iterator — async for raises TypeError, the handler's broad except Exception swallows it, and the test still passes because it only asserts on the URL. That pattern silently cannot exercise message handling, so I didn't build on it. I confirmed the new dispatch tests fail against unpatched source (AttributeError: type object 'RealtimeEvents' has no attribute 'COMMITTED_TRANSCRIPT_ENTITIES', plus empty handler results).

Full suite: 183 passed, 16 failed. The same 16 fail on a clean tree (they call the live API and need credentials) — baseline is 166 passed, 16 failed, so no new failures. ruff clean on the changed files; mypy reports only 3 pre-existing errors in generated core/ files, none in realtime/.

Spec gap

invalid_request is the message_type the server sends when it rejects handshake parameters, but it isn't declared in asyncapi.yml, so neither SDK claims it. Worth adding to the spec: until then a rejected parameter closes the connection with no error event.

🤖 Generated with Claude Code


Note

Medium Risk
Touches realtime auth (token vs API key) and event dispatch for errors/terms; mostly additive API surface with one stricter connect validation that could surface earlier for misconfigured clients.

Overview
Brings the hand-written realtime Scribe wrapper in line with the websocket API: new connect options, fixed message routing, and clearer auth/validation.

Connect options now include secondary_languages, include_language_detection, entity_detection, filter_background_audio, enable_logging, and client-side token auth (query param; when set, xi-api-key is not sent). Shared settings live on _RealtimeSharedOptions with _shared_url_kwargs so audio and URL modes stay in sync. Pre-handshake checks reject filter_background_audio + include_timestamps and enforce keyterms limits (50 terms, 20 chars each).

RealtimeEvents gains final_transcript, final_transcript_with_timestamps, committed_transcript_entities, and unaccepted_terms (server literal was previously mismatched as unaccepted_terms_error, so those messages were dropped). unaccepted_terms still also emits the legacy event name and generic ERROR.

Behavior change: connecting with neither api_key nor token now raises ValueError instead of opening with an empty API key header.

Tests expand URL serialization, auth headers, and real async message dispatch for the new events.

Reviewed by Cursor Bugbot for commit b9f3c81. Bugbot is set up for automated code reviews on this repo. Configure here.

Ports the TypeScript SDK fix (elevenlabs-js#436) to Python. The
hand-written realtime wrapper only exposed a subset of the parameters the
websocket endpoint accepts. asyncapi.yml is the contract used throughout.

New options, accepted by both connection modes:
- secondary_languages, sent as repeated secondary_languages params
- include_language_detection
- entity_detection, accepting a single category/type or a list
- filter_background_audio
- enable_logging
- token, a single-use token that authenticates the session on its own, so
  the xi-api-key header is omitted when no key is configured

The two option TypedDicts duplicated every shared field, so they now
inherit from a shared base instead of restating it, and the connect
methods build their query kwargs through one helper rather than
unpacking each option twice.

New server messages, previously dropped by the dispatcher:
- final_transcript and final_transcript_with_timestamps
- committed_transcript_entities

Also fixes unaccepted_terms: the enum only carried
"unaccepted_terms_error", which the server never sends, so
RealtimeEvents(message_type) raised and the message was swallowed. The
correct literal is added and both event names are emitted so existing
subscribers keep firing.

Behaviour change: connecting with neither an api_key nor a token now
raises ValueError instead of sending an empty xi-api-key header and
waiting for the server to reject it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9250190. Configure here.

Comment thread src/elevenlabs/__init__.py Outdated
The URL tests asserted one substring each, which only proved a value
passed in came back out. Replaced with assertions on the exhaustive
parameter set, so a renamed, dropped or duplicated parameter fails, plus
cases for the behaviour that can actually regress: explicitly false
booleans surviving serialization, list parameters repeating rather than
joining, and both keyterm limits being inclusive at the boundary.

Also drops the "new params" framing, which described the diff rather than
the endpoint.

Verified by mutation: comma-joining a list, switching enable_logging to a
truthiness check, appending audio_format twice, and always sending the
api key header each fail at least two tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes from bugbot review.

The api key was still sent whenever one was configured, even when the
caller passed a token. The server tries the single-use token first and
closes the session if it fails rather than falling back, so the key could
never authenticate that connection - it was just a long-lived credential
on the wire for no reason. A token now suppresses the header outright,
which is also what the option's docstring already claimed.

RealtimeEntityDetection was hand-added to src/elevenlabs/__init__.py,
which Fern generates. The realtime root exports come from
additional_init_exports in the API definition's generators.yml, so the
next regeneration would have dropped this one while leaving the others,
breaking `from elevenlabs import RealtimeEntityDetection`. Reverted that
file to generated state; the type is exported from elevenlabs.realtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PaulAsjes
PaulAsjes requested a review from kraenhansen July 31, 2026 18:33
Comment on lines +418 to +433
if filter_background_audio and include_timestamps:
raise ValueError(
"filter_background_audio cannot be combined with include_timestamps"
)

if keyterms is not None:
if len(keyterms) > 50:
raise ValueError(
f"keyterms cannot exceed 50 entries, received {len(keyterms)}"
)
too_long = next((term for term in keyterms if len(term) > 20), None)
if too_long is not None:
raise ValueError(
f"Each keyterm must be at most 20 characters, "
f"'{too_long}' is {len(too_long)}"
)

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.

Curious on your thoughts around adding these client-side checks. I mean, it will fail faster for sure, but they don't provide additional type safety and they would fail with the server (hopefully) propagating a meaningful error anyway.

What value does a client-side check of these invariants give us?

The drawback on the other hand is that we'd have to discover, update and ship a new version of the SDK if the server choose to lift these constraints. I'm concerned that we'll forget or be too busy to do this, causing pain for someone wanting to adopt potential server-side changes.

To me, these constraints and invariants are server-side implementation details that our clients shouldn't replicate.

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.

Missing BaseOptions for realtime Speech-To-Text websocket endpoint

2 participants