Add Boson realtime plugin - #6340
Conversation
bdc9c7e to
0dddba7
Compare
8395936 to
ddd576d
Compare
|
Opened #6835 to discuss the scope of this, per CONTRIBUTING's "open an issue first" guidance. Short version for anyone picking it up: this adds a realtime plugin for Boson's Higgs speech-to-speech model. Boson is self-serve, so it can be verified end to end without any coordination with me — a Google sign-in gets you a key, the endpoint is public and documented, and the PR ships an CI is green, the CLA is signed, and the Devin review findings are all addressed. @longcw @tinalenguyen — I wasn't sure who owns realtime provider plugins these days, so apologies if I've picked the wrong people; a pointer to whoever does would be very welcome. Equally happy to hear that a separate plugin isn't the direction you want before anyone spends review time on the diff. |
565990c to
9596377
Compare
…ntime
Addresses the review finding that a single WebSocket failure was
terminal. Instead of extending the forked connection machinery, drop it
(~400 lines) and inherit the parent OpenAI plugin's
_main_task/_run_ws/_reconnect loop, xAI-style; Boson-specific behavior
now lives in small overrides and hooks:
- Reconnection follows the server's documented contract (the voice-chat
server keeps no state across connections): the base _reconnect re-sends
the full session config and replays the local chat-context mirror via
conversation.item.create, with _create_session_update_event /
_create_tools_update_event / _create_update_chat_ctx_events overridden
to emit Boson-shaped payloads. Retry count/backoff come from
conn_options, and a session_reconnected event is emitted on resume.
- _run_ws wraps the base loop only to reclassify closes a reconnect
cannot fix as non-retryable: close code 3000 (invalid API key) and
server-announced session ends (session.idle_timeout,
session.max_duration_reached), which must not be resurrected.
- Boson-only server events are handled off the base's
openai_server_event_received hook instead of a forked dispatch;
_main_task fails pending response futures immediately on terminal
failure instead of leaving them to their own timeouts.
- Stop advertising auto_tool_reply_generation: the server treats
conversation.item.create as a pure insert now, so the framework must
send response.create after tool outputs — previously tool calls
completed client-side but no reply audio ever came back.
- Treat conversation.item.added for a known id as an in-place update:
the server merges consecutive same-role turns into one item and
re-emits added with cumulative content, which the base insert rejected
("already exists"), leaving the mirror with stale content.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The _handle_response_created override closed the current generation and recorded _current_response_id before the base handler could check _discarded_event_ids. A response that timed out or was interrupted before the server created it would therefore cut off the streams of a newer in-progress generation, and a subsequent interrupt() would cancel the stale response id instead of the active one. Check the discard set first and let the base handler cancel/discard the stale response without touching the in-progress generation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous fix stopped closing the active generation when a discarded response's response.created arrived, but the base handler still parked a _DiscardedGeneration marker in the generation slot, so every subsequent delta of the legitimate in-progress response was silently dropped — and plain save/restore would only defer the damage, because the stale response's response.done (which follows the cancel almost immediately) would close whatever generation sits in the slot: neither the slot nor _handle_response_done is response-id aware. When a legitimate generation is streaming, restore it after the base discards the stale response AND remember the stale response id; _handle_response_done ignores terminal events for remembered ids instead of closing the active generation or clearing _current_response_id. With no generation in progress (the common serial case) the base discard marker is kept so the stale response's trailing events are still eaten. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ids The server now stores client-supplied item ids and echoes them on conversation.item.added (duplicate ids are rejected instead of merged), so item ids work as the foreign key the base machinery expects. Drop the append-only update_chat_ctx and its manual seen-id ledger in favor of the base implementation: diff against the remote mirror, create/delete sync, and echo-correlated futures. _create_update_chat_ctx_events shrinks to a thin adapter: it feeds the base diff a text-only mirror of the context (audio is represented by its transcript; the GA converter must never see audio frames, whose conversion can raise on empty buffers) and rebuilds each create with the Boson item shape, mapping the unsupported "root" previous_item_id sentinel to a tail append. A minimal extra="allow" item model keeps the base isinstance/echo plumbing working without fighting the GA content-type literals. With items addressable, mutable_chat_context returns to the base default (True): interrupted never-played messages are now cleaned up server-side and realtime sessions can be reused across agent handoffs. Also trim nonstandard surface while here: drop the RealtimeModel.aclose override that closed sessions (session lifecycle belongs to the framework) and the _pending_response_futures test-compat alias, remove the unused NUM_CHANNELS re-export and single-use helpers, warn once instead of per-frame in push_video, and reword comments to describe wire behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A response id enters _boson_discarded_response_ids when its stale response.created arrives while another generation is streaming, and leaves it on the matching response.done. If the connection drops in between, that done never arrives, so the entry would outlive its connection. Clear the set on session_reconnected alongside the other per-connection state, matching how the base reconnect clears _discarded_event_ids. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire and options: rename `modalities` to `output_modalities`, add `truncation`, update the default model id, stop retrying close codes a reconnect can't fix, and keep expected client/server races from surfacing as recoverable errors. Correctness fixes, each with a regression test verified against a real repro: - Route response-scoped events by response id instead of a single shared slot, so overlapping responses can't corrupt each other's generation — including trailing events that arrive once the slot is empty. - Drop system/developer chat items before the context diff rather than after, so an unsupported item at the head stops forcing a full remote rebuild on every sync. - Scope generate_reply()'s instructions/tool_choice/tools at the session level, serialized and cancellation-safe, since there is no per-response override. - Report update_chat_ctx() sync failures precisely: match the offending item by its previous_item_id, and hold the error across concurrent calls. Tests are categorized as unit so they run in the CI gate.
The server scopes a per-response `instructions` to that turn: it replaces the session prompt for the turn while the turn still answers from the real conversation. Send it there instead of emulating the scope with a session.update before response.create and another one after. That removes the lock serializing overlapping scoped calls, the task set they ran in, the cancellation bridging between the caller's future and those tasks, and the restore path — the emulation's whole failure surface. `tools`/`tool_choice` are still not applied per response — the server accepts and ignores them — so they are no longer forwarded either. The framework already scopes them at the session level around the call (per_response_tool_choice stays False); a caller reaching the session directly now gets one warning rather than silently ignored arguments. The base only prefixes its own instructions onto a per-response value when they are already set, and leaves them unset until the first update_instructions(). Since the server *replaces* the prompt, that would drop the configured instructions for the turn, so seed them at construction and keep both copies in sync. Requires the server-side change that scopes response.create overrides to the turn instead of answering from an empty conversation.
The error used to identify nothing it rejected: no event_id, and the *missing* previous_item_id rather than the item that referenced it. The only way to fail the right create was to regex the id back out of the message prose and then search a table of every pending create's previous_item_id for the ones that had used it -- a table this had to write on every create and prune on every sync, kept alive solely for a lookup the error should have made unnecessary. The server now names the rejected item, so the create is looked up directly and the table, the regex, and the parse-failure fallback all go. The degradation stays: a server that does not name the item leaves the error unattributable, and update_chat_ctx() reports its own timeout instead -- the same outcome an unparseable message produced before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_tools_to_boson converts function tools and skips everything else. The skip was silent, so a tool the caller registered would simply never reach the model, with nothing in the logs to say why. The framework gaining a tool kind this plugin cannot express -- MCP tools, say -- is exactly when that would happen, and exactly when nobody would think to look here. Warned once per session, off the count of tools that came out shorter than the list that went in rather than a second type test, so the check cannot drift from the conversion it describes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things moved under the plugin since this branch was cut. RealtimeModel.session() gained a keyword-only `turn_detection_disabled`, which asks a model configured with server VAD to hand turn-taking back to the client for one session. Accept it to keep the override compatible, but decline it: that path is untested against Boson, so pin can_disable_turn_detection off rather than half-honor the request. The framework reads the capability before passing the argument, so the value is never True here -- the same shape xai, ultravox and phonic settled on. The base now fails every pending generate_reply future from its own session-loop teardown, with one generic reason. That reason reaches the caller ahead of the Boson close detail, so the two close tests assert what each half is actually responsible for now: the future resolves rather than hanging, and the close code -- including close_code=3000 -- arrives on the error event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`livekit-plugins-boson` was in the workspace but not in the optional dependencies of `livekit-agents`, so `pip install livekit-agents[boson]` -- the way every other plugin is installed -- did not resolve. Its version was still the 1.0.0.dev0 the package was scaffolded with, which would have released it out of step with the rest of the repo. Move it and its livekit-agents floor to 1.6.9, the current train. uv.lock carries only what those two changes imply: the workspace member, the extra, and the package entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more things the base grew while this branch sat, both invisible to CI because nothing covered them. `InputTranscriptionCompleted.turn_started_at` carries when the person began speaking, and the framework stamps the user's message with it so a late transcript cannot be dated after the reply it prompted. The base keeps the bookkeeping in two halves -- record on speech_started, hand over on transcription completed -- and this plugin overrides both without calling super(), so the field was always None and the correction never applied. Restored, but not by deferring to the base: it assigns on every speech_started and pops on the first completed, which suits one item per turn. Here the server merges consecutive speech into a single item, firing speech_started once per fragment and re-transcribing the item with its accumulated text. Assigning would date the turn from its last fragment, and popping would leave every revision after the first with None -- the exact case the timestamp is for. So: first fragment wins, and the entry survives until the item is deleted. Separately, a rejected conversation.item.create/delete never sends the added/deleted reply its future waits on. The base settles that future from the error's event_id -- the only thing that distinguishes the delete and the create an item update sends under one item id. This _handle_error replaced the base's outright and never consulted it, so any rejection other than invalid_previous_item_id left update_chat_ctx() to burn its 5s timeout and report a generic message instead of the server's. It settles the future now, and, like the base, treats one rejected item as a warning rather than a failed turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merged-item branch guarded its future with cancelled(), where the base uses done(). The difference only shows on a future that is done with an exception -- cancelled() is False there, so set_result raises InvalidStateError, which the recv loop logs as "failed to handle event" after dropping the rest of that event's handling. A rejected create is exactly such a future, and it is still registered: the error paths settle by event id or read _item_create_future without removing the entry, and only update_chat_ctx() clears it. So a server that rejects an item and then echoes it anyway lands on one. Settling the future from the event id widened that from invalid_previous_item_id to every rejection, so this is the guard that change needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONTRIBUTING requires every new public class and method to carry a docstring: pdoc3 generates the API reference from them, so without one this provider publishes an empty page. Nothing here had any. The module header leads with the six ways Boson departs from the OpenAI realtime protocol, since that is what makes the rest of the file read the way it does. Below that, the parameters callers actually have to get right are spelled out rather than named: that user transcripts arrive only when `input_audio_transcription_model` is set, that output is one modality or the other and mixing raises, that `turn_detection=None` and `False` both mean off, that `tools`/`tool_choice` on generate_reply are accepted and ignored by the server, that `tracing` and `reasoning` exist to match the base signature, and which close codes end the retry loop instead of feeding it. Docstrings only -- the AST is unchanged with them stripped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inserting ahead of what the server already holds has no wire primitive, so that case deletes the remote conversation and sends it back in order. Only what can be expressed as text goes back: a user turn still waiting on its transcript has none, and no audio is kept client-side, so the delete takes it away and the recreate skips it. The loss is real but bounded. Deleting also drops the item from the remote mirror, so a later update_chat_ctx() creates it once its text exists -- at the tail, since the order it was reinserted for is gone. Until then the model answers a turn short. Nothing here can avoid that; the client has nothing to resend. What it can stop doing is losing a turn silently, so the affected ids are named in a warning, and the trade-off is written down in the method and the README next to the rebuild that causes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settling the create's future from the error's event id introduced a way to lose the escalation it was meant to leave alone. Both lookups -- the event id, and the item id invalid_previous_item_id names -- reach one future object, registered under both keys by the base. The event id gets there first, so the invalid_previous_item_id branch found it already done, skipped its body, and never set _chat_ctx_sync_error. update_chat_ctx() reads only that slot, so it returned success for a sync the server had refused, leaving the agent to reply from a context it believed it had updated. The failure was inverted, too: escalation survived only against servers that omit event_id from the error. The ones that report the most detail -- which is where this error carries both ids -- were the ones it stopped working on. Nothing covered that, because the existing test predates event_id being sent and pins the older shape. The decision is now the error's own type, in _settle_chat_ctx_wait, so it no longer depends on which lookup arrived first. Which types escalate moves to a named set rather than an inline comparison, since it is now read from two call sites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_NON_RETRYABLE_CLOSE_CODES already ends the session on the 4429 close that follows a billing entitlement refusal, but the server sends an `error` event first, and this handler classified it as recoverable. So the application was told to expect a recovery in the window before the close ruled it out. Deferring to the base's own fatal set would not have fixed it. `_is_fatal_error` reads `code` in preference to `type`, and three of the four codes here -- monthly_cap_reached, contract_ended, no_billing_account -- are Boson's own and absent from that set. Only `type` is stable across them, and it never gets looked at once a code is present. Hence a set of both, next to the close codes that carry the same policy. Raised rather than emitted, which is how the base signals the same thing: its recv loop lets a non-retryable APIError through, and _main_task reports it with recoverable=False and stops reconnecting. A refusal that arrived without a following close now ends the session on its own rather than on nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment claimed "root" marks an item that is not remote yet. It does not: the base emits it for whatever sits at the head of the target context, and a plain text change to the item already at the head arrives the same way, since the base expresses a content update as a delete and a create under one id. The behaviour was right and is unchanged. Neither case can be sent incrementally: previous_item_id=None appends, so while the server holds anything, mapping "root" to it puts the item after the turns it should precede -- as true of a head item the delete just removed from its position as of a new one. The comment is now what the code does. Pinned by a test, because the misreading suggests a narrowing that looks like a cheap win: trigger the rebuild only on "genuine" inserts and leave head content updates incremental. That reorders the conversation. The test drives a head text change over two remote turns and asserts the full teardown and re-send; it reads the sent events under a timeout so the narrowed version fails on the event list rather than blocking for the ones it never sends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aclose cancels the main task rather than awaiting it, so closing never waits out a retry backoff. cancel_and_wait reads no task's outcome, and _main_task re-raises on terminal failure, which reads like a task left to be reported again by asyncio on collection. It is not. Task.cancel() clears the flag that report is driven by as its first statement, ahead of the done() check that makes it a no-op for a task that has already finished. The exception is never read; the report is suppressed anyway. Behaviour unchanged, then -- what was missing is anything saying so, which is what invites adding a retrieval that is already handled. Asserted through the loop's exception handler after dropping the task and collecting, not through the flag: the flag is an implementation detail, while the absence of the report is the contract. That distinction earned itself -- against the flag, a first attempt at checking the test could tell the difference passed while replacing the cancel with a wait that swallowed the exception itself, and only the handler caught that the substitute had retrieved what it was meant to leave alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Higgs Realtime is now self-serve — anyone can claim a key at
boson.ai/workspace and connect to the documented public endpoint — so the
model no longer needs to be told where to connect or what to authenticate
with:
AgentSession(llm=boson.realtime.RealtimeModel())
`url` defaults to wss://api.boson.ai/v1/realtime, and an omitted `api_key`
reads BOSON_API_KEY, following the same convention as the xAI plugin. An
empty BOSON_API_KEY counts as unset: carrying it through would connect with
no credential and surface as a close code far from the blank line that
caused it.
Passing `api_key=None` explicitly still means "this endpoint takes no auth"
and skips the environment entirely, so a local dev server cannot pick up a
key left over from another run.
Also note that `turn_detection` reaches the server unfiltered, so the
semantic_vad type works as well as the server_vad default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
commit_user_turn() commits the input buffer whether or not server VAD is on. With it on, the server has already committed each segment itself, so the client's commit lands on an emptied buffer and the server complains — which reached the application as a recoverable error event, making ordinary manual turn-taking look like a failure. The base handler suppresses this, but keys it on the turn detection in its own opts. Boson's lives in _boson_opts and is sent separately, so the base copy is always None here and the check would never have matched even if this handler delegated to it. Read the Boson value instead. Kept conditional on server VAD being enabled: with turn detection off the client owns every commit, so an empty one is a genuine client bug and still surfaces. Reported by Devin review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plugin could be read but not tried: nothing in the tree connects it to
a microphone. Add examples/agent.py, a plain voice assistant with a weather
and a clock tool, so one console session exercises audio in and out, user
transcription, barge-in, and the function-call round trip.
export BOSON_API_KEY=...
python examples/agent.py console
Console mode needs no LiveKit server and no LIVEKIT_* credentials, so
trying it costs a key and a microphone.
The example is excluded from both wheel and sdist by the existing
packaging config, and lives outside the mypy package scope, so it adds
nothing to what ships or to what CI checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A rejection that names a conversation.item.create already reaches the caller: the future it settles carries it to update_chat_ctx(), where the base downgrades a single rejected item to a warning rather than failing the turn. The handler then went on to emit a session error for the same event, so an application saw an alarm for something the plugin had just decided was not worth failing over -- during ordinary chat-context syncing. Return after settling, as the base does. A fatal error still falls through: which client event the server happened to be answering says nothing about whether the account can continue. Escalation is unaffected, being decided inside _settle_chat_ctx_wait, and no generate_reply can be waiting on the id, since a chat-ctx event id and a response.create event id are never the same id. The fatal test in the fallthrough path now shares a predicate with this check, so the two cannot drift apart. Reported by Devin review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The base states a content change as a delete plus a create under one item id. When the new content has no text left, the create is one this client cannot express -- the server stores nothing for a textless item, so its echo would never resolve the create future -- and the incremental path dropped it silently. The delete went out alone: the server lost the turn, the remote mirror lost it, and update_chat_ctx() reported success. Keep the delete. The caller asked for that text to leave the context, and holding the server's copy would keep feeding the model something they took out. But losing the turn is a bigger outcome than emptying it, so report it the way the rebuild path already reports what it cannot put back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5f32265 to
92fb91c
Compare
|
The A Verified by running this branch's exact commit with and without the fix, on the CPython build CI uses (3.12.13):
#6882 is the fix — one file, test-only, based on main. This PR needs no change; it should go green once that lands. |
Summary
Adds
livekit-plugins-boson, exposing Boson AI'sHiggs Realtime speech-to-speech model as a LiveKit
RealtimeModel.Higgs speaks a dialect of the OpenAI Realtime protocol, so the plugin subclasses
the OpenAI realtime plugin and overrides only where the two diverge — the same
shape as the xAI plugin.
Connection handling is not forked.
_main_taskand_run_wsare thin wrappersthat delegate to
super(), leaving the base in charge of the connect, retry andreconnect flow (including the chat-context replay a reconnect needs); they exist
only to reclassify the closes Boson reports differently and to settle pending
futures on a terminal failure. What is genuinely Boson-specific is the
session-update payload, conversation synchronisation, response handling, and
error classification.
Tracking issue: #6835
Try it yourself (~2 minutes)
Boson is self-serve, so you can verify this against the real service without
asking me for anything:
wss://api.boson.ai/v1/realtime(docs)
Then talk to it:
export BOSON_API_KEY=... uv run python livekit-plugins/livekit-plugins-boson/examples/agent.py consoleconsoleuses the local microphone and speakers — no LiveKit server and noLIVEKIT_*credentials needed. The example is a plain voice assistant with acouple of function tools, so one session exercises audio in and out, user
transcription, barge-in, and the function-call round trip.
The plugin itself needs no configuration —
urldefaults to the hosted endpointand
api_keyfalls back toBOSON_API_KEY:Demo
Here is that session recorded, if you would rather watch than set it up:
https://drive.google.com/file/d/1Uc_UMKe-dr_mzXo-Sg-Yqrta8PjwQcMn/view
Protocol differences from OpenAI Realtime
The divergences are behavioural, so pointing the OpenAI plugin at the endpoint
gives you a session that connects and then misbehaves in ways that are hard to
attribute. The six that reach callers:
session.updatereplaces the whole session instead of merginginstructionsandtoolsinstructionsare honoured; per-responsetools/tool_choiceare accepted and ignoredper_response_tool_choice = Falseconversation.item.createis a plain insert["text"]or["audio"], never bothOne more that shapes the implementation: the server has no insert-at-head
primitive —
previous_item_id: nullalways means append-at-tail. So anupdate_chat_ctx()that needs to put a new item ahead of turns the serveralready has (e.g. prepending a context summary) deletes and recreates the whole
remote conversation in the target order, rather than silently misordering it by
appending at the tail. The rebuild can only restore what it can express as text,
so a user turn whose transcript hasn't arrived yet is dropped and the plugin logs
a warning naming the affected item ids.
Test coverage
88 test functions (93 collected cases), all hermetic —
pytest.mark.unit, nonetwork, no credentials, driven by a scripted fake WebSocket. They cover
behaviour rather than shape:
session.updatesemantics, and that a reconnect re-sends config +replays the chat-context mirror
warning for turns it cannot put back
start time for metrics
insufficient_quota,monthly_cap_reached,contract_ended,no_billing_account), which areexpected client/server races and must not surface as user-facing errors, and
which escalate a chat-context sync failure
the session instead of burning the retry budget
non-function tools
BOSON_API_KEYfallback, an emptyenvironment value treated as unset, and
api_key=Nonemeaning "this endpointtakes no auth" without consulting the environment
input_audio_buffer.committhat server VAD makes routine issuppressed, while the same error with turn detection off still surfaces
The close-code handling is also confirmed against the hosted service rather than
only the fake: connecting with a bad key really does close with 3000, and the
plugin reports it as
retryable=Falseinstead of burning the retry budget.Checklist
ruff check/ruff formatmake type-check(mypy strict, 3.10 and 3.13)livekit-agents[boson]extraKnown limitations (documented in the README)
["text", "audio"]output is not supported (server has no combined mode)model. Higgs Realtime itself accepts
audio/pcmat 8/16/24/48 kHz plusaudio/opusandaudio/pcmu(migration notes);
exposing that is straightforward follow-up work, left out here to keep the
first PR small
system/developerchat items are dropped when syncingupdate_chat_ctx()(the server's conversation store only accepts
assistant/user); useinstructionsinsteadturn_detection=None(client-driveninput_audio_buffer.commit) is exposedfor interface completeness but isn't a heavily-exercised path — treated as
experimental
Happy to split this up, rework the approach, or adjust anything that doesn't fit
the project's conventions.