fix(openai_mllm_python): migrate realtime client to the GA interface - #2261
Open
leepokai wants to merge 5 commits into
Open
fix(openai_mllm_python): migrate realtime client to the GA interface#2261leepokai wants to merge 5 commits into
leepokai wants to merge 5 commits into
Conversation
The extension spoke the beta shape of the Realtime API on three axes: it sent
the OpenAI-Beta: realtime=v1 header, recognised only beta server event names,
and serialised session.update in the flat beta layout.
Confine the wire format to a serialisation boundary in realtime/struct.py:
- point the EventType enum at the GA response.output_* event names, which the
dispatch in parse_server_message follows automatically
- add session_update_to_ga_dict(), mapping the flat internal params onto the
GA nested payload (session.type, audio.input, audio.output,
output_modalities, max_output_tokens)
- drop the beta header
extension.py matches on dataclasses rather than event strings, so it is
unaffected.
audio.*.format is emitted as an object ({"type": "audio/pcm", "rate": 24000}),
not the bare string the published reference still documents; the service
rejects the string form. The extension leaves the format unset today, which
inherits the GA default of PCM16 24 kHz mono, so behaviour is unchanged.
Also correct two model defects: the config default was gpt-4o, which is not a
realtime model, so any deployment that did not override the property failed to
connect. Defaults now track gpt-realtime-2.1, and a new optional
reasoning_effort property exposes its reasoning budget. It defaults to empty,
which omits the field, so existing deployments are unaffected.
Adds the extension's first tests. They cover the wire boundary only, so they
need no credentials, no network and no built runtime.
Scope is deliberately limited to openai_mllm_python. azure_mllm_python,
glm_mllm_python and stepfun_mllm_python carry their own copies of realtime/
but target independent endpoints and none sends the beta header; propagating
GA event names to them would break them.
Follow-up to the previous commit, addressing defects found in review.
Protocol gaps the first pass missed:
- assistant conversation items still used the beta content-part type `text`,
which GA renamed to `output_text`. Every assistant item was rejected, and
since SessionCreated replays stored history on each reconnect, the model
came back with only the user half of its context
- the OpenAI branch authenticated with aiohttp.BasicAuth, sending
Authorization: Basic. GA documents a Bearer token, which is also what the
sibling realtime clients in this repo send
Mapper hardening. The first version popped known fields and passed the
remainder through under their beta names, which made it a denylist:
- unmapped fields now raise instead of leaking. `temperature` is declared on
SessionUpdateParams, on the config and in every shipped graph, so wiring it
up would have sent an unknown key and had GA reject the whole
session.update, taking instructions, tools, VAD and voice with it
- `modalities` given a bare string was iterated into characters
(`"audio"` -> `["a","d","i","o","u"]`); a string is now rejected and order
is preserved rather than sorted
- an empty `reasoning_effort` serialised to `{"reasoning": {"effort": ""}}`.
The sentinel is now handled in the mapper rather than relying on a guard in
another module
- `_ga_audio_format` returned the shared table entry by reference, so a caller
mutating the result rewrote it for every later session. It now returns a
copy, raises on an unmapped value instead of emitting an object-shaped but
invalid `{"type": <value>}`, and passes an already-GA-shaped dict through
Test corrections:
- both model tests guarded DEFAULT_VIRTUAL_MODEL, which already read
"gpt-realtime" before this work and is unreachable in production since
start_connection always passes config.model. They now assert the
OpenAIRealtimeConfig default, which is the value that was gpt-4o
- the "no beta keys survive" loop was a hand-maintained denylist that passed
vacuously for any field the fixture left unset, and omitted the one field
that actually leaked. Replaced with a whole-payload assertion
- source checks moved from substring matching to AST inspection, so comments
naming a symbol cannot decide the result
The design doc is dropped from docs/, which is synced to the public portal on
release by trigger_sync_remote_docs.yml and is not the place for an internal
spec.
`case ErrorMessage()` only logged. GA leaves the socket open when it rejects an event, so nothing else surfaced the failure: `listen()` never terminated, reconnect never fired, `mllm_server_session_ready` was never sent, and the graph waited forever against a connection that looked healthy. This path was not load-bearing while the payload was the flat beta shape the service accepted. Reshaping session.update makes a rejection the primary failure mode, so route it through the base class's send_mllm_error with the vendor's own code and message attached.
ResponseCreateParams is still the flat beta struct — modalities, voice,
output_audio_format, max_response_output_tokens — where GA takes
output_modalities, max_output_tokens and audio.output.{voice,format}.
Nothing sets it today: send_client_create_response sends a bare
ResponseCreate(), so the field is unmigrated rather than wrong. Raise on the
first caller that populates it instead of letting a beta-shaped payload reach
a GA endpoint, matching how the session mapper treats unmapped fields.
leepokai
force-pushed
the
fix/openai-mllm-realtime-ga
branch
from
August 4, 2026 07:46
115258d to
55e0932
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
openai_mllm_pythonspeaks the beta shape of the Realtime API on three axes:realtime/connection.pysends theOpenAI-Beta: realtime=v1header, which the GA interface requires callers to droprealtime/struct.pyrecognises only beta server event names — GA renamed these toresponse.output_audio.delta,response.output_text.deltaandresponse.output_audio_transcript.deltasession.updateis serialised flat, where GA requiressession.type: "realtime", audio nested undersession.audio.input/session.audio.output,output_modalitiesandmax_output_tokensThis migrates the client to GA, corrects two model defects, and adds the extension's first tests.
Approach
The beta/GA difference is confined to a serialisation boundary in
realtime/struct.py, which imports only the standard library.Inbound. The
EventTypeenum values change to the GA strings. The dispatch inparse_server_messagecompares against those members, so it follows automatically.Outbound. A
session_update_to_ga_dict()mapper converts the flat internal params into the GA nested payload;to_json()dispatches to it, soconnection.py'ssend_request()path is untouched.extension.pymatches on dataclasses rather than event strings, so the rename does not touch its 20match/casearms.The mapper is an allowlist: a field with no GA mapping raises. GA rejects the entire
session.updatewhen it carries an unknown key, which would take instructions, tools, VAD, transcription and voice down with it, so a field added toSessionUpdateParamswithout a mapping has to fail loudly rather than silently invalidate the session.Audio format
audio.*.formattakes an object ({"type": "audio/pcm", "rate": 24000}), not the string"pcm16"the published reference still documents — the GA implementation answers the string form withInvalid type for 'session.audio.input.format': expected an object, but got a string instead.The extension leaves the format unset today and
to_jsondropsNone, so it inherits the GA default of PCM16 24 kHz mono, matching its ownsample_rate: 24000. Behaviour is unchanged; the mapping only applies when a format is explicitly set.Other GA gaps fixed
text, which GA renamed tooutput_text(Literal["output_text", "output_audio"]inopenai-python). Every assistant item was rejected, and sinceSessionCreatedreplays stored history on each reconnect, the model came back with only the user half of its contextaiohttp.BasicAuth, sendingAuthorization: Basic. GA documents a Bearer token, which is also whatstepfun_mllm_pythonsends for the same protocol. Note this one is alignment, not a fix —Basicis still accepted by the service today (see verification below)case ErrorMessage()only logged. GA leaves the socket open when it rejects an event, so nothing surfaced the failure: the graph waited onmllm_server_session_readyagainst a connection that looked healthy. Errors now route through the base class'ssend_mllm_errorModel configuration
gpt-4o, which is not a realtime model — any deployment not overriding the property failed to connect. Defaults now trackgpt-realtime-2.1reasoning_effortproperty exposing that model's reasoning budget. It defaults to empty, which omits the field, so existing deployments are unaffectedScope
Limited to
openai_mllm_python.azure_mllm_python,glm_mllm_pythonandstepfun_mllm_pythoncarry their own copies ofrealtime/—stepfun'sstruct.pywas byte-identical to this one — but they target independent endpoints (AZURE_AI_FOUNDRY_BASE_URIwith its ownapi_version,wss://open.bigmodel.cn,wss://api.stepfun.com) and none of them sends the beta header. They cloned the beta event schema but are separate services on their own release schedules, so propagating GA event names to them would break them.Relationship to #2167
#2167 performs the same protocol migration using the same mapper approach and has been open without review since 2026-05-21. This supersedes it by adding the model defaults, the
reasoning_effortcontrol, the GA gaps listed above, and a test suite. Happy to close this in favour of #2167 plus a follow-up if maintainers prefer — the overlap is the protocol layer only.Tests
52 tests, the extension's first. They target the wire boundary, so they need no credentials, no network and no built runtime.
Two deliberate deviations from how other extensions lay out
tests/: there is notests/__init__.pyand there is atests/pytest.ini. Makingtestsa subpackage causes pytest to importopenai_mllm_python/__init__.py, which importsaddon, which imports the nativeten_runtime— so those suites only run where a runtime is installed. Keeping the extension package out of the collection tree lets this suite run anywhere, which is the point of confining the wire format to a layer that depends on nothing.Source-level checks use
astrather than substring matching, so a comment naming a symbol cannot decide the result.Verified via
./tests/bin/start, the pathtask test-extensioninvokes.black --line-length 80 --checkis clean — note that the files in this extension satisfy 80, while 79 and 88 both report drift against unmodified files.Live verification
Run against
api.openai.comwithmodel=gpt-realtime-2.1, driving the actualto_json()from this branch:OpenAI-Beta: realtime=v1(whatmainsends)error— The Realtime Beta API is no longer supported. Please use /v1/realtime for the GA API.session.createdsession.updatesession.updatedaudio.input.formatas objectaudio.input.formatas string"pcm16"error— Invalid type for 'session.audio.input.format': expected an object, but got a string instead.session.updateerror— Missing required parameter: 'session.type'.Authorization: Basic(whatmainsends)session.created— still acceptedTwo things worth calling out. The published reference still documents
formatas a string; the service rejects it, so the object mapping in_GA_AUDIO_FORMATSis required and now has empirical backing rather thanresting on the docs. And
Basicauth is still accepted, so the switch toBeareris alignment with the documented scheme and the sibling clients, nota fix for a live failure — reviewers should weigh it on those terms.
Not covered
negotiation, not streaming PCM in and audio out. The event renames are
covered offline against captured payload shapes rather than live deltas.
temperatureandmax_tokensare declared on the config, inmanifest.jsonand inproperty.json, but are read nowhere inextension.py— pre-existing dead config. Left alone:temperaturemay not be accepted on a reasoning model, so wiring it up needs its own verification. The mapper now raises rather than leaking it, so the gap is visible instead of silent.voice-assistant-realtime,voice-assistant-companionanddemoall setmodelexplicitly, so this default bump reaches only new graphs. Worth a separate pass —gpt-realtimeenters deprecation on 2026-08-28.ResponseCreateParamsis not migrated. Nothing sets it today (send_client_create_responsesends a bareResponseCreate()), so it is unmigrated rather than wrong;to_jsonnow raises on the first caller that populates it.