Skip to content

Add Boson realtime plugin - #6340

Open
helin-mktech wants to merge 27 commits into
livekit:mainfrom
helin-mktech:feat/boson-realtime-plugin
Open

Add Boson realtime plugin#6340
helin-mktech wants to merge 27 commits into
livekit:mainfrom
helin-mktech:feat/boson-realtime-plugin

Conversation

@helin-mktech

@helin-mktech helin-mktech commented Jul 7, 2026

Copy link
Copy Markdown

Summary

Adds livekit-plugins-boson, exposing Boson AI's
Higgs 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_task and _run_ws are thin wrappers
that delegate to super(), leaving the base in charge of the connect, retry and
reconnect 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:

  1. Sign in at https://www.boson.ai/workspace (Google sign-in)
  2. New accounts currently get free credit, enough to try this out
  3. Create a key at https://www.boson.ai/workspace/api-key
  4. Endpoint is public: 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 console

console uses the local microphone and speakers — no LiveKit server and no
LIVEKIT_* credentials needed. The example is a plain voice assistant with a
couple 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 — url defaults to the hosted endpoint
and api_key falls back to BOSON_API_KEY:

session = AgentSession(llm=boson.realtime.RealtimeModel())

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:

# Difference Consequence
1 session.update replaces the whole session instead of merging Every update must resend the full config; a partial update silently erases instructions and tools
2 Per-turn instructions are honoured; per-response tools/tool_choice are accepted and ignored Tools are scoped at the session level; the plugin reports per_response_tool_choice = False
3 conversation.item.create is a plain insert It never triggers a reply
4 Sessions are not persisted server-side Each connection starts empty; reconnect replays the chat context from the client
5 Consecutive same-role speech merges into one item Re-sent with its accumulated transcript as it grows
6 Output is single-modality ["text"] or ["audio"], never both

One more that shapes the implementation: the server has no insert-at-head
primitive
previous_item_id: null always means append-at-tail. So an
update_chat_ctx() that needs to put a new item ahead of turns the server
already 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, no
network, no credentials, driven by a scripted fake WebSocket. They cover
behaviour rather than shape:

  • full-replace session.update semantics, and that a reconnect re-sends config +
    replays the chat-context mirror
  • the conversation rebuild: when it triggers, the resulting order, and the
    warning for turns it cannot put back
  • same-role item merging, and that the merged turn keeps the first fragment's
    start time for metrics
  • error classification — which server errors are fatal (insufficient_quota,
    monthly_cap_reached, contract_ended, no_billing_account), which are
    expected client/server races and must not surface as user-facing errors, and
    which escalate a chat-context sync failure
  • non-retryable close codes (3000 invalid key, 4429 entitlement refusal) ending
    the session instead of burning the retry budget
  • single-modality enforcement, tool filtering, and the drop warning for
    non-function tools
  • credential resolution: an explicit key, the BOSON_API_KEY fallback, an empty
    environment value treated as unset, and api_key=None meaning "this endpoint
    takes no auth" without consulting the environment
  • the empty input_audio_buffer.commit that server VAD makes routine is
    suppressed, 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=False instead of burning the retry budget.

Checklist

  • ruff check / ruff format
  • make type-check (mypy strict, 3.10 and 3.13)
  • Unit tests green
  • Every public method/param documented (Google-style docstrings, pdoc3-compatible)
  • CLA signed
  • Plugin registered as a livekit-agents[boson] extra
  • Version aligned with the 1.6.9 train

Known limitations (documented in the README)

  • Video input is not supported
  • Mixed ["text", "audio"] output is not supported (server has no combined mode)
  • Audio format is fixed at 24 kHz 16-bit mono PCM and not configurable on the
    model. Higgs Realtime itself accepts audio/pcm at 8/16/24/48 kHz plus
    audio/opus and audio/pcmu
    (migration notes);
    exposing that is straightforward follow-up work, left out here to keep the
    first PR small
  • system/developer chat items are dropped when syncing update_chat_ctx()
    (the server's conversation store only accepts assistant/user); use
    instructions instead
  • turn_detection=None (client-driven input_audio_buffer.commit) is exposed
    for 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.

@helin-mktech
helin-mktech requested a review from a team as a code owner July 7, 2026 09:04
@CLAassistant

CLAassistant commented Jul 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@helin-mktech
helin-mktech force-pushed the feat/boson-realtime-plugin branch from bdc9c7e to 0dddba7 Compare August 11, 2026 09:44
devin-ai-integration[bot]

This comment was marked as resolved.

@helin-mktech
helin-mktech force-pushed the feat/boson-realtime-plugin branch from 8395936 to ddd576d Compare August 11, 2026 10:09
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@helin-mktech

Copy link
Copy Markdown
Author

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 examples/agent.py you can talk to over your own microphone. There's a recorded session in the description if that is easier than setting it up.

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.

@helin-mktech
helin-mktech force-pushed the feat/boson-realtime-plugin branch from 565990c to 9596377 Compare August 13, 2026 03:15
devin-ai-integration[bot]

This comment was marked as resolved.

helin-mktech and others added 8 commits August 17, 2026 10:03
…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.
helin-mktech and others added 19 commits August 17, 2026 10:03
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>
@helin-mktech

Copy link
Copy Markdown
Author

The unit-tests failure on this PR is not from its code — it is #6881.

A google.genai client finalizer schedules aclose() on whatever event loop is running when the collector reaches it, and fail_on_leaked_tasks charges that task to whatever test happens to be running then. Here it lands on tests/test_plugin_openai_stt_context.py, which is synchronous and never touches google. This branch adds a test module and nothing else related, which is enough to move GC timing onto that test.

Verified by running this branch's exact commit with and without the fix, on the CPython build CI uses (3.12.13):

unit-tests
this branch as-is fails, every run
this branch + #6882 passes, every run

#6882 is the fix — one file, test-only, based on main. This PR needs no change; it should go green once that lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants