Repository navigation
fix(bedrock): forward each Nova Sonic assistant sentence once over the realtime API - #44987
Conversation
…ssistant sentence once Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…e realtime API Nova 2 Sonic sends every assistant text block twice, a SPECULATIVE preview next to the audio and a FINAL transcript once the audio turn has ended. The realtime bridge forwarded both, so voice clients rendered each sentence twice and the FINAL copies opened extra responses after response.done, the last of which never closed. FINAL assistant text blocks are now dropped whole, so a turn carries each sentence once inside the one response with its audio
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
…h its FINAL block
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6451016. Configure here.
TLDR
Problem this solves:
How it solves it:
Decision, which text stage to forward: the SPECULATIVE block, since it streams in step with the audio and is the one AWS's own Nova Sonic sample client displays. The alternative, forwarding FINAL instead, would give the exact spoken words under barge-in but arrives 2 to 13 seconds after the audio turn ended, so the text would trail the voice and the response would have to stay open past its audio
Decision, the barge-in marker: when the user talks over the assistant, Nova Sonic sends
{ "interrupted" : true }as a FINAL text block, so it is dropped with the rest of that stage. Before this PR it reached the client as aresponse.text.done, a JSON string in the transcript; clients learn about barge-in frominput_audio_buffer.speech_started, which the bridge still emits at the same instant. The alternative, forwarding that one block, keeps that string as an assistant sentenceUser Flow
Before: a voice chat app on the gateway hears Nova 2 Sonic's answer once but renders every sentence of it twice, the second copy landing seconds after the voice stopped
After: the same app renders each sentence once, inside the one response that carries the audio
Linear ticket
Resolves LIT-8997
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Last updated: 6451016. QA and /live-pr-risk ran on 07107e5, the last commit with a product diff; 48cd6d4 and 6451016 only retype, tag, and extend the tests. /audit round 1 ran on 6451016 (same product code as 07107e5) against the merge base e98bbc2
Shared setup: one proxy process per leg, booted from the named commit with
--num_workers 2(two uvicorn workers) on a random free port, a Postgres DB,store_model_in_db: true, AWS keys for us-east-1 in its env, and this config. Both legs ran concurrently with the same client and the same flowThe client is a 118-line websocket script (nova_sonic_realtime_client.py) that does what a voice chat app does: session.update with modalities text and audio, then a spoken "what is the weather in paris?" (the repo's
tests/e2e/llm_translation/realtime/fixtures/weather_question_24k.wav) as 100 ms PCM16 16 kHz input_audio_buffer.append chunks followed by silence until 15 s pass with no event. It prints every non-audio event with its arrival time, then the counts. Real Nova 2 Sonic calls, real spendBefore (e98bbc2, the merge base)
One spoken turn over ws://127.0.0.1:31137/v1/realtime?model=nova-2-sonic
python nova_sonic_realtime_client.py 31137 "$LITELLM_MASTER_KEY" nova-2-sonic events.jsonlThe e2e test in this PR against that proxy
LITELLM_PROXY_URL=http://127.0.0.1:31137 uv run --group e2e-dev pytest tests/e2e/llm_translation/realtime/test_realtime_bedrock_duplicate_text_e2e.py -vAfter (07107e5, the last commit with a product diff)
One spoken turn over ws://127.0.0.1:29927/v1/realtime?model=nova-2-sonic
python nova_sonic_realtime_client.py 29927 "$LITELLM_MASTER_KEY" nova-2-sonic events.jsonlThe e2e test in this PR against that proxy
LITELLM_PROXY_URL=http://127.0.0.1:29927 uv run --group e2e-dev pytest tests/e2e/llm_translation/realtime/test_realtime_bedrock_duplicate_text_e2e.py -vObservations from the run
/live-pr-risk, 07107e5 against the merge base e98bbc2
Verdict: no dependent path breaks; one backward-incompatible observable, decided in the TLDR. Same two-worker proxies as the QA above (before on port 31137 from the merge-base worktree, after on port 29927 from this branch), the same websocket client, both scenarios run on both legs at the same time. The after proxy's debug log shows 19
Dropping FINAL ASSISTANT text blocklines over these runs, the before log noneBreaking
None. Per spoken session, counted from the client's event stream:
Barge-in: the client speaks the question again 0.8 s after the first
response.audio.delta. Both legs emitinput_audio_buffer.speech_startedat the moment the mic audio resumes, and the answer continues inside the same response id on both. Second turn: the client speaks again 3 s after the firstresponse.done; before, the first turn's FINAL blocks were still arriving and opened phantom responses, one of which swallowed the second turn's first sentence into its own response idSpend rows (
GET /spend/logs, one row per session on both legs):completion_tokensequals the sum of theresponse.doneusage the client saw. Before: 605 and 722 (the phantomresponse.doneevents carried zero usage and added nothing). After: 495 and 702. Same rule, same row shape, samecall_typeBackward incompatible
{ "interrupted" : true }as a FINAL text block whosecontentEndcarriesstopReason: INTERRUPTED(both proxies' debug logs show that block). Before: forwarded as aresponse.text.donewith that JSON string as the text. After: dropped with the FINAL stage, whileinput_audio_buffer.speech_startedstill reaches the client at the same instant. Decision recorded in the TLDR, shipped with this PR's approval; the unit testtest_barge_in_marker_in_final_block_is_dropped_and_the_next_answer_still_flowspins itRegression risk
response.doneon both legs, never a cancelled status; pre-existing, both legs identical, out of scopeDependency graph
transform_content_start_event,transform_text_output_event,transform_content_end_event: one implementer, one caller (transform_realtime_response), no subclass, no string dispatch; verified live on both scenarios and the QA turn_parse_generation_stage: already used by the USER transcript path, whose unit tests are unchanged and pass; verified live (conversation.item.input_audio_transcription.completedonce per spoken question on both legs)_assistant_final_text_active: per-session attribute onBedrockRealtimeConfig, which the handler instantiates per websocket session; verified live across two turns in one session and across two workersresponse.doneusage); verified live through the spend rows abovelitellm_core_utils/realtime_streaming.py: the path every event above crossed; verified livelitellm/,enterprise/,tests/,ui/for the method names, the attribute, the stage strings, and theinterruptedmarker: no other reference of any kindNot verified
amazon.nova-sonic-v1:0, the first-generation model: not driven (Caveats)origin/mainis two commits past the merge base (8adb8f3, an Azure deprecation date; 62dee3d, Claude Code gateway refresh tokens). The merge is clean and neither touches a realtime module, so the merged tree's realtime code is byte-identical to this tip's; no merged-tree rerun/audit round 1 of 3, 6451016 against the merge base e98bbc2
Verdict PASS: every inventory row verified, no row deferred, no mock inside the proxy. Head 6451016 (this tip, product diff unchanged since 07107e5), merge base e98bbc2 (
git merge-base origin/main 64510165e61), each booted from its own worktree with--num_workers 2on its own port against its own Postgres database, Redis and scripted upstream, every worktree's.envmoved out before the boot (zeroEnvironment variables loaded fromlines in all five proxy logs, the two chaos restarts included)Every Nova Sonic row is a live cell, run once per leg against the real
amazon.nova-2-sonic-v1:0in us-east-1, because no deterministictests/integration/cell can reach this path today: the realtime handler talks to Bedrock throughaws_sdk_bedrock_runtime's CRT client, which negotiates HTTP/2 only through TLS ALPN against the system trust store with no CA bundle or verify hook, and over plaintext HTTP/1.1 smithy buffers the whole duplex body before sending it, so a scripted local peer can never carry the bidirectional stream. That testability gap is tracked internally; no deterministic test was committed by this audit, and the live cells are not committed either. Artifacts per row under the run's scratch directory,audit/live/<sha>/:<row>_events.jsonl(every event the client received),<row>_summary.json(checks, per-response usage, close code),<row>_bedrock_log.txt(the proxy'sReceived from Bedrocklines with the vendor's completionId),<row>_spend_rows.json(the session'sLiteLLM_SpendLogsrows). Runner:audit/live/run_live.py(N1-N9) andaudit/live/chaos_live.py(N12)ValidationException: The first event must be a SessionStart event.(the runner stops reading at the error event), no spend row, next session servedValidationException: The first event must be a SessionStart event.(the runner stops reading at the error event), no spend row, next session served<run scratch>/audit/live/<sha>/N1_*<run scratch>/audit/live/<sha>/N2_*<run scratch>/audit/live/<sha>/N3_*<run scratch>/audit/live/<sha>/N4_*<run scratch>/audit/live/<sha>/N5_*<run scratch>/audit/live/<sha>/N6_*litellm.BadRequestError: LLM Provider NOT provided. Pass in the LLM provider you are tryin(the runner stops reading at the error event), no spend row, next session servedlitellm.BadRequestError: LLM Provider NOT provided. Pass in the LLM provider you are tryin(the runner stops reading at the error event), no spend row, next session served<run scratch>/audit/live/<sha>/N7_*Unknown error for operation com.amazonaws.bedrockruntime#InvokeModelWithBidirectionalStrea(the runner stops reading at the error event), no spend row, next session servedUnknown error for operation com.amazonaws.bedrockruntime#InvokeModelWithBidirectionalStrea(the runner stops reading at the error event), no spend row, next session served<run scratch>/audit/live/<sha>/N8_*<run scratch>/audit/live/<sha>/N9_*<run scratch>/audit/live/<sha>/N12_summary.json,N12-after-*Reading the matrix: on every audio turn the merge base delivers each sentence twice, opens one response more than it closes (
created/done 3/2,5/4) and on N4 and N5 forwards the barge-in marker{ "interrupted" : true }as text; the head delivers each sentence once, closes every response it opens and forwards no marker. N6 (a text item turn) repeats nothing on the base either, since the FINAL block Nova sent on that path carried no text (the inventory allowed either outcome on the base). On every served session, on both legs, the spend row'scompletion_tokensequals the sum of theresponse.doneoutput_tokensthe client saw,prompt_tokensthe sum ofinput_tokens, andspendis above zero (thespend_rowcheck). N1, N7 and N8 are inherited failure shapes identical on both legs and outside this diff (Caveats). N10 (stage-less blocks, the USER transcript and audio, forwarded) is thetranscription_completedandaudio_presentcheck on N2-N6, N9 and every follow-up session; N11 (one spend row per session with matching usage) is thespend_rowcheck on the same rowsN9: four concurrent sessions, each one response with no repeat on the head, four spend rows with matching usage, and
lsofduring the burst showed both uvicorn workers holding a Bedrock socket on both legs. N12: SIGKILL of the worker holding a session closed the client's socket 0.02 s later with code 1006 on both legs, uvicorn respawned the worker and the next session was served (no repeat on the head, repeats on the base); a SIGTERM restart of the whole proxy had the listener gone in 0.1 s and ready again in 48.2 s (head) and 61.2 s (base), next session servedControls, the realtime integration cells, run once on the head on a third rig (stock
tests/integration/proxy_config.yaml, scripted upstream,uv run --no-sync python tests/integration/run.py providers <13 node ids> --seed 20261006 --order-seed 20261006,INTEGRATION_PROXY_READY_SECONDS=240, serial as CircleCI runs the group): 13 passed in 121 s, lane log ataudit/results/ctl/lane.log, junit ataudit/results/ctl/junit.xmltests/integration/providers/test_openai_realtime_transcription_url.py::test_openai_sdk_async_transcription_session_reaches_a_custom_api_basetests/integration/providers/test_openai_realtime_transcription_url.py::test_openai_sdk_sync_transcription_session_reaches_a_custom_api_basetests/integration/providers/test_openai_realtime_transcription_url.py::test_conversation_session_forwards_only_model_and_bills_the_scripted_usagetests/integration/providers/test_openai_realtime_transcription_url.py::test_upstream_outage_closes_every_open_transcription_session_and_recoverstests/integration/providers/test_openai_realtime_transcription_url.py::test_repeated_conversation_sessions_write_one_spend_row_eachtests/integration/providers/test_openai_realtime_transcription_url.py::test_concurrent_transcription_sessions_each_reach_the_upstream_with_model_and_intenttests/integration/providers/test_openai_realtime_transcription_url.py::test_worker_kill_then_proxy_restart_keep_transcription_sessions_servingtests/integration/providers/test_bedrock_gpt_responses_native_wire.py::test_async_openai_sdk_responses_stream_is_served_by_the_native_responses_routetests/integration/messages_endpoint/providers/bedrock/test_bedrock_messages_gpt_chat_completions_wire.py::test_async_anthropic_sdk_disabled_thinking_reaches_native_chat_completions_as_effort_nonetests/integration/messages_endpoint/providers/bedrock/test_bedrock_messages_gpt_chat_completions_wire.py::test_anthropic_sdk_stream_with_thinking_budget_is_served_by_native_chat_completionstests/integration/providers/test_bedrock_runtime_chat_completions_wire.py::test_openai_sdk_reasoning_request_is_served_by_native_chat_completionstests/integration/providers/test_bedrock_runtime_chat_completions_wire.py::test_sigv4_deployment_signs_the_native_requesttests/integration/providers/test_bedrock_runtime_chat_completions_wire.py::test_async_openai_sdk_stream_keeps_the_upstream_id_and_usageRun notes: the first controls run failed 10 of 13 on the rig, not the product (the owned Postgres cluster was initialized without
-E UTF8, so its databases were SQL_ASCII and psycopg returned bytes for text columns, which the harness's spend-row model rejects); the controls database was recreated as UTF8, the proxy rebooted and the lane rerun above passed 13 of 13. The first live attempt hit the same bytes problem inside the runner after two rows and was discarded (attempt1_runnerbug/); the matrix above is the second attempt, run end to end on both legs. Both live proxies were restarted by the N12 chaos step and served the rows that followedType
🐛 Bug Fix
Caveats (if any)
Low
{ "interrupted" : true }text block no longer reaches the client (decision in the TLDR);input_audio_buffer.speech_startedstill does at the same instant. Left as is: forwarding that one FINAL block puts a JSON string in the transcript, which is what every client saw before this PRsession.updategets Bedrock's ownValidationException: The first event must be a SessionStart event.as anerrorevent and the session ends, because the Bedrock realtime handler never sends the default session configuration the way the generic realtime path does. Left as is: tracked internally as its own fix, not part of the duplicate-text changecustom_llm_provider: bedrockand a model id Bedrock does not know (amazon.nova-2-sonic-v9:0) fails the session withlitellm.BadRequestError: LLM Provider NOT providedinstead of a message naming the unknown model. Left as is: tracked internally as its own fixQA runbook
store_model_in_db: true; POST /model/new with the master key,model_nameof your choice,litellm_params.model: bedrock/amazon.nova-2-sonic-v1:0,aws_region_name: os.environ/AWS_REGION, andmodel_info.mode: realtime{"type":"session.update","session":{"modalities":["text","audio"],"instructions":"You are a friendly assistant. Answer in two or three short sentences."}}, wait for session.updatedinput_audio_buffer.appendchunks of 100 ms, then keep sending 100 ms of silence every 100 ms until no event has arrived for 15 stextof every response.text.done: at least one sentence, and no sentence more than onceFinal Attestation
The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
07107e5 passes /live-pr-risk
6451016 passes /audit (round 1 of 3, PASS, no unverified rows)
Note
Medium Risk
Changes Bedrock realtime event translation with a backward-incompatible drop of FINAL assistant text (including the barge-in JSON marker); scope is Nova Sonic’s generationStage pattern but affects all consumers of
/v1/realtimefor that model.Overview
Fixes duplicate assistant chat bubbles on Bedrock Nova Sonic realtime by not forwarding ASSISTANT
TEXTblocks whosecontentStartcarriesgenerationStage: FINAL. Nova sends each sentence twice (SPECULATIVE with audio, then FINAL after the turn); the bridge now keeps only SPECULATIVE text inside the singleresponse.created/response.donelifecycle and suppresses matchingtextOutput/contentEndvia_assistant_final_text_active.Tests: unit scenarios for FINAL replay, post–
END_TURNFINAL, barge-in{ "interrupted" : true }FINAL drops, and a live e2e that streams mic audio through newRealtimeSession.stream_and_collectand asserts eachresponse.text.donesentence appears once.Observable change: clients no longer receive the FINAL-stage barge-in JSON as
response.text.done(speech boundaries unchanged).Reviewed by Cursor Bugbot for commit 6451016. Bugbot is set up for automated code reviews on this repo. Configure here.