Add missing realtime speech-to-text options - #830
Conversation
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
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>
| 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)}" | ||
| ) |
There was a problem hiding this comment.
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.

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.ymlis used as the contract throughout.New options
Accepted by both
RealtimeAudioOptionsandRealtimeUrlOptions:secondary_languagessecondary_languages(repeated)include_language_detectioninclude_language_detectionentity_detectionentity_detection(single value or repeated)filter_background_audiofilter_background_audioenable_loggingenable_loggingtokentokentokenis the single-use token param. When supplied and no API key is configured, thexi-api-keyheader 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 atry/except ValueErrorthat ignores unknown types, so any message type missing from the enum vanished:final_transcriptandfinal_transcript_with_timestampscommitted_transcript_entitiesunaccepted_terms was unreachable
The enum only carried
UNACCEPTED_TERMS_ERROR = "unaccepted_terms_error", which the server never sends — the spec declaresunaccepted_terms. SoRealtimeEvents(message_type)raisedValueErrorand the message was swallowed, meaning neither the specific event nor the genericERRORevent ever fired for it.UNACCEPTED_TERMSis 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
RealtimeAudioOptionsandRealtimeUrlOptionsrestated every shared field, which is what let them drift apart. They now inherit from a shared_RealtimeSharedOptionsbase and only declare what's specific to each mode (audio_format/sample_ratevsurl). Similarly,_connect_audioand_connect_urleach unpacked all the shared options by hand before passing them along; that's now one_shared_url_kwargshelper, so adding a parameter touches one place instead of three.Behaviour change
Connecting with neither an
api_keynor atokennow raisesValueErrorinstead of sending an emptyxi-api-keyheader and waiting for the server to reject it. Note this path was already non-functional:ScribeRealtimeonly ever receives the extractedxi-api-keyvalue, 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_audiocombined withinclude_timestamps(the server rejects it, since dropping low-activity frames shifts the timestamp timeline), and thekeytermslimits. On provenance: the max-50 limit is inasyncapi.yml; the 20-character-per-keyterm limit is not in the ws spec but is enforced by the endpoint.Testing
tests/test_stt_realtime.pygoes 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
Falsebooleans surviving serialization (a truthiness check would silently revertenable_logging=False), list parameters repeating rather than joining, both keyterm limits inclusive at the boundary, options threading throughconnect(), 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_loggingto a truthiness check, appendingaudio_formattwice, 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 forraisesTypeError, the handler's broadexcept Exceptionswallows 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.
ruffclean on the changed files;mypyreports only 3 pre-existing errors in generatedcore/files, none inrealtime/.Spec gap
invalid_requestis themessage_typethe server sends when it rejects handshake parameters, but it isn't declared inasyncapi.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-sidetokenauth (query param; when set,xi-api-keyis not sent). Shared settings live on_RealtimeSharedOptionswith_shared_url_kwargsso audio and URL modes stay in sync. Pre-handshake checks rejectfilter_background_audio+include_timestampsand enforce keyterms limits (50 terms, 20 chars each).RealtimeEventsgainsfinal_transcript,final_transcript_with_timestamps,committed_transcript_entities, andunaccepted_terms(server literal was previously mismatched asunaccepted_terms_error, so those messages were dropped).unaccepted_termsstill also emits the legacy event name and genericERROR.Behavior change: connecting with neither
api_keynortokennow raisesValueErrorinstead 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.