Skip to content

Commit acf361c

Browse files
fix(backend): name the serving provider on the live-STT ready event (#11822)
Closes the remaining backend slice of #11306. ## What was wrong `ListenSessionRuntime.run` emitted a bare `ready` as soon as the provider socket opened: ```python self.send_event(MessageServiceStatusEvent(status='ready')) ``` `_create_stt_socket` can walk the fallback chain (#11695, #11752), so by the time this fires the session may be served by a provider other than the one the serving policy selected. The client clears its terminal-failure state on `ready`, so "Listening" equally described a healthy session and a fallback socket about to die, with nothing in the payload to tell them apart. `MessageServiceStatusEvent` already carries `provider` and `reason` — added as the additive terminal-failure contract and unused on this path — and `ListenReceiver._serving_provider()` already resolves the serving provider at use time. This wires the two together. @kodjima33 named this slice explicitly when merging the attribution half as #11359: > Still open here: … surfacing the actual provider + fallback reason on the `ready` event > so the client can tell a fallback session from a healthy Parakeet one. ## What changed - `ready` now carries `provider`, read from `_serving_provider()` **at emission time**. Resolving it any earlier is precisely the attribution bug #11359 fixed on the terminal-failure path, so the accessor is called here rather than reusing a value held from bootstrap. - `_bootstrap` records the policy-**selected** provider (`stt_service_selected`). That value is fixed at selection and is not a snapshot of the serving provider; it exists only so the emission can tell whether the chain moved the session. - When serving ≠ selected, `reason` is `fallback_from_<selected>`. A healthy session carries no `reason` at all. - Custom-STT sessions claim no backend provider — the client produces its own transcripts — so neither field is emitted. Payload for a Modulate session that Velma refused and Deepgram took over: ```json {"type": "service_status", "status": "ready", "provider": "deepgram", "reason": "fallback_from_modulate"} ``` ## Scope **Backend emission only.** Making the clients *act* on the new fields is a separate, larger change across three platforms (Flutter app, iOS, desktop) and is deliberately not included here. This PR only makes the information available to them. The underlying vendor cause of a fallback (`quota` / `timeout` / `provider_5xx`) is classified inside `connect_stt_socket_with_fallback` and already recorded through `record_fallback`; it is not returned to the caller. Plumbing it onto the event would change that shared helper's signature and its four call sites, so it is left out of this slice. No new provider-changing or mode-changing branch is introduced here, so the fallback-telemetry contract needs no new `record_fallback` call. ## Compatibility Purely additive. `MessageServiceStatusEvent.to_json` uses `exclude_none=True`, so a client that does not read the new fields sees exactly the payload it sees today — verified by `test_ready_stays_additive_for_clients_that_ignore_the_new_fields`. This is a WebSocket event and does not appear in the REST app-client contract (`grep -c service_status docs/api-reference/app-client-openapi.json` → `0`). The gate was run anyway: ``` $ python scripts/check_app_client_openapi_compatibility.py --base-ref upstream/main App-client OpenAPI compatibility passed against merge-base 5712bfa. ``` ## Verification ### Red before green The regression test was written first and run against unmodified code. It drives the real `run()` sequence with a real `ListenReceiver`, stubbing only the vendor connect functions, so the fallback it reports is produced by the production chain rather than asserted about. ``` $ python -m pytest tests/unit/test_listen_ready_provider.py -q # before the fix ___________ test_ready_names_the_fallback_provider_actually_serving ____________ > assert payload['provider'] == 'deepgram' E KeyError: 'provider' _____ test_ready_on_a_healthy_selected_provider_carries_no_fallback_reason _____ > assert payload['provider'] == 'modulate' E KeyError: 'provider' ______ test_ready_provider_is_resolved_at_emission_time_not_at_selection _______ > assert runtime._ready_event().to_json()['provider'] == 'parakeet' E AttributeError: 'ListenSessionRuntime' object has no attribute '_ready_event' _____ test_bootstrap_records_the_selected_provider_for_fallback_comparison _____ > assert runtime.stt_service_selected == STTService.modulate E AttributeError: 'ListenSessionRuntime' object has no attribute 'stt_service_selected' 4 failed, 2 passed, 9 warnings in 2.72s ``` `KeyError: 'provider'` is the exact pre-fix shape: the field was `None` and `exclude_none=True` stripped it from the payload entirely. The first failure only reaches its assertion after `assert runtime.stt_service == STTService.deepgram` passes, which proves the fallback chain really moved the session before `ready` was emitted. After the fix: ``` $ python -m pytest tests/unit/test_listen_ready_provider.py -q 6 passed, 9 warnings in 1.94s ``` ### Blast radius The full GitHub Actions unit contract for this diff — `scripts/select_backend_unit_tests.py` selected 148 files from the changed paths — run through `test.sh` with CI's file-isolation and timing guards: ``` $ BACKEND_UNIT_TEST_FILE_LIST=<selected> BACKEND_FAST_UNIT_FAIL_SECONDS=1.0 \ BACKEND_PYTEST_FILE_ISOLATION=1 bash test.sh 143 files, 2473 tests passed, exit 0 ``` Type check and the isolation scanners: ``` $ bash scripts/typecheck.sh 0 errors, 3660 warnings, 0 informations $ python scripts/check_module_stub_pollution.py Checked 903 backend test file(s); 0 violation(s). $ python scripts/scan_import_time_side_effects.py Checked 800 backend production file(s); 0 violation(s). $ black --line-length 120 --skip-string-normalization --check 2 files would be left unchanged. ``` ### Not verified here Four of the 148 selected files could not run on this machine, for reasons unrelated to this diff: - `test_verify_pusher_config_references.py` shells out to `helm`, which is not installed (`FileNotFoundError: [Errno 2] No such file or directory: 'helm'`). Excluded from the 2473-test run above; CI has helm. - `test-preflight.sh` reports one failure: Python 3.11.13 installed vs 3.11.15 pinned in `.python-version`. Patch-level, environmental. **This was verified hermetically at the `process_audio_*` seam. It was not exercised against live Deepgram, Modulate, or Parakeet**, and I have not run a real device session against it. The provider values asserted are the ones the production fallback chain assigns to `host.stt_service`; that those match what a live vendor socket serves is existing behavior this PR does not change. ## Gates - **Product invariants affected:** none (`scripts/pr-preflight --base upstream/main --suggest`). - **Docs:** `docs/doc/developer/backend/transcription.mdx` and `listen_pusher_pipeline.mdx` both documented the `ready` payload and are updated. Failure-Class: none Rationale: this adds reporting fields to an existing event. It introduces no new failure, recovery, or fallback branch — the fallback behavior it reports on landed in #11695/#11752/#11814. #11359, the attribution half of this same issue on the same code path, also declared `Failure-Class: none`. `scripts/failure-class prepare` inferred no class from the diff. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/BasedHardware/omi/pull/11822?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
2 parents bc7f765 + 0d76c21 commit acf361c

4 files changed

Lines changed: 327 additions & 4 deletions

File tree

backend/routers/listen/runtime.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def __init__(self, request: ListenRequest):
115115
self.is_multi_channel = request.channels >= 2
116116
self.language = request.language
117117
self.stt_service: Any = None
118+
self.stt_service_selected: Any = None
118119
self.stt_language = ''
119120
self.stt_model = ''
120121
self.vocabulary: List[str] = []
@@ -275,6 +276,10 @@ async def _bootstrap(self) -> bool:
275276
multi_lang_enabled=not single_language_mode,
276277
preferred_service=request.stt_service,
277278
)
279+
# The provider the serving policy chose, captured before `_create_stt_socket`
280+
# can walk the fallback chain. Only the *selected* value is safe to hold onto:
281+
# the serving one has to be read at use time (#11306).
282+
self.stt_service_selected = self.stt_service
278283
self.parity_capture = ListenParityCapture.from_environ(
279284
principal_id=request.uid,
280285
session_id=getattr(self, 'session_id', ''),
@@ -574,6 +579,32 @@ async def _start_pusher(self) -> None:
574579
self.task_supervisor.create_lifetime_task(session.audio_bytes_consume(), name='pusher_audio')
575580
)
576581

582+
def _ready_event(self) -> MessageServiceStatusEvent:
583+
"""Name the provider actually serving this session on the `ready` event (#11306).
584+
585+
The client clears its terminal-failure state on `ready`, so a bare event leaves a
586+
fallback socket that is about to die indistinguishable from a healthy session on
587+
the provider the user selected. `_create_stt_socket` can walk the fallback chain
588+
(#11695, #11752), so the serving provider is only knowable once the socket exists
589+
— resolving it any earlier is the attribution bug #11359 fixed on the
590+
terminal-failure path, which is why this reads `_serving_provider()` here rather
591+
than reusing a value from bootstrap.
592+
593+
Both fields are optional and dropped by `exclude_none=True`, so a client that does
594+
not read them sees exactly the payload it sees today.
595+
"""
596+
if self.use_custom_stt:
597+
# Custom-STT clients produce their own transcripts; no backend provider serves.
598+
return MessageServiceStatusEvent(status='ready')
599+
serving = self.receiver._serving_provider()
600+
selected = getattr(self.stt_service_selected, 'value', self.stt_service_selected)
601+
fell_back = bool(serving) and bool(selected) and serving != selected
602+
return MessageServiceStatusEvent(
603+
status='ready',
604+
provider=serving,
605+
reason=f'fallback_from_{selected}' if fell_back else None,
606+
)
607+
577608
async def run(self) -> None:
578609
if not await self._admit() or not await self._bootstrap():
579610
return
@@ -628,7 +659,7 @@ async def run(self) -> None:
628659
self.task_supervisor.create_finite_task(self.speakers.load_and_run(), name='speaker_id'),
629660
]
630661
)
631-
self.send_event(MessageServiceStatusEvent(status='ready'))
662+
self.send_event(self._ready_event())
632663
result = await self.task_supervisor.supervise(receive_task=receive_task)
633664
logger.info('Listen supervisor exited reason=%s', result.reason)
634665
if result.reason in {'crash', 'lifetime_done'}:
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""Regression (#11306): the live-STT `ready` event must name the provider serving.
2+
3+
The backend emitted a bare ``MessageServiceStatusEvent(status='ready')`` as soon as
4+
the provider socket opened. ``_create_stt_socket`` can walk the fallback chain
5+
(#11695, #11752), so "Listening" could equally mean the Parakeet session the user
6+
selected or a Modulate/Deepgram fallback socket that is about to die — the client
7+
clears its terminal-failure state on ``ready`` and cannot tell the two apart.
8+
9+
These tests drive the real ``run()`` sequence with the vendor connect functions
10+
stubbed, so the fallback the event has to report is produced by the production
11+
chain rather than asserted about.
12+
"""
13+
14+
import asyncio
15+
import os
16+
from contextlib import contextmanager
17+
from types import SimpleNamespace
18+
from unittest.mock import AsyncMock, patch
19+
20+
import pytest
21+
22+
from routers.listen import receiver as receiver_mod
23+
from routers.listen.contracts import ListenRequest, ListenSessionState
24+
from routers.listen.receiver import ListenReceiver
25+
from routers.listen.runtime import ListenSessionRuntime
26+
from utils.stt import provider_resilience, streaming
27+
from utils.stt.streaming import STTService
28+
29+
30+
@pytest.fixture
31+
def anyio_backend():
32+
return 'asyncio'
33+
34+
35+
@contextmanager
36+
def _serving_policy(models=('modulate-velma-2', 'dg-nova-3', 'parakeet')):
37+
"""Drive the real provider gates off ``STT_SERVICE_MODELS``, not a stubbed answer."""
38+
with (
39+
patch.object(streaming, 'stt_service_models', list(models)),
40+
patch.object(streaming, '_deepgram_is_available', return_value=True),
41+
patch.dict(os.environ, {'HOSTED_PARAKEET_API_URL': 'ws://parakeet.omi.me/v3/stream'}),
42+
):
43+
yield
44+
45+
46+
class _RejectedSocket:
47+
"""Velma's over-quota shape: the upgrade succeeds, then the stream is refused."""
48+
49+
def __init__(self) -> None:
50+
self.death_reason = 'modulate error: Monthly usage limit reached.'
51+
self.finished = False
52+
53+
@property
54+
def is_connection_dead(self) -> bool:
55+
return True
56+
57+
def finish(self) -> None:
58+
self.finished = True
59+
60+
61+
def _live_socket():
62+
return SimpleNamespace(is_connection_dead=False, death_reason=None, finish=lambda: None)
63+
64+
65+
async def _idle():
66+
return None
67+
68+
69+
def _runtime_for_ready(*, selected=STTService.modulate, custom_stt=False):
70+
"""A runtime wired with the real receiver, stubbed only outside the STT path."""
71+
request = ListenRequest(
72+
websocket=SimpleNamespace(),
73+
uid='ready-user',
74+
codec='pcm8',
75+
sample_rate=16000,
76+
)
77+
runtime = object.__new__(ListenSessionRuntime)
78+
runtime.request = request
79+
runtime.state = ListenSessionState()
80+
runtime.session_id = 'ready-session'
81+
runtime.use_custom_stt = custom_stt
82+
runtime.is_multi_channel = False
83+
runtime.language = 'en'
84+
runtime.stt_service = selected
85+
runtime.stt_service_selected = selected
86+
runtime.stt_language = 'en'
87+
runtime.stt_model = 'velma-2'
88+
runtime.vocabulary = []
89+
runtime.client_device_context = SimpleNamespace(platform='ios')
90+
runtime.limits = SimpleNamespace(bg_drain_timeout=5.0)
91+
runtime.pusher_tasks = []
92+
93+
events = []
94+
runtime.send_event = events.append
95+
96+
async def asend_event(event):
97+
events.append(event)
98+
return True
99+
100+
runtime.asend_event = asend_event
101+
runtime.emitted_events = events
102+
103+
runtime._admit = AsyncMock(return_value=True)
104+
runtime._bootstrap = AsyncMock(return_value=True)
105+
runtime._start_pusher = AsyncMock()
106+
runtime._teardown = AsyncMock()
107+
runtime._heartbeat = _idle
108+
runtime._record_usage_periodically = _idle
109+
110+
runtime.conversations = SimpleNamespace(
111+
send_last_conversation=AsyncMock(),
112+
prepare=AsyncMock(return_value=False),
113+
lifecycle_loop=_idle,
114+
process_pending=lambda *_args: _idle(),
115+
)
116+
runtime.transcripts = SimpleNamespace(process_loop=_idle)
117+
runtime.speakers = SimpleNamespace(load_and_run=_idle)
118+
119+
def create_task(coro, *, name, **_kwargs):
120+
return asyncio.ensure_future(coro)
121+
122+
async def supervise(*, receive_task):
123+
await receive_task
124+
return SimpleNamespace(reason='client_disconnect')
125+
126+
runtime.task_supervisor = SimpleNamespace(
127+
start_session=lambda: None,
128+
create_task=create_task,
129+
create_lifetime_task=create_task,
130+
create_finite_task=create_task,
131+
supervise=supervise,
132+
drain_monitored=AsyncMock(return_value=0),
133+
)
134+
runtime.spawn = lambda coro, *, name: asyncio.ensure_future(coro)
135+
136+
runtime.receiver = ListenReceiver(runtime, [], {})
137+
runtime.receiver.receive_data = _idle
138+
runtime.receiver._monitor_stt_death = _idle
139+
return runtime
140+
141+
142+
def _ready_payload(runtime):
143+
ready = [event for event in runtime.emitted_events if getattr(event, 'status', None) == 'ready']
144+
assert len(ready) == 1, f'expected exactly one ready event, got {len(ready)}'
145+
return ready[0].to_json()
146+
147+
148+
async def _run_session(runtime, *, modulate_socket, dg_socket=None):
149+
with (
150+
_serving_policy(),
151+
patch.object(provider_resilience, 'STT_FALLBACK_LIVENESS_GRACE_SECONDS', 0.05),
152+
patch.object(receiver_mod, 'is_gate_enabled', return_value=False),
153+
patch.object(receiver_mod, 'process_audio_modulate', new=AsyncMock(return_value=modulate_socket)),
154+
patch.object(receiver_mod, 'process_audio_dg', new=AsyncMock(return_value=dg_socket)),
155+
patch.object(receiver_mod, 'process_audio_parakeet', new=AsyncMock(return_value=None)),
156+
patch.object(streaming, 'record_fallback'),
157+
):
158+
await runtime.run()
159+
160+
161+
@pytest.mark.anyio
162+
async def test_ready_names_the_fallback_provider_actually_serving():
163+
"""The #11306 shape: Modulate takes the session, refuses it, Deepgram serves.
164+
165+
``ready`` must say ``deepgram`` — the provider on the other end of the socket
166+
the client is about to stream into — not the ``modulate`` the policy selected.
167+
"""
168+
runtime = _runtime_for_ready(selected=STTService.modulate)
169+
170+
await _run_session(runtime, modulate_socket=_RejectedSocket(), dg_socket=_live_socket())
171+
172+
assert runtime.stt_service == STTService.deepgram, 'the fallback chain did not move the session'
173+
payload = _ready_payload(runtime)
174+
assert payload['provider'] == 'deepgram'
175+
assert payload['reason'] == 'fallback_from_modulate'
176+
177+
178+
@pytest.mark.anyio
179+
async def test_ready_on_a_healthy_selected_provider_carries_no_fallback_reason():
180+
"""A session served by the selected provider must not look like a fallback."""
181+
runtime = _runtime_for_ready(selected=STTService.modulate)
182+
183+
await _run_session(runtime, modulate_socket=_live_socket())
184+
185+
payload = _ready_payload(runtime)
186+
assert payload['provider'] == 'modulate'
187+
assert 'reason' not in payload, 'a healthy session must not carry a fallback reason'
188+
189+
190+
@pytest.mark.anyio
191+
async def test_ready_claims_no_backend_provider_for_custom_stt_sessions():
192+
"""Custom-STT clients own transcript production; no backend provider serves them."""
193+
runtime = _runtime_for_ready(selected=STTService.modulate, custom_stt=True)
194+
195+
await _run_session(runtime, modulate_socket=_live_socket())
196+
197+
payload = _ready_payload(runtime)
198+
assert 'provider' not in payload
199+
assert 'reason' not in payload
200+
201+
202+
@pytest.mark.anyio
203+
async def test_ready_stays_additive_for_clients_that_ignore_the_new_fields():
204+
"""``exclude_none=True`` keeps the legacy payload shape intact."""
205+
runtime = _runtime_for_ready(selected=STTService.modulate)
206+
207+
await _run_session(runtime, modulate_socket=_live_socket())
208+
209+
payload = _ready_payload(runtime)
210+
assert payload['type'] == 'service_status'
211+
assert payload['status'] == 'ready'
212+
assert 'status_text' not in payload
213+
assert 'outcome' not in payload
214+
assert 'retryable' not in payload
215+
216+
217+
def test_ready_provider_is_resolved_at_emission_time_not_at_selection():
218+
"""Pin the accessor contract #11359 established for the terminal-failure path.
219+
220+
A value read before ``_create_stt_socket`` returns attributes a fallback
221+
session to the provider that never served it.
222+
"""
223+
runtime = _runtime_for_ready(selected=STTService.parakeet)
224+
225+
assert runtime._ready_event().to_json()['provider'] == 'parakeet'
226+
227+
runtime.stt_service = STTService.modulate # what _create_stt_socket does on fallback
228+
229+
payload = runtime._ready_event().to_json()
230+
assert payload['provider'] == 'modulate'
231+
assert payload['reason'] == 'fallback_from_parakeet'
232+
233+
234+
@pytest.mark.anyio
235+
async def test_bootstrap_records_the_selected_provider_for_fallback_comparison(monkeypatch):
236+
"""``_bootstrap`` must keep the selected provider so ``ready`` can spot a fallback."""
237+
import routers.listen.runtime as runtime_module
238+
from utils.listen_session_bootstrap import ListenConnectBase
239+
240+
runtime = object.__new__(ListenSessionRuntime)
241+
runtime.request = ListenRequest(websocket=SimpleNamespace(), uid='select-user', stt_service='parakeet')
242+
runtime.use_custom_stt = False
243+
runtime.session_id = 'select-session'
244+
runtime.language = 'en'
245+
246+
base = ListenConnectBase(
247+
user_exists=True,
248+
user_has_credits=True,
249+
transcription_prefs={'single_language_mode': False, 'uses_custom_stt': False},
250+
fair_use_init_stage=None,
251+
fair_use_track_dg_usage=False,
252+
fair_use_dg_budget_exhausted=False,
253+
)
254+
255+
async def close(**_kwargs):
256+
raise AssertionError('bootstrap must not close the socket in this test')
257+
258+
monkeypatch.setattr(runtime_module, 'load_listen_connect_base', lambda *_a, **_k: _resolved(base))
259+
monkeypatch.setattr(
260+
runtime_module,
261+
'get_stt_service_for_language',
262+
lambda *_a, **_k: (STTService.modulate, 'en', 'velma-2'),
263+
)
264+
monkeypatch.setattr(runtime_module, 'finalize_listen_connect_context', _stop_after_selection)
265+
266+
with pytest.raises(_SelectionRecorded):
267+
await runtime._bootstrap()
268+
269+
assert runtime.stt_service_selected == STTService.modulate
270+
271+
272+
class _SelectionRecorded(Exception):
273+
"""Stop `_bootstrap` once provider selection is done; the rest needs live IO."""
274+
275+
276+
def _stop_after_selection(*_args, **_kwargs):
277+
raise _SelectionRecorded
278+
279+
280+
def _resolved(value):
281+
async def _coro():
282+
return value
283+
284+
return _coro()

docs/doc/developer/backend/listen_pusher_pipeline.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ sequenceDiagram
139139
Backend-->>Client: {type: "conversation_session", conversation_id, recording_session_id, lifecycle_version, lifecycle_phase, lifecycle_sequence}
140140
Backend->>STT: Open STT WebSocket
141141
Backend->>Pusher: Open Pusher WebSocket
142-
Backend-->>Client: {type: "service_status", status: "ready"}
142+
Backend-->>Client: {type: "service_status", status: "ready", provider, reason?}
143143
144144
Note over Backend: Start background tasks:<br/>conversation_lifecycle_manager (5s poll)<br/>speaker_identification_task<br/>stream_transcript_process
145145
@@ -478,7 +478,7 @@ Staleness is fingerprint-driven: when late chunks change `audio_files` (pusher b
478478

479479
| Event | Fields | When |
480480
|-------|--------|------|
481-
| `service_status` | `{type, status: "ready"}` | After WS connect, services initialized |
481+
| `service_status` | `{type, status: "ready", provider?, reason?}` | After WS connect, services initialized. `provider` names the STT provider actually serving (absent for custom-STT sessions); `reason` is `fallback_from_<selected>` only when the fallback chain moved the session off the selected provider |
482482
| `conversation_session` | `{type, conversation_id, recording_session_id?, status}` | Exact recording-to-conversation binding; identified clients require IDs to match |
483483
| `memory_processing_started` | `{type, recording_session_id?, memory: {id, ...}}` | Conversation sent to pusher for LLM; only emitted for the listen session's current conversation |
484484
| `memory_created` | `{type, recording_session_id?, memory: {id, structured: {title, overview, ...}}}` | LLM processing complete; only emitted for the listen session's current conversation |

docs/doc/developer/backend/transcription.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,9 +563,17 @@ sequenceDiagram
563563
{
564564
"type": "service_status",
565565
"status": "ready",
566-
"status_text": "Service Ready"
566+
"provider": "deepgram",
567+
"reason": "fallback_from_modulate"
567568
}
568569
```
570+
571+
`provider` names the STT provider actually serving the session, resolved after the
572+
fallback chain has settled — a session may be served by a provider other than the one
573+
the serving policy selected. `reason` appears only in that case, as
574+
`fallback_from_<selected>`, so a client can tell a fallback session from a healthy one.
575+
Both fields are omitted when absent, and `provider` is omitted entirely for custom-STT
576+
sessions, where the client produces its own transcripts.
569577
</Tab>
570578

571579
<Tab title="Speaker Suggestion" icon="user-plus">

0 commit comments

Comments
 (0)