Skip to content

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

Merged
PaulAsjes merged 8 commits into
mainfrom
fix/realtime-stt-missing-options
Aug 11, 2026
Merged

Add missing realtime speech-to-text options#830
PaulAsjes merged 8 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, the xi-api-key header is not sent. The token takes precedence server-side (the endpoint tries it first and closes the session rather than falling back to the key), so sending the key alongside it would put a long-lived credential on the wire that could not authenticate the connection anyway.

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
  • invalid_request, sent when the server rejects the connection parameters. It accepts the socket, sends the error, then closes — so without an enum member, a bad parameter surfaced as a connection close with no error event at all. This was missing from the spec too; added in elevenlabs/xi#44830 and now synced.

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.

Parameter constraints are left to the API

An earlier revision validated the filter_background_audio/include_timestamps conflict and the keyterms limits client-side. Removed per @kraenhansen's review: replicating server-side invariants gives no type safety, and the cost is drift — if a constraint is relaxed server-side, callers cannot adopt the change until the SDK ships an update. The constraints remain in the docstrings, mirroring the API reference, since a stale doc misleads far less than a hard failure.

Testing

tests/test_stt_realtime.py goes from 17 to 33 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, 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/.

Root export

RealtimeEntityDetection is exported from elevenlabs.realtime, not the package root. An earlier revision hand-added it 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. That file is back to generated state. Adding the root export properly needs a generators.yml change in the API definition repo; happy to open that separately if wanted.

🤖 Generated with Claude Code


Note

Medium Risk
Changes realtime STT connection auth and event behavior (including a breaking-style early ValueError without credentials), but scope is limited to the realtime module with strong test coverage.

Overview
Bumps the SDK to 2.63.0 and brings the hand-written realtime Scribe wrapper in line with the WebSocket API.

Connection optionsRealtimeAudioOptions and RealtimeUrlOptions now share a _RealtimeSharedOptions base and forward additional query params: secondary_languages, include_language_detection, entity_detection, filter_background_audio, enable_logging, and token. Shared mapping lives in _shared_url_kwargs; RealtimeEntityDetection is exported from elevenlabs.realtime.

Authentication — A token authenticates via the query string and omits xi-api-key. Connecting with neither api_key nor token now raises ValueError instead of sending an empty header.

Event dispatchRealtimeEvents gains handlers for final_transcript, final_transcript_with_timestamps, committed_transcript_entities, invalid_request, and unaccepted_terms (the server literal; the old unaccepted_terms_error name is still emitted for compatibility). invalid_request and unaccepted_terms also surface on the generic ERROR event.

Teststests/test_stt_realtime.py adds exhaustive URL/auth coverage and async message-dispatch tests.

Reviewed by Cursor Bugbot for commit e49fc09. 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 thread src/elevenlabs/realtime/scribe.py Outdated
PaulAsjes and others added 3 commits August 10, 2026 12:03
Per review: replicating server-side invariants client-side gives no type
safety, and the server reports these anyway. The real cost is drift - if
the constraint is relaxed server-side, callers cannot adopt the change
until the SDK is updated and shipped, which is a failure mode nobody is
watching for.

Drops the filter_background_audio/include_timestamps conflict check and
the keyterms count and length checks, along with their tests. The
constraints stay in the docstrings, mirroring the API reference, since a
stale doc misleads far less than a hard failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Now declared in asyncapi.yml (elevenlabs/xi#44830), so the SDK can pick it
up from the spec.

The server sends this when it rejects the connection parameters: it
accepts the websocket, sends the error, then closes. The dispatcher
resolves events through RealtimeEvents(message_type) and ignores unknown
types, so the message was dropped and a bad parameter surfaced as a
connection close with no error event at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of the tests added on this branch asserted nothing about the SDK.
test_committed_transcript_entities_carries_entities checked that a payload
the SDK never transforms survived an emitter, and the list-parameter test
duplicated the exhaustive serialization assertion, which already fails on
a joined list.

Strengthens the connect() option test instead: it is the only cover for
the _shared_url_kwargs mapping, where a typo drops an option silently, so
it now asserts the full parameter set rather than a few substrings.

Verified by mutation: comma-joining a list, enable_logging truthiness, a
mapping typo, sending the api key alongside a token, and removing either
the INVALID_REQUEST or FINAL_TRANSCRIPT enum member each still fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PaulAsjes and others added 2 commits August 11, 2026 11:03
Minor: adds realtime speech-to-text options and message types. Matches the
JS SDK, which the two are released in lockstep with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PaulAsjes
PaulAsjes merged commit e1cf8db into main Aug 11, 2026
4 checks passed
@PaulAsjes
PaulAsjes deleted the fix/realtime-stt-missing-options branch August 11, 2026 09:52
kraenhansen added a commit that referenced this pull request Aug 11, 2026
* [fern-generated] Update SDK

Generated by Fern
CLI Version: unknown
Generators:
  - fernapi/fern-python-sdk: 4.64.1

* [fern-replay] Applied customizations

Patches with unresolved conflicts (1):
  - patch-560b8934: fix: send list-of-primitive multipart fields as repeated form fields (#819) (#825)
    Run `fern-replay resolve` to apply these customizations.

Patches absorbed by generator (1):
  - patch-e1cf8db9: Add missing realtime speech-to-text options (#830)
    The generator now produces these customizations natively.

* fix: preserve repeated-form-field encoding for keyterms/webhook_ids

Fern regeneration reverted the #825 fix and dropped the json import,
breaking mypy and re-introducing the original multipart bug.

---------

Co-authored-by: fern-api[bot] <115122769+fern-api[bot]@users.noreply.github.com>
Co-authored-by: Kræn Hansen <kraen@elevenlabs.io>
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