Skip to content

DX-818: Add realtime transcription support with failover handling#450

Open
yadavsahil197 wants to merge 10 commits into
mainfrom
syadav/realtime-transcription
Open

DX-818: Add realtime transcription support with failover handling#450
yadavsahil197 wants to merge 10 commits into
mainfrom
syadav/realtime-transcription

Conversation

@yadavsahil197

Copy link
Copy Markdown

No description provided.

@broly-code-security-scanner

broly-code-security-scanner Bot commented Jul 16, 2026

Copy link
Copy Markdown

Broly Security Scan

Note

Summary

6 actionable finding(s) in this PR

  • 🟡 6 medium

5 highest-priority actionable rows in the table below (critical/high first, then top medium).

Severity Scanner Issue Location Dismiss Verdict
🟡 MEDIUM SCA aiohttp@3.13.3 — 42 vulnerabilities (worst:
GHSA-2fqr-mr3j-6wp8)
→ >= 3.14.1
uv.lock:1 d1 🔺 TRUE_POSITIVE · Confidence: HIGH
🟡 MEDIUM SCA pillow@12.1.1 — 31 vulnerabilities (worst:
GHSA-45hq-cxwh-f6vc)
→ >= 12.3.0
uv.lock:1 d7 🔺 TRUE_POSITIVE · Confidence: HIGH
🟡 MEDIUM SCA pygments@2.19.2 — 2 vulnerabilities (worst:
GHSA-5239-wwwm-4pmq)
→ >= 2.20.0
uv.lock:1 d4 🔺 TRUE_POSITIVE · Confidence: HIGH
🟡 MEDIUM SCA idna@3.11 — 2 vulnerabilities (worst:
GHSA-65pc-fj4g-8rjx)
→ >= 3.15
uv.lock:1 d2 🔺 TRUE_POSITIVE · Confidence: HIGH
🟡 MEDIUM SCA pytest@9.0.2 — 2 vulnerabilities (worst:
GHSA-6w46-j5rx-g56g)
→ >= 9.0.3
uv.lock:1 d5 🔺 TRUE_POSITIVE · Confidence: HIGH

Dismiss false positives

Each dismissable row has a Dismiss key (d1, d2, …) in the table above. Reply to this comment (or post on the PR) with /broly dismiss d2: your reason to mark a false positive, or /broly undismiss d2 to reverse it. Broly records every dismissal in the section below this comment (updated in place) and suppresses the finding on the next scan.

Key Finding
d1 🟡 MEDIUM · aiohttp@3.13.3 — 42 vulnerabilities (worst: GHSA-2fqr-mr3j-6wp8) · uv.lock:1
d2 🟡 MEDIUM · idna@3.11 — 2 vulnerabilities (worst: GHSA-65pc-fj4g-8rjx) · uv.lock:1
d7 🟡 MEDIUM · pillow@12.1.1 — 31 vulnerabilities (worst: GHSA-45hq-cxwh-f6vc) · uv.lock:1
d4 🟡 MEDIUM · pygments@2.19.2 — 2 vulnerabilities (worst: GHSA-5239-wwwm-4pmq) · uv.lock:1
d5 🟡 MEDIUM · pytest@9.0.2 — 2 vulnerabilities (worst: GHSA-6w46-j5rx-g56g) · uv.lock:1
d6 🟡 MEDIUM · urllib3@2.6.3 — 4 vulnerabilities (worst: GHSA-mf9v-mfxr-j63j) · uv.lock:1

Note

Re-scan this PR anytime with /broly scan — useful after /broly undismiss, or to refresh findings without a new push.

Broly — SAST (zai-org/GLM-5.2) · Secrets · SCA · GH Actions (zizmor) · Containers · SBOM · Powered by Together AI

Comment thread examples/realtime_transcription.py Outdated
Comment thread examples/realtime_failover.py Outdated

@blainekasten blainekasten left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks great!

As we discussed in slack, let's move this functionality under the beta namespace for now to give us some wiggle room if we decide to change the api interface in the near term.

@yadavsahil197
yadavsahil197 force-pushed the syadav/realtime-transcription branch from 85d5879 to 6d913ab Compare July 16, 2026 21:20
@yadavsahil197

Copy link
Copy Markdown
Author

Moved to the beta namespace as discussed!

@yadavsahil197 yadavsahil197 changed the title Add realtime transcription support with failover handling DX-818: Add realtime transcription support with failover handling Jul 16, 2026
Comment thread examples/realtime_failover.py Outdated
@zainhas
zainhas self-requested a review July 18, 2026 01:15
for _held, reclaim in holders:
if deficit <= 0:
return
deficit -= reclaim(deficit)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think there's a cross-thread mutation hazard here. charge() runs on whatever thread is appending, and when the pool is under pressure this ends up calling reclaimAudioBuffer.trim_to on other sessions buffers. But those sessions are running on their own event loops so we can popleft from a deque while its owner's writer loop is mid-iteration in read_from which will be a error.

It would only fire under pool pressure with multiple sessions - could we marshal the reclaim onto the holder's loop via call_soon_threadsafe or put a lock around AudioBuffer?

Comment thread src/together/lib/realtime/_session.py Outdated
pool=pool,
)
self._pool = pool
pool.register(self, lambda: self.state.buffer.size, self.state.reclaim)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Registering in init but only unregistering in close()/_fail() means a session that's constructed but never started (or just abandoned) stays strongly referenced by the client-scoped pool forever. Could we move registration into start(), or make _holders a WeakKeyDictionary so the pool never keeps a session alive on its own?

# 300s no-append idle timeout
if self._keepalive_silence and self._last_append_at:
if now - self._last_append_at > 200.0:
await self.append(b"\x00" * int(self.state.bps * 0.1)) # 100ms of silence

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This append() can raise a RealtimeBufferOverflowError when overflow="error" or a stored terminal failure racing in via _raise_if_failed() and _watchdog_loop only handles CancelledError. So the watchdog task dies with an unobserved exception and we lose echo probing + keepalive while the session looks healthy. Worth wrapping the loop body in an except Exception that at least logs.

Comment thread src/together/lib/realtime/_session.py Outdated
self._writer_wakeup.set()
for task in (self._watchdog_task, self._writer_task, self._reconnect_task, self._reader_task):
if task is not None:
task.cancel()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We cancel these but never await them, and in the sync the loop gets stopped right after so tasks can be destroyed while still pending (asyncio warnings, and the reader may not finish tearing down the socket). A gather(*tasks, return_exceptions=True) after the cancels would make shutdown deterministic.

self._session = self._call(_create())

def __enter__(self) -> "RealtimeTranscriptionSession":
self.start()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Probably want a try/except around this that calls close() before re-raising. If start() raises here (e.g. bad base_url → RealtimeConnectionError), exit never runs so the background loop thread we spawned in init lives until atexit.

@yadavsahil197
yadavsahil197 force-pushed the syadav/realtime-transcription branch from 6e2c0c7 to 7aa3dfc Compare July 21, 2026 19:48
@yadavsahil197
yadavsahil197 requested a review from zainhas July 21, 2026 19:48
Comment thread src/together/lib/realtime/_state.py Outdated

SAMPLE_RATE = 16_000
CHUNK_BYTES = SAMPLE_RATE * 2 // 10 # 100 ms per append, like a live source
MAX_ATTEMPTS = 3 * len(ENDPOINTS) # give up after several full ring cycles

@sbeurnier sbeurnier Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

too high, should be max(2, len(endpoints)) imo -- retry once if one endpoint otherwise try each once

@sbeurnier

Copy link
Copy Markdown

Design question: warm/standby WS for cross-endpoint failover

Right now there's no pre-established connection anywhere — every WS is opened lazily when it's needed. The BoundedSemaphore(32) reconnect throttle is a rate limiter, and BufferPool is a memory budget; neither holds sockets.

The consequence is on the failover path: the backup endpoint's client is just config ("nothing connects until a session starts"), so when the primary dies we can only start dialing the destination after we already know the source is dead. That failover pays a cold open — ws_connect (open_timeout=10s) plus the session.created round-trip — before the first carried-over byte can flow. For a live conversation that's latency injected at the worst possible moment, and pending_audio() gives us the audio to carry but nothing to carry it into yet.

Worth considering a warm-standby option: pre-dial the next endpoint in the ring and hold it through session.created, then swap on failure so failover is a buffer replay rather than a full handshake. Not free — idle sockets, server-side session/idle-timeout accounting on the standby, auth-token expiry, ~2× connections per stream — so probably a future opt-in rather than default. Mostly flagging so the "failover is app-side" boundary is an explicit scope decision rather than silent: today the ceiling on failover speed is a cold cross-cluster handshake.

Not blocking — just want it captured.

@sbeurnier

Copy link
Copy Markdown

Suggestion: expose per-token logprobs / confidence on transcription events

Noticed while reading the event types: our realtime backend (ipop, realtime-base.ts sendDelta/sendComplete) attaches quality signals to ...transcription.delta and ...transcription.completed frames — off the OpenAI spec, populated only when the engine decodes with beam>=2 (greedy omits them):

{
  "type": "...completed", "item_id": "msg_7", "transcript": "...",
  "logprobs": { "avg_logprob": -0.21, "token_logprobs": [...], "token_texts": [...] },
  "tokens":   [ { "token_id": 123, "text": " hi", "confidence": 0.94 }, ... ]
}

Right now the SDK's wire models don't declare these, so a consumer can only reach them accidentally — they survive as untyped extras because BaseModel is extra='allow' (completed.raw.logprobs returns a plain dict, no types, no autocomplete, nothing telling a future maintainer they're load-bearing). Reachable, not supported.

I put together a small additive change to make them first-class (happy to fold in or you can take it):

  • New models RealtimeLogprobs (avg_logprob, token_logprobs, token_texts) and TranscriptionToken (token_id, text, confidence), following the existing nested-model precedent (RealtimeErrorInfo).
  • logprobs / tokens added to TranscriptionDeltaEvent + TranscriptionCompletedEvent (so .raw.logprobs is typed on the raw connect() path) and to the normalized TranscriptDelta / TranscriptCompleted (so event_callback users get event.logprobs.avg_logprob without reaching into .raw), plumbed through in _session.py.
  • Exported from together.lib.realtime and the together.realtime shim.

Non-breaking, verified:

  • every field Optional[...] = None → frames without them still parse (bare frame → None);
  • construct_type_unchecked builds the nested models recursively (Optional[List[Model]] is the standard Stainless pattern, so pydantic v1 + v2 both fine);
  • 86/86 realtime unit tests pass; ruff check/format clean; mypy clean on the changed files.
diff
diff --git a/src/together/lib/realtime/__init__.py b/src/together/lib/realtime/__init__.py
index f7911422..36eb1b9e 100644
--- a/src/together/lib/realtime/__init__.py
+++ b/src/together/lib/realtime/__init__.py
@@ -13,10 +13,12 @@ from ._types import (
     BufferOptions,
     SessionStarted,
     TranscriptDelta,
+    RealtimeLogprobs,
     ReconnectOptions,
     TranscriptFailed,
     EchoResponseEvent,
     RealtimeErrorInfo,
+    TranscriptionToken,
     TurnDetectionParam,
     RealtimeServerEvent,
     SessionCreatedEvent,
@@ -48,6 +50,8 @@ __all__ = [
     "EchoResponseEvent",
     "UnknownEvent",
     "RealtimeErrorInfo",
+    "RealtimeLogprobs",
+    "TranscriptionToken",
     # events (normalized session)
     "RealtimeSessionEvent",
     "SessionStarted",
diff --git a/src/together/lib/realtime/_session.py b/src/together/lib/realtime/_session.py
index 6394a110..6f8a3528 100644
--- a/src/together/lib/realtime/_session.py
+++ b/src/together/lib/realtime/_session.py
@@ -489,6 +489,8 @@ class AsyncRealtimeTranscriptionSession:
                     audio_start=seg.audio_start,
                     audio_end=self.state.to_seconds(self.state.write_head),
                     replayed=seg.replayed,
+                    logprobs=event.logprobs,
+                    tokens=event.tokens,
                     raw=event,
                 )
             )
@@ -509,6 +511,8 @@ class AsyncRealtimeTranscriptionSession:
                     audio_start=seg.audio_start,
                     audio_end=self.state.to_seconds(self.state.anchor or self.state.write_head),
                     replayed=seg.replayed,
+                    logprobs=event.logprobs,
+                    tokens=event.tokens,
                     raw=event,
                 )
             )
diff --git a/src/together/lib/realtime/_types.py b/src/together/lib/realtime/_types.py
index ff4d86a5..39120842 100644
--- a/src/together/lib/realtime/_types.py
+++ b/src/together/lib/realtime/_types.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import json
 import base64
-from typing import Any, Dict, Union, Mapping, Optional, cast
+from typing import Any, Dict, List, Union, Mapping, Optional, cast
 from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict
 
 from ..._utils import PropertyInfo
@@ -15,6 +15,8 @@ __all__ = [
     # wire (server -> client) events
     "RealtimeErrorInfo",
     "RealtimeSessionInfo",
+    "RealtimeLogprobs",
+    "TranscriptionToken",
     "SessionCreatedEvent",
     "TranscriptionDeltaEvent",
     "TranscriptionCompletedEvent",
@@ -109,6 +111,24 @@ class SessionCreatedEvent(BaseModel):
     session: Optional[RealtimeSessionInfo] = None
 
 
+# Per-segment/token quality signals some backends attach to delta/completed
+# frames (off the OpenAI spec). Populated only when the engine decodes with
+# beam>=2; greedy decode omits them. All fields optional so frames that don't
+# carry them still parse.
+class RealtimeLogprobs(BaseModel):
+    avg_logprob: Optional[float] = None
+    token_logprobs: Optional[List[float]] = None
+    """Parallel to token_texts: token_logprobs[i] is the logprob of token_texts[i]."""
+    token_texts: Optional[List[str]] = None
+
+
+class TranscriptionToken(BaseModel):
+    token_id: Optional[int] = None
+    text: Optional[str] = None
+    confidence: Optional[float] = None
+    """Softmax probability of the emitted token (exp of its logprob)."""
+
+
 class TranscriptionDeltaEvent(BaseModel):
     type: Literal["conversation.item.input_audio_transcription.delta"]
     item_id: Optional[str] = None
@@ -117,6 +137,8 @@ class TranscriptionDeltaEvent(BaseModel):
     """Server-side consumed-speech clock — NOT a position on the appended-audio
     timeline (silence is skipped); do not use for buffer accounting."""
     duration: Optional[float] = None
+    logprobs: Optional[RealtimeLogprobs] = None
+    tokens: Optional[List[TranscriptionToken]] = None
 
 
 class TranscriptionCompletedEvent(BaseModel):
@@ -126,6 +148,8 @@ class TranscriptionCompletedEvent(BaseModel):
     start: Optional[float] = None
     """See TranscriptionDeltaEvent.start — informational only."""
     duration: Optional[float] = None
+    logprobs: Optional[RealtimeLogprobs] = None
+    tokens: Optional[List[TranscriptionToken]] = None
 
 
 class TranscriptionFailedEvent(BaseModel):
@@ -328,6 +352,8 @@ class TranscriptDelta(BaseModel):
     audio_start: Optional[float] = None
     audio_end: Optional[float] = None
     replayed: bool = False
+    logprobs: Optional[RealtimeLogprobs] = None
+    tokens: Optional[List[TranscriptionToken]] = None
     raw: Optional[TranscriptionDeltaEvent] = None
 
 
@@ -338,6 +364,8 @@ class TranscriptCompleted(BaseModel):
     audio_start: Optional[float] = None
     audio_end: Optional[float] = None
     replayed: bool = False
+    logprobs: Optional[RealtimeLogprobs] = None
+    tokens: Optional[List[TranscriptionToken]] = None
     raw: Optional[TranscriptionCompletedEvent] = None
 
 
diff --git a/src/together/realtime.py b/src/together/realtime.py
index e16a8917..fbd49b0c 100644
--- a/src/together/realtime.py
+++ b/src/together/realtime.py
@@ -18,10 +18,12 @@ from .lib.realtime import (
     RealtimeError as RealtimeError,
     SessionStarted as SessionStarted,
     TranscriptDelta as TranscriptDelta,
+    RealtimeLogprobs as RealtimeLogprobs,
     ReconnectOptions as ReconnectOptions,
     TranscriptFailed as TranscriptFailed,
     EchoResponseEvent as EchoResponseEvent,
     RealtimeErrorInfo as RealtimeErrorInfo,
+    TranscriptionToken as TranscriptionToken,
     TurnDetectionParam as TurnDetectionParam,
     RealtimeServerEvent as RealtimeServerEvent,
     SessionCreatedEvent as SessionCreatedEvent,
@@ -50,6 +52,8 @@ __all__ = [
     "EchoResponseEvent",
     "UnknownEvent",
     "RealtimeErrorInfo",
+    "RealtimeLogprobs",
+    "TranscriptionToken",
     # events (normalized session)
     "RealtimeSessionEvent",
     "SessionStarted",

…ription events

- Classify server `no_healthy_upstream` as a terminal RealtimeConnectionError
  (code="no_healthy_upstream"), raised immediately — bypasses same-endpoint
  reconnect — so an application failover ring rotates to another endpoint.
- Lower default reconnect max_attempts 10 -> 2: ride out a transient websocket
  blip, then fail over fast instead of grinding a dead endpoint for ~a minute.
- Expose logprobs (avg_logprob, token_logprobs/token_texts) and per-token
  confidence on both wire and normalized delta/completed events
  (RealtimeLogprobs, TranscriptionToken); add `start` to TranscriptionFailedEvent.
- Document the failover contract (catch RealtimeConnectionError; code marks
  no_healthy_upstream) in examples/realtime_failover.py.

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

Copy link
Copy Markdown

Pushed a commit (514f26c1) adding the SDK side of realtime endpoint failover, to pair with ipop #2082 (which emits a no_healthy_upstream signal).

What it adds (9 files, +113/−12, realtime pkg only):

  • no_healthy_upstream → failover: classified as a terminal RealtimeConnectionError(code="no_healthy_upstream"), raised immediately (bypasses same-endpoint reconnect, since retrying a hollow endpoint is futile) — so an app's failover ring rotates on it. Chose a .code over a subclass so existing except RealtimeConnectionError rings rotate unchanged, with no speculative public class.
  • Reconnect budget default 10 → 2 (_state.py, _session.py): ride out a transient WS blip, then fail over fast instead of grinding a dead endpoint for ~a minute.
  • Logprobs / tokens exposed: RealtimeLogprobs + TranscriptionToken on wire + normalized delta/completed events (the engine emits them under beam≥2; were being silently dropped); plus start on TranscriptionFailedEvent.
  • Docs: examples/realtime_failover.py now documents the contract (catch RealtimeConnectionError; exc.code == "no_healthy_upstream" marks the upstream case).

Failover orchestration stays app-side (the ring example) — this is just the SDK support that makes it trigger on the new signal.

Verified: 88/88 realtime unit tests, ruff/format/mypy clean.

🤖 Generated with Claude Code

sbeurnier and others added 2 commits July 21, 2026 22:51
Describe endpoint failures in terms of the API contract (the server reported it
cannot serve; code='no_healthy_upstream') rather than internal infrastructure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

4 participants