Skip to content

feat(sarvam): add bulbul:v4-flash TTS - #7335

Open
dhruvladia-sarvam wants to merge 7 commits into
livekit:mainfrom
dhruvladia-sarvam:sarvam-tts-bulbul-v4
Open

dhruvladia-sarvam wants to merge 7 commits into
livekit:mainfrom
dhruvladia-sarvam:sarvam-tts-bulbul-v4

Conversation

@dhruvladia-sarvam

Copy link
Copy Markdown
Contributor

Summary

  • Add bulbul:v4-flash to the Sarvam TTS plugin with its own speaker catalogue, parameter bounds, IN22 language codes, and /text-to-speech/ws/v2 pinning. The API rejects bulbul:v4; that string is not accepted as an alias. Default model stays bulbul:v3.
  • Forward Sarvam's websocket error code (or the NNN: message prefix) into APIStatusError so 4xx failures are not retried, and keep the raw Sarvam frame in the message/body plus request_id.
  • Cover the wire name, speaker compatibility, v4-flash bounds, and error-code retry mapping in unit tests.

Test plan

  • uv run pytest tests/test_plugin_sarvam_tts.py --unit (13 new/updated v4-flash and error-mapping tests pass; 2 pre-existing max_retry==0 assertions still fail and are unrelated)
  • ruff check / ruff format / mypy clean on the plugin
  • Live websocket 422 from a subscription without v4-flash access surfaces as status_code=422, retryable=False with request id
  • Live v3 REST and websocket still produce audio (no regression)
  • Re-run live REST + websocket audio for bulbul:v4-flash once beta access is restored on the key

Made with Cursor

The API rejects `bulbul:v4` and only accepts `bulbul:v4-flash`. Pin that
wire name, use the v4-flash speaker catalogue and /ws/v2, and forward
Sarvam's websocket status codes so 4xx failures are not retried.

Co-authored-by: Cursor <cursoragent@cursor.com>
@dhruvladia-sarvam
dhruvladia-sarvam requested a review from a team as a code owner September 18, 2026 10:35
devin-ai-integration[bot]

This comment was marked as resolved.

…ockets

update_options wrote the new model before validating the retained speaker, so
a rejected switch left an unusable model/speaker pair, and stored pitch, pace,
loudness and temperature were never re-checked against the new model's tighter
v4-flash bounds. Validate a candidate copy and commit it only once every
model-dependent limit passes.

model and send_completion_event are pinned in the handshake URL, so invalidate
the pool when either changes rather than letting a socket keep the old
endpoint. Tag the config debug log for PII redaction, and drop two tests that
asserted retry behaviour this branch does not implement.

Co-authored-by: Cursor <cursoragent@cursor.com>
@dhruvladia-sarvam

dhruvladia-sarvam commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Pooled sockets keep old endpoint / Model switches retain invalid settings / Rejected updates corrupt model state — all three shared one root cause in update_options, which mutated self._opts field by field while validating only the arguments of that call. It now applies changes to a replace(self._opts) candidate, re-validates speaker compatibility and pace/loudness/temperature against the resulting model (clamping pitch the same way the constructor does), and assigns self._opts only once everything passes. A rejected update leaves the live options untouched. It also calls self._pool.invalidate() when model or send_completion_event changes, since both are pinned in the handshake URL and v4-flash is served on a different path.

Synthesis config bypasses PII redaction — the debug log extra is now lk.pii.config, matching the convention already used elsewhere in this plugin.

WebSocket errors expose customer content — not changing this one. Putting the raw provider frame in the message is pre-existing behaviour on both this websocket path and the REST path above it, and surfacing Sarvam's verbatim error is the point of the change: it is what lets a caller correlate a failure with Sarvam's server-side logs via request_id. The frame is also already tagged lk.pii.raw_message on the log record, and in practice these frames carry schema and configuration messages rather than echoed speech. Happy to trim it to just the status code and request_id if maintainers would rather not have provider text in the exception message.

Also dropped two tests that asserted conn_options.max_retry == 0, which this branch does not implement — that belongs to separate work. unit-tests should be green now.

devin-ai-integration[bot]

This comment was marked as resolved.

dhruvladia-sarvam and others added 2 commits September 18, 2026 17:26
The websocket error log put Sarvam's message in the log body and in an
untagged error_message extra, so a collector could not redact provider text
that may echo customer content. Use a static body and tag the field
lk.pii.error_message, matching the raw frame already tagged beside it.

Co-authored-by: Cursor <cursoragent@cursor.com>
The pool handshakes sockets from the TTS's current options while each stream
carries its own snapshot, so a stream constructed before update_options sent a
config frame for the previous model over a socket opened for the new one, and
v4-flash is served on a different path. Record the options behind each socket
and have the stream take the model-coupled set from them. Language, sample rate
and codec ride in the config frame and the emitter is already initialized from
them, so the stream keeps its own.

Co-authored-by: Cursor <cursoragent@cursor.com>
@dhruvladia-sarvam

dhruvladia-sarvam commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Queued streams mix model versions — correct, and the root cause was that the handshake and the config frame read different sources: the pool builds sockets in TTS._connect_ws from the TTS's live _opts, while each stream carries a replace(tts._opts) snapshot. Rather than re-syncing the stream from the TTS (which races and would also let an in-flight stream drift between segments), each socket now records the options it was handshaken with, keyed by id(ws) alongside the existing keepalive map, and the stream takes the model-coupled set — model, send_completion_event, speaker and the tuning bounds — from the socket it is actually handed. The language, sample rate and codec stay snapshotted, since those ride in the config frame rather than the handshake and the output emitter was already initialized from them.

Worth noting this one predates the pool invalidation: _connect_ws already built the URL from live options, so the same stream hit the identical mismatch whenever the pool happened to be empty. The invalidation removed the opposite skew (a new stream reusing a stale socket) and made this one deterministic in the one ordering where a stream is constructed, then update_options is called, then text is pushed.

Provider errors bypass log redaction — fixed. That log put Sarvam's message in the body via an f-string and in an untagged error_message extra, neither of which a collector can redact, while the raw frame beside it was already tagged. It now uses a static body with lk.pii.error_message. I also checked the receive task's except Exception handler, which re-logs with exc_info=True, but the existing except (APIStatusError, APIConnectionError, APITimeoutError): raise above it already intercepts these without logging, so there was no second leak there.

devin-ai-integration[bot]

This comment was marked as resolved.

_adopt_handshake_opts copied the socket's whole option snapshot, so reusing a
still-valid pooled socket reverted a config-only update_options: speaker, pace
and the other tuning fields are sent per segment rather than pinned in the
handshake. Only model is carried by both, so align that and leave the rest to
the stream's snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>
@dhruvladia-sarvam

dhruvladia-sarvam commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

_adopt_handshake_opts copied the socket's whole snapshot, but only model is carried by both the handshake URL and the per-segment config frame; speaker, pace, pitch, loudness and temperature are sent per segment and should keep following the stream's own snapshot. It now returns early when the socket already speaks the stream's model, so reusing a still-valid pooled socket no longer reverts a config-only update_options, and it takes the model-coupled set only when the models actually differ. Added a test that stamps a socket, applies a speaker and pace update that leaves the handshake valid, and asserts the update survives.

I also confirmed send_completion_event is read only by _websocket_url, never by the stream, so it does not need to be adopted.

APIStatusError.__str__ renders both message and body, and the framework logs
that with %s before each retry, so a Sarvam error frame placed in either field
reached the log where no collector can redact it. Carry only the status code
and request_id on the exception; the frame is already recorded in full on the
lk.pii.* tagged log record beside it.

Co-authored-by: Cursor <cursoragent@cursor.com>
@dhruvladia-sarvam

dhruvladia-sarvam commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

APIStatusError.__str__ in livekit-agents/livekit/agents/_exceptions.py: renders both message and body, and the framework formats the exception with %s into logger.warning("failed to synthesize speech: %s", ...) before each retry. So provider text in either field lands in a log body no collector can redact, and trimming only the message would not have closed it.

Nothing provider-written goes on the exception now. It carries status_code and request_id, which are the non-PII identifiers a caller needs to correlate against Sarvam's server-side logs and to decide retryability. The frame itself is still recorded in full one line earlier under lk.pii.error_message and lk.pii.raw_message, which is the channel that actually gets redacted, and a test asserts the frame survives there while staying out of str(exc).

Worth flagging for maintainers separately: APIStatusError.__str__ including body means any plugin that sets body to a provider response has the same exposure through that retry log line, so this may deserve a fix a layer up rather than per-plugin.

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