Realtime Voice¶
doc_processing_toolkit.realtime holds a speech-to-speech session open across turns: you stream
microphone PCM in, and audio, transcripts, and usage stream back as they are produced. It is the
session-oriented counterpart to Multimedia, which does one-shot TTS, STT, and image
work over ordinary request/response calls.
The public surface is provider-neutral. It is not a re-export of OpenAI's wire protocol: a
session is connect → configure → send → events → close, and each adapter maps its provider down to
that shape. Neither provider's event names, message shapes, nor reconnect semantics leak out. Two
sibling modules add a session family each behind that same shape —
doc_processing_toolkit.realtime_translation (interpreter sockets on both providers) and
doc_processing_toolkit.realtime_gpt_live (OpenAI's GPT-Live family) — and both register themselves
into the same routing table, so open_realtime_session(model, session_kind=...) reaches them without
your importing either.
from doc_processing_toolkit.realtime import open_realtime_session
async with await open_realtime_session(
"gpt-realtime",
voice="marin",
instructions="You are terse. Answer in at most three words.",
output_format="audio/pcm",
) as session:
await session.send_text("Say the word hello, and nothing else.")
async for event in session.events():
if event.type == "audio_delta":
speaker.write(event.audio) # raw PCM16, already base64-decoded
elif event.type == "transcript_delta":
print(event.text, end="")
elif event.type == "response_complete":
break
print(session.cost, session.usage.as_dict()) # final after close
open_realtime_session picks the adapter from the model's provider in model_information.json, so
callers never name one. Its keyword arguments beyond model are optional, and any
RealtimeSession.configure option (output_format, turn_detection, modalities,
input_transcription, thinking_level, context_compression, max_video_fps, …) can be passed
through as well. It also takes tools=, hooks=, max_tool_rounds= and tool_timeout_seconds= —
see Tools in a realtime session.
Which kind of session¶
A socket is not always a conversation. open_realtime_session(model) opens a voice conversation; the
same function opens each of the three dedicated families with session_kind=, and each of those has
a call of its own under a clearer name.
session_kind |
What the socket does | Own name | Detail |
|---|---|---|---|
"realtime" |
A voice conversation — audio and text both ways, tools, interruption | open_realtime_session(model) |
this page |
"transcription" |
Captions only: audio in, transcripts out, no model turn | open_transcription_session(model) |
Live transcription |
"translation" |
An interpreter: speech in, translated speech and both transcripts out | open_translation_session(model, target_language=…) |
Live translation |
"live" |
OpenAI's GPT-Live family on its own /v1/live/* endpoints, billed by the second of active session time as the provider reports it |
open_live_session(model) |
GPT-Live |
from doc_processing_toolkit.realtime import (
open_realtime_session,
open_transcription_session,
open_translation_session,
)
from doc_processing_toolkit.realtime_gpt_live import open_live_session
voice = await open_realtime_session("gpt-realtime-2.1-mini") # unchanged
captions = await open_transcription_session("gpt-transcribe") # same call, own name
same = await open_realtime_session("gpt-transcribe", session_kind="transcription")
interpreter = await open_translation_session("gpt-realtime-translate", target_language="es")
duplex = await open_live_session("gpt-live-1")
Which kinds a model serves is registry data: get_model_info(model)["session_kinds"] reports it
and list_available_models(session_kind=...) filters on it, so asking a captions model to hold a
conversation fails immediately — naming what it does serve — rather than opening a socket that will
never speak. resolve_realtime_session_kind(model, kind) answers the same question on its own, and
every rejection (an unknown kind, a kind the model does not serve, a kind with no adapter for that
provider) happens before the socket opens. session_kind=None resolves to the conversational
kind wherever the model serves one, so every existing call site is unchanged.
A conversational row is not a dedicated session of another kind
session_kinds reports transcription on every conversational realtime row, because that
row's own session transcribes the audio it hears. That is a derived statement about the
conversational socket, not a second session family — and opening it as one is refused:
open_transcription_session("gemini-3.8-live") raises, naming
open_realtime_session(model, input_transcription=True) as the way to get transcripts out of a
conversation. A dedicated kind is accepted on a row that also serves the conversational kind
only when the row declares that kind in session_kinds.
The refusal is worth its own rule rather than a note. The dedicated adapters configure a session
a conversational model rejects — a live run had gemini-3.8-live closing with a 1007 on the
text-only response a captions session asks for — or price it from the wrong row, as the same
call did on OpenAI. Rows that declare a dedicated kind (gpt-transcribe, gpt-live-transcribe,
gpt-realtime-whisper, gemini-3.5-transcribe-live, both translation rows, gpt-live-1) are
unaffected, and the derived discovery statement is unchanged.
SESSION_KIND_REALTIME, SESSION_KIND_TRANSCRIPTION, SESSION_KIND_TRANSLATION and
SESSION_KIND_LIVE are exported as constants. open_live_session lives in
doc_processing_toolkit.realtime_gpt_live with the rest of that family's surface; everything else
here is importable from doc_processing_toolkit.realtime.
Models¶
| Model | Provider | Audio in | Audio out | Voices |
|---|---|---|---|---|
gpt-realtime |
OpenAI | 24 kHz PCM16 | 24 kHz | 10 (alloy, marin, cedar, …) |
gpt-realtime-2, gpt-realtime-2.1, gpt-realtime-2.1-mini, gpt-realtime-1.5 |
OpenAI | 24 kHz PCM16 | 24 kHz | 10 |
gemini-3.1-flash-live-preview |
16 kHz PCM16 | 24 kHz | 30 (Kore, Puck, Zephyr, …) |
|
gemini-3.8-live, gemini-3.8-live-extended-thinking |
16 kHz PCM16 | 24 kHz | no published list — see below |
Four further rows open a transcription socket only — no voice, no tools, no model turn. They are
listed under Live transcription. Two more open a
translation socket (gemini-3.5-live-translate-preview, gpt-realtime-translate — see
Live translation), and gpt-live-1 opens a live
session on a protocol of its own (GPT-Live).
Two rows publish no voice list, deliberately
Google documents the 3.8 Live models as accepting "any of the voices available for our TTS
models" and enumerates none, so those rows leave supported_voices unset and record why in a
_supported_voices note. Copying the 3.1 list across would make the local validator reject voices
the API accepts — failing a legal request offline, which is worse than not validating.
Three rows carry a retirement record
gpt-realtime retires on 2027-01-20, replaced by gpt-realtime-2.1.
gemini-3.1-flash-live-preview has no announced shutdown date but Google's capabilities table
calls it a legacy preview and recommends gemini-3.8-live. gemini-3.5-live-translate-preview
carries one with neither a date nor a replacement, which is how a preview row says it is one. All
three are recorded as a
deprecation object ({date, replacement, note}) on the registry row, where a null date means
"a replacement is recommended, no shutdown announced". Discovery surfaces it — see
Client Integration — so you can react to a retirement
without reading a changelog.
The input sample rates differ
OpenAI Realtime takes 24 kHz in both directions; Gemini Live wants 16 kHz in and emits 24 kHz
out. This is the one place the neutral surface cannot paper over the providers — send_audio
forwards your bytes as-is and neither provider resamples for you. Read the rate off the constants
rather than hardcoding it:
from doc_processing_toolkit.realtime import (
OPENAI_REALTIME_SAMPLE_RATE, # 24000
GOOGLE_LIVE_INPUT_SAMPLE_RATE, # 16000
GOOGLE_LIVE_OUTPUT_SAMPLE_RATE, # 24000
)
The registry also carries input_audio_sample_rate / output_audio_sample_rate per model, which
is the right source when you support both.
Event vocabulary¶
session.events() is an async iterator of RealtimeEvent. The full neutral vocabulary:
event.type |
Meaning | Populated fields |
|---|---|---|
audio_delta |
A chunk of model speech | audio (raw PCM16 bytes) |
transcript_delta |
A fragment of the spoken transcript | text, role |
transcript_complete |
One committed stretch of audio has a settled transcript | text, role, item_id, duration_seconds |
transcript_failed |
The provider could not transcribe that item | item_id, error |
text_delta |
A fragment of text output | text, role |
response_complete |
The model finished answering | usage, cost |
turn_complete |
The turn boundary, on models where that is not the same thing | usage, cost |
tool_call |
The model asked for a tool | tool_name, tool_call_id, tool_arguments |
tool_result |
The toolkit answered it | tool_name, tool_call_id, tool_result, error |
tool_call_cancelled |
The server withdrew a tool call | tool_call_id |
interrupted |
The model's turn was cut short by the caller speaking | — |
session_resumed |
A transparent reconnect happened (Google only) | — |
session_restarted |
A reconnect had no resumption handle to send, so the new session has no memory of the conversation (Google only) | — |
error |
The provider reported an error | error |
Every event also carries raw, the untranslated provider event, for callers who need something the
neutral layer does not model, and item_id, the provider's identifier for the assistant item being
spoken — the correlation key truncate_response() uses. role is "assistant" for model output and
"user" for transcripts of the caller's own audio — the latter only appear when
input_transcription=True is configured.
The module exports each type as a constant (EVENT_AUDIO_DELTA, EVENT_RESPONSE_COMPLETE, …) so a
consumer can match on those instead of bare strings.
A GPT-Live session adds three of its own,
defined in doc_processing_toolkit.realtime_gpt_live because only that family can produce them:
delegation_created (the model handed work to your application or to the backend — the delegation id
is in tool_call_id, the target in text), usage_updated (a cumulative duration snapshot in
duration_seconds, never a delta) and session_closed, which is that protocol's terminal event.
A translation session needs no new type at all: both
transcripts arrive as transcript_delta told apart by role, and RealtimeEvent.language carries
the BCP-47 code the provider attached, where it attaches one.
Configuration is validated against the registry¶
configure() stages options and checks them against the model's descriptions.<model> row before
any network activity. An unsupported voice or output format raises RealtimeValidationError naming
what the model does support, rather than failing mid-session:
session.configure(voice="Kore", modalities=["audio"], input_transcription=True)
Every argument is optional and only the ones supplied change, so it can be called repeatedly to adjust one knob at a time. When configuration is applied differs by provider:
A native-audio Gemini Live model will not answer in text only
gemini-3.8-live rejects a TEXT-only response modality outright — its row lists both text and
audio among its output_types because it emits both, not because you may ask for one. Do not
configure modalities=["text"] against it; if you want the words rather than the speech, read
transcript_delta off the audio turn, or open a
transcription session instead.
- OpenAI applies staged options to the live session at the next send or event poll.
- Gemini Live fixes its configuration at connect time. A post-connect
configure()logs a warning and takes effect on the next reconnect.
configure() also takes thinking_level, proactive_audio, enable_affective_dialog,
context_compression, tool_call_behavior and tool_response_scheduling. Each is checked against
the model's row before the socket opens, and so is anything you set through provider_options,
which reaches LiveConnectConfig verbatim — validating only the neutral path would have left the
documented escape hatch wide open. session.capability(name) answers one capability with its
documented default, session.capabilities returns the whole record, and realtime_capabilities(model)
answers for a model you have not connected to yet. That record deliberately separates
provider_capabilities (what the registry says the API offers) from toolkit_features (what this
adapter actually implements), because a capability flag is not a promise that the toolkit drives it.
toolkit_features also says which session families the toolkit serves on that provider:
transcription_sessions, translation_sessions and live_sessions are True exactly when an
adapter is registered for that (provider, kind) pair — so live_sessions is True on OpenAI and
False on Google, and a discovery client can tell "no adapter yet" from "the model cannot do it"
without opening a socket. The table is checked against the routing table itself, so a routable kind
cannot ship without its key.
The rest of toolkit_features follows the model's own session kind, not just its provider. The
provider map is the conversational default, and the adapter for another kind overrides the keys it
answers differently: gpt-live-1 reports explicit_response_cancel, response_truncation,
visual_input and video_frames as False, and the two translation rows also report text_turns,
tool_dispatch, async_tool_dispatch and context_window_compression as False — the controls
those adapters refuse. Keyed by provider alone, the report used to claim them. Conversational rows
report exactly what they did before.
turn_detection is translated, not forwarded¶
turn_detection is a neutral dict. None keeps the provider default (server-side voice activity
detection), while {} or {"disabled": True} requests manual turn control. With manual control you
close each input turn yourself, and commit_audio() does that on both providers:
await session.send_audio(pcm_chunk)
await session.commit_audio() # only needed when turn detection is disabled
The wire messages differ. OpenAI commits the input buffer and asks for a response. On Gemini Live
the client has to mark the turn itself once automatic activity detection is off, so the first
send_audio() of a turn is preceded by activity_start and commit_audio() sends activity_end.
The model answers the turn, and the next send_audio() opens a new activity. With automatic
detection on, Gemini's commit_audio() sends audio_stream_end instead, which tells the server the
microphone paused and makes it flush buffered audio. Google documents the two paths as mutually
exclusive: activityStart / activityEnd only with automatic detection disabled, and
audioStreamEnd only with it enabled. The adapter picks the path from your turn_detection
setting. session.manual_turn_control reports which path is in force. Before this release the
Gemini conversational adapter had no commit_audio() at all, and nothing sent activity_start /
activity_end, so a manual-turn Gemini session could never close a turn.
gpt-live-transcribe and gpt-realtime-whisper publish supports_turn_detection: false. OpenAI
says those models "require turn detection to be omitted or set to null". A turn_detection that asks
for voice-activity detection is therefore refused locally with a RealtimeValidationError.
{"disabled": True} is still accepted and sent as null.
Everything else is mapped per provider by translate_turn_detection, which is public and testable on
its own:
| Neutral key | OpenAI | |
|---|---|---|
disabled: True / {} |
turn_detection: null |
AutomaticActivityDetection(disabled=True) |
type |
verbatim (server_vad default) |
dropped → "automatic activity detection" |
threshold |
verbatim | dropped → start_of_speech_sensitivity |
prefix_padding_ms, silence_duration_ms |
verbatim | on AutomaticActivityDetection |
idle_timeout_ms |
verbatim | dropped → silence_duration_ms |
create_response, interrupt_response |
verbatim | dropped → activity_handling |
eagerness |
verbatim (semantic_vad) |
dropped → end_of_speech_sensitivity |
start_of_speech_sensitivity, end_of_speech_sensitivity |
dropped → threshold / eagerness |
on AutomaticActivityDetection |
activity_handling, turn_coverage |
dropped | on RealtimeInputConfig, one level up |
A key the target provider cannot express is dropped with a warning naming the nearest control it
does have, never approximated: threshold and start_of_speech_sensitivity are differently scaled
knobs, and silently changing barge-in behaviour is worse than not applying a setting the provider
never had.
Before this release the neutral dict was forwarded into AutomaticActivityDetection verbatim. That
object is extra='forbid', so threshold=0.5 raised a pydantic ValidationError at connect time,
and activity_handling / turn_coverage were being sent to the wrong object entirely.
Tools in a realtime session¶
A realtime model asks for a tool the same way an ordinary one does, but neither protocol runs it for
you: the host executes the tool and sends the result back over the socket. Pass tools= to
open_realtime_session and the toolkit does that for you, using the same tool formats, the same MCP
execution path and the same hook events (tool_call_start, the before_tool_call veto,
tool_call_result) as an ordinary Agent run. You see the exchange as two neutral events,
tool_call and tool_result, and you can ignore both if you only care about the answer. The
tool_call_result hook event carries the first 500 characters of the result and, since 0.5.1,
result_truncated; a session has no tool_result_preview_chars setting, and the whole result is on
the tool_result event. A session does not read the _dpt_spend marker a tool can put in its result
(the multimedia MCP tools do): the marker reaches the model unread, and session.cost does not
include that spend, unlike an Agent run's (see
Tool-reported spend).
async with await open_realtime_session(
"gpt-realtime-2.1-mini",
tools=["tools/my_tools.py"],
hooks=my_hooks,
max_tool_rounds=8,
) as session:
...
Dispatch is bounded in the two ways a voice session can run away. max_tool_rounds (default 16) caps
how many calls one session may run; tool_timeout_seconds (default 30) caps one call. A breached
ceiling, a vetoed call, a timeout and a crashing tool all send the model an error result — a
realtime model left waiting on an unanswered tool slot simply stops talking, which is a worse failure
than a tool that says it failed. tools= requires supports_tool_calls on the row, and passing
hooks= without tools= warns, because there is then nothing for them to observe.
On OpenAI the tool result goes out as a function_call_output conversation item as soon as the
tool finishes. The follow-up response.create cannot go out yet — the response that made the call
is still active, and asking for a second one is an API error — so it is sent on that response's
response.done, and one request covers every call the response made. The consequence worth knowing:
that calling response's completion is not surfaced as response_complete, so the event keeps
meaning what it means in the text loops, "the model finished answering". A calling response cancelled
by a barge-in gets no automatic follow-up at all; the result is carried by the next user turn.
On Gemini Live the behaviour is per model. gemini-3.1-flash-live-preview is blocking: it will
not speak until the answer arrives, so the whole batch is answered in one send_tool_response. The
3.8 rows call tools asynchronously — the model keeps speaking and keeps accepting your speech
while the tool runs. The toolkit follows the model's registry row rather than a flag you set:
gemini-3.8-live defaults to NON_BLOCKING, and gemini-3.8-live-extended-thinking supports nothing
else. Results are returned as they land, each carrying a scheduling hint as a top-level
FunctionResponse field (WHEN_IDLE by default; INTERRUPT to cut off what is being said), and
outstanding calls are cancelled when the session closes, when it reconnects — a resumed connection has
no memory of the call ids — and when the server sends a tool-call cancellation, which arrives as a
tool_call_cancelled event. A result that had already finished and was waiting to be sent when the
reconnect happened is discarded too, rather than answered on the resumed session under an id it never
issued. close() also finalizes the receive loop and its background tasks, within a two-second bound;
under NON_BLOCKING they used to outlive the session.
Showing the model what you are looking at¶
A realtime session can take pictures as well as sound. session.send_image(png_bytes) attaches a
still — a photographed page, a chart, a screenshot — and the model answers about it on your next
turn. session.send_video_frame(jpeg_bytes) is for a continuous capture: a camera, a screen share, a
document under a webcam.
The two are deliberately different. A still is a discrete act, like typing a question, so it goes on
the wire immediately. A frame is one of many, and the model re-reads every one of them as billable
image tokens — so frames arriving faster than max_video_fps (one per second by default) are
coalesced: the newest frame is kept, the one it superseded is counted in session.frames_dropped,
and nothing is queued.
That is the point rather than a limitation to work around. A 30 fps loop would otherwise multiply
your bill roughly thirtyfold for a picture that barely changed, and a model asked about a screen
share should answer about the screen now, not about a backlog of stale frames. Nothing ever blocks
your producer, and audio is never gated or dropped. Set max_video_fps=0 if you really do want every
frame.
async for frame in camera: # 30 fps in
await session.send_video_frame(frame) # ~1 fps reaches the model
await session.send_text("What am I holding?")
print(session.frames_sent, session.frames_dropped)
send_video_frame returns whether the frame went on the wire now; flush_pending_frame() releases a
held frame, and both adapters call it from send_audio, so a live session's continuous audio is a
free heartbeat for a capture loop that has gone still. DEFAULT_MAX_VIDEO_FPS is the default, and no
registry row carries the key — no vendor publishes a frames-per-second figure.
Pass encoded image bytes (PNG, JPEG, GIF, WebP, BMP — REALTIME_IMAGE_MIME_TYPES), not raw
pixels. The container is identified from the payload's own first bytes with sniff_image_media_type,
and a payload the toolkit cannot identify is refused rather than labelled with a guess, so a
mislabelled frame fails locally instead of being billed and rejected by the provider. Size is checked
against the registry's image_requirements, and a model whose row declares neither image nor
video in input_types refuses before anything is sent.
| OpenAI Realtime | Gemini Live | |
|---|---|---|
| Still | conversation.item.create with an input_image part |
send_realtime_input(video=…) |
| Frame | The same wire — a frame is an image on this protocol | send_realtime_input(video=…) |
detail (REALTIME_IMAGE_DETAILS) |
Sent verbatim | No equivalent — dropped with a warning naming media_resolution |
On Gemini Live a still also goes out on the video channel
Google documents exactly one way to show a Live model a picture — "video frames are sent as
individual images" — and a live run on gemini-3.8-live confirmed it is the only one that works:
the same PNG sent on the SDK's generic media= channel was accepted without any error and then
never seen, while video= answered about it correctly. So both stills and frames use video=,
and a still differs from a frame only in never being rate-gated.
A frame never triggers a response on its own. It is context for whatever you say next; asking the model to answer every frame would make a capture loop talk over itself.
Pixel dimensions are not checked
image_requirements also carries max_pixels and max_edge, but reading them needs an image
decoder the toolkit deliberately does not depend on. Only max_size_mb is enforced, so a frame
inside the byte ceiling but over the edge limit still reaches the provider.
Knowing when a turn is over¶
The usual way to end a realtime turn is to break on response_complete. On
gemini-3.8-live-extended-thinking that is not enough on its own: the model keeps reasoning in the
background after the turn boundary, so turnComplete no longer means the session is idle. The toolkit
reads the server's interaction_status for you on rows that publish it — you get a turn_complete
event at the boundary and response_complete only once the model reports IDLE.
Every other model behaves exactly as before: turn_complete is emitted only on rows that use
interaction_status, so the other streams are unchanged, and a session where the server never sends
the field still completes on the turn boundary. break on response_complete therefore cannot hang.
Interrupting, and what the model thinks it said¶
cancel_response() stops the model mid-answer. truncate_response() does the other half: it tells
the provider how much of that answer the listener actually heard, so the next turn is grounded in
reality rather than in an answer that was cut off. The toolkit counts the assistant audio it handed
you per item (session.emitted_audio_ms, reset on a new item_id and on interrupted) and uses that
by default; if your application buffers audio before playing it, pass your own played_ms.
await session.cancel_response()
await session.truncate_response(played_ms=1_800) # what the listener really heard
OpenAI supports both controls. Gemini Live has no truncation message at all, so truncate_response()
raises there naming the provider; its interrupt is available only on the 3.8 rows (it is
send_client_content(turns=None, turn_complete=True), which requires supports_client_content_mid_session).
On 3.1 Live you interrupt by speaking, which is what voice-activity detection is for, and the error
says so.
Session length: two different mechanisms¶
Session resumption and context-window compression are often described together, and they are not the
same thing. Resumption reconnects a dropped transport and replays the handle — the toolkit does it
for you and you see a single session_resumed event. It does not enlarge the context.
Compression is the separate policy that decides what to keep as the conversation grows, enabled
with configure(context_compression=...) on rows that carry supports_context_window_compression
(True, or a dict with trigger_tokens / sliding_window_target_tokens).
session.compression_policy reports both, plus the provider's session-duration caps, so you can see
which limit you are actually up against.
Live transcription: a different kind of session¶
A transcription session is not a conversation with the model's voice turned off. There is no model
turn at all: you send audio, you get transcripts, and there is nothing to interrupt, no tool to
call and no answer to cancel. send_text raises, naming the conversational kind, and tools= is
refused at construction by the row's supports_tool_calls flag.
from doc_processing_toolkit.realtime import open_transcription_session
session = await open_transcription_session("gpt-transcribe")
try:
await session.send_audio(pcm_24k)
await session.commit_audio()
async for event in session.events():
if event.type == "transcript_complete":
print(event.item_id, event.text)
finally:
await session.close()
print(session.transcript, session.cost)
Read item_id, not the order events arrive in. Each committed stretch of audio is transcribed
independently, so a later utterance can finish before an earlier one — session.transcripts is keyed
by item id for exactly that reason, and session.transcript joins them in completion order.
Closing the session drains whatever is still being transcribed first (bounded by
DEFAULT_FINAL_DRAIN_SECONDS, 2 s, and by "no items still open"), because a turn you have already
paid for is often still in flight when you stop reading; a drain that cannot finish warns and names
the lost item ids.
| Row | Provider | Billing | Notes |
|---|---|---|---|
gpt-transcribe |
OpenAI | $0.0045 / minute | The same row you use for a recorded file, on a second surface at the same price. Its transcription_input_mode is committed, so with turn detection off you call commit_audio() |
gpt-live-transcribe |
OpenAI | $0.017 / minute | Socket-only, continuous |
gpt-realtime-whisper |
OpenAI | $0.017 / minute | Socket-only, continuous; keyword biasing is a gpt-transcribe-family option and this row declares supports_keywords: false |
gemini-3.5-transcribe-live |
tokens: $3.50/M audio in, $21.00/M text out | Plain captions, a ten-minute cap it cannot resume from, and an estimated bill — see below |
The three OpenAI rows are billed by duration, and the toolkit bills the seconds the provider
itself reported (session.provider_audio_seconds, summed from each completed item's
usage.type == "duration") rather than the time your socket happened to be open;
session.billed_audio_seconds is the figure actually used, falling back to connected wall clock —
and a session billed on that fallback sets session.cost_estimated, because the socket's clock is
not what the provider charged for (it used to be reported as exact). A duration-billed session can
never report a confident $0.00 — an unmeasurable one bills the MINIMUM_BILLABLE_MINUTES floor
and sets session.cost_estimated.
The OpenAI rows take the five guidance fields of the provider's transcription object, as keywords to
open_transcription_session(...) or to configure(...): language (an ISO-639-1 code), languages
(candidate codes when the language is not known in advance), prompt (free text steering style or
continuing a previous segment), keywords (words or phrases to bias towards) and delay (latency
traded for accuracy, one of realtime.TRANSCRIPTION_DELAY_LEVELS: "minimal", "low",
"medium", "high", "xhigh"). They used to be unreachable — passing one raised a bare
TypeError. The toolkit checks their shape only — a string where one is expected, a non-empty
list of strings for languages and keywords, a known delay — and raises
RealtimeValidationError otherwise; which model honours which field is the vendor's call, so a row's
supports_keywords: false is not enforced on this socket. noise_reduction ("near_field",
"far_field", or False for off) is available here too.
session = await open_transcription_session(
"gpt-transcribe", language="en", keywords=["doc-processing-toolkit"]
)
The connect URL is registry data rather than a branch on the session type: realtime_connect_query
and realtime_model_in_url produce ?intent=transcription with no model= on those rows,
because the model is named inside session.update. Absent, they reproduce the conversational URL
byte for byte.
Gemini live captions behave differently¶
gemini-3.5-transcribe-live is a captions-only Live socket, and three things about the live protocol
are worth knowing before you build on it.
- Interim captions are cumulative snapshots, not fragments. While audio streams the server sends
the whole utterance so far ("This", "This is a", "This is a test"), so each neutral
transcript_deltacarries only what the latest snapshot added. The completed caption is the authoritative text. - The settled caption arrives only after the end of the audio stretch is marked.
commit_audio()sendsaudio_stream_end, andclose()sends it for audio you never flushed. Callcommit_audio()at each utterance boundary, or the caption you are waiting for never settles. Under manual turn control (turn_detection={"disabled": True}) the same call sendsactivity_end, closing theactivity_startthe firstsend_audio()of the stretch opened. Google labels no conversation items, so completed captions are keyed by arrival order (caption-0, …) anditem_idstaysNoneon a delta. - The provider reports no usage for this model at all. A token-priced session with no token
report would close at a confident
$0.00, so the adapter estimates instead: input audio tokens from the seconds of audio it sent at the row'sinput_audio_tokens_per_second(25, registry data), output tokens from the caption text.session.usage_estimatedandsession.cost_estimatedare bothTrueand one WARNING says so. A real usage report, should one ever arrive, replaces the estimate.
Language hints, custom vocabulary and mode. Google documents three options for live
transcription, and they are named parameters of open_transcription_session(...) and of
configure(...) on this row:
languages: BCP-47 hints, sent aslanguage_codes. An empty list asks for automatic identification explicitly.custom_vocabulary: up to 1,000 phrases. Google says results are best with up to 100, and the toolkit warns above that.mode:"VERBATIM", the default, or"SMART", which removes disfluencies and formats the text.
Each option is gated on a registry key: supports_language_hints, supports_custom_vocabulary
(with max_custom_vocabulary_terms and recommended_custom_vocabulary_terms), and
supported_transcription_modes. They are validated by the same rules as
file transcription. A row without the key
refuses the parameter by name. Before this release languages= raised a bare TypeError, although
the row said supports_language_hints: true.
session = await open_transcription_session(
"gemini-3.5-transcribe-live",
languages=["en-US"],
custom_vocabulary=["doc-processing-toolkit", "Gemini Live"],
mode="SMART",
)
Diarization and word timestamps are refused locally: the row publishes neither, and the API would
accept the option and return plain captions — a silent wrong answer rather than an error. Use
transcribe_audio on a completed recording when
you need annotations.
The ten-minute cap ends the stream; it does not reconnect
This row publishes no session resumption, so a reconnect would be an unrelated session with no
memory of the audio already transcribed and the transcript would silently restart mid-sentence.
The adapter therefore ends the stream with an error event naming the cap.
session.compression_policy["limits"]["max_session_minutes"] tells you the figure before you hit
it; for a long meeting, open a fresh session before the cap and join the transcripts yourself.
Every conversational Gemini Live row declares resumption, so they are unaffected.
Live translation: the model as interpreter¶
A translation session is the one realtime session where the model is not talking to you. It listens
to a speaker and interprets: it does not answer questions, follow instructions, call tools or take
turns. Both vendors are explicit about the difference — Google contrasts "the model acts as an
assistant" with "the model acts as an interpreter … translates as the speaker talks without waiting
for turns", and OpenAI contrasts "uses a conversation and response lifecycle" with "streams
continuously from incoming audio … you don't call response.create". That is why it is its own
session kind rather than a system prompt.
from doc_processing_toolkit.realtime import open_translation_session
session = await open_translation_session(
"gemini-3.5-live-translate-preview", target_language="en"
)
consumer = asyncio.create_task(drain_events(session)) # audio_delta → speaker
try:
for chunk in japanese_microphone: # 16 kHz PCM16, paced
await session.send_audio(chunk)
finally:
await session.close()
print(session.source_transcript, "->", session.translated_transcript)
open_translation_session(model, target_language=...) is
open_realtime_session(model, session_kind="translation", ...) under its own name, and
target_language is required. Google documents a default of "en"; the toolkit refuses instead,
because a session that silently interpreted into a language nobody asked for is a wrong answer billed
by the minute. Everything a conversation has and an interpreter does not — voice, instructions,
output_format, modalities, turn_detection, thinking_level, proactive_audio,
enable_affective_dialog, context_compression, tool_call_behavior, tool_response_scheduling —
is refused locally rather than accepted by the provider's config object and ignored, and send_text
raises naming send_audio.
Keep sending audio, including the silence between phrases. Both transcripts — and Gemini's usage reports — advance only while input audio keeps arriving, so a caller that streams a recorded clip and then stops sending loses the tail of both transcripts (the translated audio still completes). Follow a clip with a second or two of paced silence, exactly as a microphone would, before you close. There are no turns to detect, and OpenAI documents that audio resumed after a gap is treated as contiguous with what came before rather than as a real-world pause.
Both transcripts come back, and the role tells them apart. role="user" is what the speaker was
heard to say, in the language they said it in; role="assistant" is the translation. Where a provider
labels the language it detected, event.language carries the BCP-47 code and
session.detected_source_languages accumulates them in first-seen order — Gemini does this, OpenAI
labels nothing. You cannot set a source language on either provider, so source_language= is
accepted and ignored with one warning rather than quietly doing nothing.
One target language per session. That is a vendor constraint, not a toolkit one: Gemini's config
carries a single target_language_code and OpenAI's guide says outright to create one session per
output language. The code is checked against the model's own published list before the socket opens —
79 codes on Gemini, 13 on OpenAI — case-insensitively and in the vendor's own spelling, so "PT-br"
goes out as pt-BR and "zh" is refused with a "did you mean zh-Hans / zh-Hant" that reflects
how Google actually splits Chinese.
target_languages= and echo_target_language=. target_languages=[code] with one element is
accepted as a synonym for target_language=; more than one code is refused, because neither row
publishes supports_multi_target_translation, and a bare string is refused with a pointer to the
singular form. echo_target_language=True asks the interpreter to repeat speech that is already in
the target language instead of staying silent for it; it is Google-only, gated on the row's
supports_echo_target_language (true on gemini-3.5-live-translate-preview, false on
gpt-realtime-translate), and refused elsewhere.
| Gemini live translate | OpenAI translate | |
|---|---|---|
| Row | gemini-3.5-live-translate-preview (preview) |
gpt-realtime-translate |
| Transport | the same Gemini Live SDK socket, plus a translation_config |
a raw WebSocket to /v1/realtime/translations |
| Audio | 16 kHz PCM16 in, 24 kHz out | 24 kHz PCM16 both ways; the vendor recommends 200 ms chunks (OPENAI_TRANSLATION_FRAME_MS) and send_audio forwards yours as they are |
| Billing | tokens ($3.50/M audio in, $21.00/M audio out) | duration ($0.034 per minute), always cost_estimated |
| Source transcripts | on by default | opt-in; engages a second, separately priced model |
| Detected language | reported on each transcript | not reported |
The two billing rows differ in what the provider tells you, not just in the tariff. Gemini does
report usage — one usage_metadata per second of input audio, 25 audio tokens each way — but the
first one lands three to four seconds into the stream, so a shorter session closes on an audio-derived
estimate instead, with session.usage_estimated set. OpenAI sends no usage or duration event of any
kind on that socket, so the toolkit bills the audio it appended and flags every cost from the row.
Both cases are spelled out under
Sessions billed by the second.
The OpenAI row is the module's one documented raw-WebSocket exception
The installed openai SDK hardcodes /realtime in AsyncRealtime._prepare_url and declares no
translation session type, so that endpoint is unreachable through it. The exception is confined to
one adapter class and adds no dependency: websockets is already a hard requirement of
google-genai, which the toolkit pins. The path itself is registry data
(realtime_connect_path), not a literal — a second endpoint on the same host is a different
row, not a branch on the session type.
The Gemini row is a preview, and never a default
gemini-3.5-live-translate-preview has no announced shutdown date, publishes no session cap, and
declares no session resumption — so the inherited reconnect refuses rather than silently opening
an unrelated session mid-sentence. (The live server does send resumable
session_resumption_update handles unasked; the row stays supports_session_resumption: false
until a resumed interpreter session has actually been exercised.) Treat an entitlement refusal at
connect as a normal outcome rather than a bug.
Ending a translation session¶
You decide when it ends, and close() waits for the tail. Neither protocol has a reliable
end-of-stream event — Gemini's turnComplete is not what a turn-free stream produces, and OpenAI has
no response to complete — so response_complete is emitted only opportunistically and must never be
your only exit. Closing the session flushes first (on Gemini by marking the audio stream ended, which
is what makes the last utterance settle; on OpenAI by sending session.close and reading until the
server answers session.closed) and folds whatever arrives into the transcripts and the usage totals.
session.final_drain_events is what the drain saw. Stop iterating events() before you call
close() — the drain reads the same socket.
commit_audio() exists on the Gemini translation session only: it marks the audio stream ended
without closing the socket, so a settled utterance can be read before the next one starts. The
OpenAI translation session has no commit — close() is its only flush.
The drain is bounded by a wall clock, so a socket that has gone quiet cannot hang your shutdown. On
OpenAI that budget is its own: a live run measured session.closed arriving 3.9 seconds after
session.close, behind 34 flushed audio deltas, so the shared 2-second realtime drain closed every
session early and dropped that audio. DEFAULT_TRANSLATION_CLOSE_DRAIN_SECONDS is 10 seconds — a
budget, not a delay: the drain returns the moment the confirmation arrives — and it is overridable per
session:
session = await open_translation_session(
"gpt-realtime-translate",
target_language="es",
provider_options={"close_drain_seconds": 20.0}, # a ceiling, not a wait
)
close_drain_seconds is read at connect and never reaches session.update. The other
provider_options this adapter takes are api_key, base_url, websocket_base_url, open_timeout
and safety_identifier (OpenAI's OpenAI-Safety-Identifier header).
OpenAI's session.created carries the server's default target, not yours
A live session opened for "en" was created with "es" — the server's own default — and only
session.updated reports the language this session actually got. The adapter therefore logs the
confirmed target from session.updated, and a confirmed language that differs from the one you
asked for is a warning, not a silent substitution.
GPT-Live: a different protocol, not a different model¶
open_live_session("gpt-live-1") opens a session that looks like the voice conversation you already
know and works almost nothing like it underneath. GPT-Live is a separate API family on /v1/live/*
with its own event vocabulary, and the toolkit surfaces the differences rather than papering over
them, because each one changes how you write the loop. It is still SDK-native: everything goes
through the openai SDK's own Live client, so there is no raw WebSocket code and no new dependency.
You never ask it to speak. There is no audio commit and no response trigger: you stream audio
continuously and GPT-Live decides when to listen and when to talk. commit_audio() and
cancel_response() both raise rather than pretending — the model is full duplex and handles
interruption itself; to redirect it, use append_instructions(...).
There is no end-of-response event. The if event.type == "response_complete": break idiom that
works on gpt-realtime will never fire here, because the protocol emits nothing that marks the end of
a spoken answer. Drive your loop from your own playback queue, or from the transcript, and end the
session when your application decides the conversation is over. The terminal event is
session_closed, and it only arrives after you ask for it.
Transcripts have no item ids. Deltas carry the text, a start time and an end time and nothing
else, so event.item_id is None on every one of them and the toolkit does not invent a substitute
that would look provider-issued. Group them by speaker and by the start_ms/end_ms timeline;
session.input_transcript, session.output_transcript and session.transcript_fragments keep the
accumulation for you.
Tools run on a backend, not on the voice model. GPT-Live delegates. By default it delegates to
your application (session.delegation.created arrives as a delegation_created event, which you
answer with the three context appends). With provider_options={"backend_model": ...} the API manages
a Responses backend instead and your tools= become its function tools, which the toolkit still
executes locally — including your MCP servers, which is the only way they can reach a Live session,
because the delegation surface accepts only function and web_search entries and has no remote-MCP
tool type. Without a backend model, tools= is refused rather than silently dead.
from doc_processing_toolkit.realtime_gpt_live import open_live_session
session = await open_live_session(
"gpt-live-1",
voice="marin",
instructions="Be concise. Delegate order questions to the backend.",
tools=[order_lookup_server],
provider_options={"backend_model": "gpt-5.6-luna"},
)
try:
async for chunk in microphone: # 24 kHz mono PCM16, paced
await session.send_audio(chunk)
finally:
await session.close()
print(session.billed_audio_seconds, session.cost, session.cost_excludes)
session.append_instructions(), .append_thinking() and .append_commentary() steer a running
conversation (each capped at 500 tokens by the API); .mute_input() / .unmute_input() stop the
model hearing you, though not the clock, which runs through silence by design. send_text queues a
user message for the backend — the voice frontend has no user-text event at all — so under the
default client delegation it raises, naming the three routes that do work: startup history through
provider_options={"initial_input": [...]}, the three appends, or configuring a backend model.
.session_id,
.close_reason and .finalization_confirmed say what happened to the socket:
close_reason is one of close_requested, expired, content, remote_hangup or
connection_lost, and finalization_confirmed records whether session.closed actually arrived.
Voices. gpt-live-1 publishes 22 voices in supported_voices: the ten Realtime built-ins
(alloy, ash, ballad, cedar, coral, echo, marin, sage, shimmer, verse) and the
twelve that OpenAI's Live conversations guide adds for GPT-Live (quartz, ripple, vesper,
willow, stone, gleam, meridian, bossa, tempo, beacon, delta, cinder). marin is
the default. A voice name is now validated locally. An OpenAI custom voice, voice={"id": "voice_..."},
bypasses the list, because the guide says gpt-live-1 supports custom voices. The list and the
custom-voice object ship together, so no voice the API accepts becomes newly rejected.
No noise_reduction. The option belongs to the Realtime audio.input object, which GPT-Live
does not have, so it is refused by name at configure() time like every other Realtime-only
option — the conversational, transcription and translation sessions keep it.
Images go to the backend, never to the voice model. send_image() is refused on a GPT-Live
session — the audio frontend accepts none — so route a photo to a vision-capable backend and hand
GPT-Live the text it found.
The tool round trip waits for the delegated response to finish
One response.create goes out per delegated response, once its terminal lifecycle event has
arrived and every call it made has been answered — the forwarded lifecycle events carry
response.output: [] even while function calls still need results, so waiting costs nothing and
not waiting strands a second parallel call behind a premature continue. A live run confirmed the
order: the result goes out on response.output_item.done, response.completed follows about
0.15 s later, the single response.create follows that, and a second backend response carries the
answer. The wait is registry data (live_tool_followup_waits_for_response_end, default true),
so a provider change is a JSON edit rather than a code change.
Not in this release: stored sessions and forking, recording download, SIP call control, the sideband monitoring connection, and the WebRTC transport.
Usage and cost¶
Usage is tracked per modality in both directions, including the cached share where the provider
reports one, and priced through calculate_cost under the registry's audio_realtime schema — or,
on a row billed by the clock rather than by tokens, under per_minute (see
Sessions billed by the second).
session.usage, session.cost and session.cost_components are live throughout the session and
final after close().
session.usage.input_audio_tokens
session.usage.input_image_tokens # stills and frames
session.usage.output_audio_tokens
session.usage.output_thinking_tokens # a SUBSET of output_text_tokens
session.usage.cached_input_tokens # a SUBSET of total_input_tokens
session.usage.as_dict() # every counter plus derived totals
session.cost_components["duration_usd"] # a per_minute row's whole charge
session.cost_components is the per-component split of session.cost — the
calculate_cost component keys, refreshed with the cost.
Every entry but total_usd sums to total_usd on every row: a token-priced session spreads its
charge over the input, cached and output components, and a duration-billed one (transcription,
gpt-realtime-translate, gpt-live-1) carries all of it in duration_usd. The attribute is new;
session.cost itself is unchanged.
Cached counts follow the toolkit-wide convention: they are a subset of their non-cached
counterparts, never an addition (see Cost Tracking). So does
output_thinking_tokens: Gemini Live reports thinking as thoughts_token_count, it is billed as
text-rate output, and the toolkit folds it into output_text_tokens and reports it separately only
so you can see how much of the answer was reasoning. Before this release it was never read, so a
thinking Live model's reasoning went unbilled — 649 tokens on one probe turn of
gemini-3.8-live-extended-thinking.
When an OpenAI response reports a cached total with no per-modality split, the toolkit spreads it
across audio, image and text in proportion to that response's input and sets
session.cost_estimated, because the split is an estimate. It used to label the whole total text,
where the per-modality clamp discarded it, so cached audio was billed at the full audio rate —
$0.160 instead of $0.011 for one 5,000-audio-token response on gpt-realtime. A reported split is
used as reported and stays exact. The audio_realtime schema
prices text, audio and image separately in each direction — input, input_audio, input_image,
output, output_audio, plus cached_input / cached_input_audio / cached_input_image where
published.
input_cached_image_tokens is clamped per modality rather than against the prompt total, so a
provider reporting more cached image tokens than image tokens cannot leak the surplus into the
discount. On OpenAI both counts come from input_token_details.image_tokens and
cached_tokens_details.image_tokens; on Google the IMAGE and VIDEO modality rows are summed,
because a frame is reported under either and both bill at the same input_image rate.
The rows that publish an image rate¶
| Row | input_image |
cached_input_image |
|---|---|---|
gpt-realtime-2, gpt-realtime-2.1, gpt-realtime-1.5 |
5.00 | 0.50 |
gpt-realtime-2.1-mini |
0.80 | 0.08 |
gemini-3.1-flash-live-preview, gemini-3.8-live, gemini-3.8-live-extended-thinking |
1.00 | — (no Live row publishes a cached rate) |
gpt-realtime |
5.00 | 0.50 |
gpt-realtime accepts image input too — OpenAI's model card lists text, audio and image input and the pricing page publishes the same $5.00 / $0.50 pair — so every conversational OpenAI row now carries one. Until
this row gained the pair, an image token on gpt-realtime billed at the text input rate ($4.00/M)
and now bills at $5.00/M. On every other row the three-way split left audio-and-text costs unchanged
to the last cent, because no row had published both an audio and an image rate before it.
Session duration is wall-clock, not frame count¶
session.session_duration_seconds accumulates the time the transport was actually connected, summed
across reconnects. A realtime provider that bills per minute bills connection time; a frame-derived
figure would need a per-provider sample-rate assumption and would under-count the silence the
provider still charges for. Gemini Live's hidden session-duration cap closes one billing segment and
opens another, so the reconnect gap is excluded.
The figure is passed to the cost engine as audio_minutes, and the zero-token early return that used
to short-circuit pricing no longer fires for a duration-billed row. Every conversational realtime
row is token-priced under audio_realtime, as is gemini-3.5-live-translate-preview; the
per_minute rows are the three OpenAI transcription rows, gpt-realtime-translate and gpt-live-1.
On all of them a provider-reported duration beats the wall clock where the provider reports one — see
Live transcription and
Sessions billed by the second.
Gemini Live has no published cached rate
Google's pricing page publishes no cached-input rate for Live models, so the registry sets none
and the gap is recorded in pricing._comments. Cached audio on gpt-realtime is priced.
Two Gemini accounting behaviors were settled against the live API rather than assumed, and both matter if you are reconciling a bill:
- Gemini Live sends exactly one
usageMetadataper turn carrying that turn's usage, not a running total —responseTokenCountwas observed decreasing across turns (25, 27, 23). Reports are therefore summed, not diffed. promptTokenCountincludes tokens that appear in no modality row (the system instruction), so the audio share is taken from the details and the remainder bills as text.
Exporting a session and seeding the next one¶
session.export() returns the session's record as JSON-clean data, at any time and final after
close():
record = session.export()
# {"schema_version": 1, "provider": "openai", "model": "gpt-realtime", "session_kind": "realtime",
# "started_at": "2026-09-24T10:00:00+00:00", "closed_at": "...", "close_reason": "closed", # "error" after a transport failure
# "duration_seconds": 12.4, "usage": {...}, "cost": 0.0123, "cost_estimated": False, "cost_components": {...},
# "transcript": [{"role": "user", "text": "...", "item_id": None, "completed_at": "..."}, {"role": "assistant", ...}]}
transcript is the text of every completed turn, in completion order: the turns you sent with
send_text, the caller's speech when input_transcription is on (tagged user), and the model's
own transcript (assistant); an answer cut short by a barge-in is kept and marked
"interrupted": True. Audio is never retained, so an hour of conversation is a few KB. The same
list is live on session.conversation. A transcription session exports its completed captions the
same way. A translation session and GPT-Live retain no transcript by design (an interpreter's two
transcripts are on source_transcript / translated_transcript and its events; GPT-Live's text is
on its own event stream) and export usage and cost with an empty list. Every family's close()
sets closed_at and close_reason; on GPT-Live close_reason is the provider's session.closed
reason when one arrived.
Seeding the next session. provider_options={"initial_items": [...]} on an OpenAI Realtime or
Gemini Live session takes the exported entries, {"role": "user" | "assistant", "text": ...}
(content is accepted in place of text, which is GPT-Live's initial_input shape), and hands
them to the model before the first response: OpenAI as completed message items right after
session.update, Gemini as prior turns through send_client_content(turns=..., turn_complete=False)
at connect, and again after a reconnect that could not resume. No response is requested for them and
they are not part of the new session's own transcript. The option is validated at construction: a
non-list, an unknown role or an empty text raises RealtimeValidationError, and so does giving it
to a transcription, translation or GPT-Live session (GPT-Live's route is
provider_options={"initial_input": ...}; a transcription session's prompt takes the tail of a
transcript instead).
record = session.export()
seed = [{"role": e["role"], "text": e["text"]} for e in record["transcript"]]
resumed = await open_realtime_session("gpt-realtime", provider_options={"initial_items": seed})
await resumed.send_text("Where were we?")
Provider notes¶
OpenAI¶
OpenAIRealtimeAdapter drives AsyncOpenAI().realtime.connect(model=...) — the GA, non-beta
surface, so the SDK owns the URL, auth header, and handshake and no OpenAI-Beta header is hand-set.
Output formats are the GA names: audio/pcm, audio/pcmu, audio/pcma. The retired beta names
(pcm16, g711_ulaw, g711_alaw) are rejected outright by a live session. audio/pcm also needs an
explicit rate, which the adapter sources from the registry.
The socket's protocol family and the model used for input transcription are registry values too
(realtime_session_type, default "realtime", and default_input_transcription_model), not
literals in the adapter. All five conversational OpenAI rows now name gpt-transcribe as the
input-transcription model. They used to name whisper-1, which OpenAI removes on 2027-02-26 together
with gpt-4o-transcribe, gpt-4o-mini-transcribe and gpt-4o-transcribe-diarize.
gpt-transcribe is the cheapest non-retiring model the Realtime reference accepts for
audio.input.transcription.model, at $0.0045 a minute against whisper-1's $0.006. So
input_transcription=True puts a different model on the wire from this release. To keep the old
one until its retirement, pass input_transcription={"model": "whisper-1"}. The whisper-1 row
stays registered. The transcription rows set realtime_session_type: "transcription" and name
themselves as their own transcription model, which is what makes their socket a different protocol
family rather than a branch in the adapter. A row that requests transcription without publishing a
default fails locally, naming the registry path.
audio.input options. Three named options map onto the audio.input object:
noise_reduction:"near_field"for headsets,"far_field"for laptop and conference-room microphones, orFalseto switch it off.Falseis sent asnull. The conversational, transcription and translation sessions share one vocabulary,realtime.NOISE_REDUCTION_TYPES; GPT-Live has no such field and refuses the option by name.input_transcription: also takes a dict of transcription guidance on a conversational session,{"model", "language", "languages", "prompt", "keywords", "delay"}. The dict is merged over the registry model, and a"model"key replaces that model.languageandlanguagesare alternatives: OpenAI says never to send both, so supplying both is refused. The accepted keys arerealtime.INPUT_TRANSCRIPTION_KEYS.- A passthrough
provider_options={"audio": {"input": {"transcription": {...}}}}is deep-merged into that object. It used to replace the object wholesale, which silently dropped the registry's transcription model.
session = await open_realtime_session(
"gpt-realtime-2.1-mini",
noise_reduction="far_field",
input_transcription={"language": "de", "keywords": ["IBAN"]},
thinking_level="low",
)
Reasoning effort. gpt-realtime-2, gpt-realtime-2.1 and gpt-realtime-2.1-mini publish
supported_thinking_levels: minimal, low, medium, high and xhigh. On those rows
thinking_level= is sent as session.reasoning.effort. It used to be dropped with a warning. It
still is on gpt-realtime and gpt-realtime-1.5, which are not reasoning models. OpenAI's
voice-prompting guide recommends starting at low. Higher effort can add latency and output tokens,
so the change has a kill switch: DPT_OPENAI_REALTIME_REASONING=off restores the drop (see
Configuration).
Custom voices. voice= also takes an OpenAI custom-voice object, {"id": "voice_..."}, on a
conversational session, on GPT-Live and in synthesize_speech. The object bypasses
supported_voices, since a custom voice is by definition not on that list, so only its shape is
checked. Whether your account may use the voice is for the API to decide: OpenAI limits custom voices
to eligible customers. Gemini Live refuses the object by name. In synthesize_speech the Gemini 3.8
TTS rows accept one as well (see Multimedia).
A raw provider_options["tools"] replaces the dispatched function tools in session.update. It is
still sent, with one WARNING.
xAI escape hatch
xAI's voice API is deliberately OpenAI-Realtime compatible, so the adapter accepts base_url,
websocket_base_url, and api_key overrides through provider_options:
session = await open_realtime_session(
"gpt-realtime",
provider_options={"base_url": "https://api.x.ai/v1", "api_key": XAI_KEY},
)
Overriding only the endpoint works too — with api_key omitted the client falls back to
OPENAI_API_KEY, read from the environment or the repo-root .env.
Google¶
GeminiLiveAdapter drives client.aio.live.connect(model=..., config=...), so the SDK owns the
BidiGenerateContent handshake and framing.
The session-duration cap is invisible to callers. Gemini Live caps sessions server-side (15
minutes, 2 with video). The adapter enables session_resumption at connect, records every resumption
handle the server issues, reacts to the server's goAway warning, and reconnects with the stored
handle — replaying the same configuration. You see one uninterrupted events() stream with a single
session_resumed event marking the seam.
If the connection drops before the server has issued any handle, the adapter still reconnects,
but there is nothing to replay and the new session has no memory of the conversation. That seam
is marked by session_restarted (EVENT_SESSION_RESTARTED) and a WARNING instead of
session_resumed: treat it as a context reset and resend whatever the next turn needs.
session_resumed now means a handle was sent. The transcription and live-translation adapters
inherit both events.
Reconnects use exponential backoff (capped at 8 s) and are bounded by max_reconnects in
provider_options, default 10. Exhausting the budget logs an error and ends the stream rather than
retrying forever.
A text turn takes a different message per model. send_text used to call send_client_content
everywhere, which Google documents on gemini-3.1-flash-live-preview as seeding initial context
only — so every text turn after the first was silently dropped on that model. The route is now driven
by the registry key supports_client_content_mid_session: 3.1 Live uses
send_realtime_input(text=...), the 3.8 Live rows keep send_client_content. No name matching is
involved.
provider_options also accepts any LiveConnectConfig field verbatim — temperature,
context_window_compression, proactivity, and so on — and the capability gates apply to it too, so
a feature the model does not have fails locally rather than at the API. Pass tools through
open_realtime_session(tools=...) rather than as a raw tools config: only the former is dispatched,
hooked and bounded.
Six passthrough keys replace what the adapter builds
provider_options is applied to LiveConnectConfig with a flat update, so a key the adapter also
builds replaces the adapter's object wholesale. Each one below is still sent, with one WARNING per
key per session naming what it overrides (the map is GeminiLiveAdapter.passthrough_overrides):
| Passthrough key | What it replaces |
|---|---|
session_resumption |
The adapter-managed transparent reconnect |
speech_config |
voice= |
thinking_config |
thinking_level= |
realtime_input_config |
turn_detection=, including manual turn control |
input_audio_transcription |
input_transcription= (on the captions row: languages=, custom_vocabulary=, mode=) |
tools |
The dispatched function tools |
The guard applies to all three Gemini adapters: conversational, captions and translation. It warns and does not refuse. A refusing variant would be a behaviour change and is a known limitation.
Tool schemas are normalised for Gemini Live. The realtime path now runs the same $ref / $defs
inlining and keyword stripping the text loop uses, so a tool whose arguments are a Pydantic model no
longer fails at connect time while the identical tool works in an Agent run. It additionally
filters the JSON Schema meta-keywords — $schema above all — that google.genai.types.Schema
rejects outright, checking each node against the installed SDK's own field list so a field a
future google-genai adds stops being stripped automatically.
The ordinary Google tool path applies the same filter
Since this release the ordinary Google tool path applies the same filter
(utils._strip_non_google_schema_keys, shared with this adapter), so an MCP tool schema carrying
$schema no longer raises while its FunctionDeclaration is built. See
Providers.
No raw WebSocket code, no new dependency¶
Every adapter goes through its vendor SDK's own client — the conversational and transcription
sessions, Gemini live translation, and GPT-Live, whose whole /v1/live/* surface ships in the
installed openai SDK. There is exactly one exception, and it is documented rather than incidental:
gpt-realtime-translate lives on /v1/realtime/translations, which that SDK cannot address at all
(it hardcodes /realtime and declares no translation session type), so its adapter speaks a raw
WebSocket. The websockets package every one of them uses underneath is already a hard requirement
of google-genai, which the toolkit pins, so realtime still adds no dependency.
Realtime is deliberately not an MCP tool¶
The multimedia MCP server exposes TTS, STT, and image generation, but not realtime. MCP tool calls are request/response; a realtime session's value is in sub-second audio frames flowing both ways, interruption handling, and a connection held open across many turns. None of that survives being squeezed through one-shot tool calls, so realtime stays a Python API.
Errors¶
| Exception | Raised when |
|---|---|
RealtimeValidationError |
The model is not a registered realtime model, or an option it does not support was passed |
RealtimeConnectionError |
No client could be built for the provider (missing SDK or API key) |
Provider-reported errors mid-session arrive as error events rather than exceptions, so a
consumer loop decides whether to continue or break.
Related pages¶
- Realtime Voice Session recipe — a runnable walkthrough: one turn, tools, a picture, captions, an interpreter and GPT-Live with a reasoning backend
- Multimedia — one-shot TTS, STT, and image generation/editing
- Client Integration — what an application around this API has to own
- Cost Tracking — pricing schemas and the cached-tokens convention
- MCP — the multimedia tool server, and why realtime is not on it
- Streaming — token streaming for ordinary text agent runs
- Providers — provider routing and the model registry