Skip to content

feat(gateway): realtime voice sessions on Bud deployments — /v1/realtime relay + translate engine, /ws legs (FRD-023) - #12

Merged
dittops merged 19 commits into
mainfrom
feat/realtime-translate-rt7
Sep 28, 2026
Merged

dittops merged 19 commits into
mainfrom
feat/realtime-translate-rt7

Conversation

@dittops

@dittops dittops commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Scope

FRD-023 (realtime voice sessions) on the WaaV side. Spec, contracts and live-test record: specs/023-realtime-voice-sessions/ in bud-runtime.

  • RT0 — no platform vendor keys on Bud-mode socket paths. Every vendor credential comes from the Bud deployment (voice_table); a process *_API_KEY is never used for a Bud session.
  • /v1/realtime — OpenAI Realtime GA on Bud deployments (RT2/RT3/RT5).
    • Relay engine: OpenAI, Azure OpenAI and xAI.
    • Deployment defaults (voice, instructions, turn detection, input transcription, noise reduction, speed, max output tokens, output modalities) are sent as one GA session.update.
    • Policy and limits are enforced (MCP tools and stored prompts refused by default; max session length; idle timeout), with FRD-022 admission and 30 s revalidation.
    • Metering emits voice.turn and voice.session spans.
    • ek_bud_ client secrets are sealed with XChaCha20-Poly1305.
    • A refused upgrade answers with OpenAI's error envelope.
  • Translate engine (RT7): a GA facade over native providers — Gemini Live, Nova 2 Sonic, and the per-minute agents (Deepgram Voice Agent, ElevenLabs Agents, Hume EVI) — with duration metering. A per-minute vendor with no price is recorded as unpriced (never dropped).
  • /ws on Bud deployments (RT6): the STT, TTS and LLM legs address Bud deployments with the caller's credential (the LLM leg goes through budgateway), and DAG templates bind to deployments.
  • Fixes found live:
    • TCP_NODELAY on accepted sockets (≈20 ms Nagle stall per frame).
    • A connection-slot deadlock with DEBUG logging.
    • Sentences sent as separate finals (Deepgram/Hume/Nova) are now spaced.
    • Turn detection "none" is sent as null. OpenAI rejects {"type":"none"}, which dropped the whole defaults update.

Companion PRs: BudEcosystem/bud-runtime#3078 (budapp, budmetrics, frontends, chart, spec), BudEcosystem/bud-connect#42 (catalog).

Linked issues

None filed; tracked by FRD-023.

Migration steps

None in WaaV. Deploy order: bud-runtime's budapp release N (enum readers) → this WaaV → budapp N+1 (writers) → bud-connect's catalog PR.

Testing

cargo test --no-default-features --features dag-routing,turn-ensemble,noise-filter,openapi --lib -- openai_realtime core::realtime   # 470 passed
cargo test ... --test openai_realtime_relay --test realtime_translate --test openai_realtime_integration                           # 41 + 8 pass; 1 pre-existing registry failure below
cargo clippy ... --all-targets     # clean except a pre-existing clippy-1.96 `manual Option::zip` in stt/iflytek/client.rs
cargo fmt --check

Live on pde-ditto (image waav:rt023-9):

  • relay 53/53 (including drain);
  • /ws 24/24;
  • RT7 with a real Deepgram Voice Agent 13/13.

Against a real OpenAI gpt-realtime-2.1-mini deployment, every deployment setting was verified (26/26): OpenAI echoes each value and the behaviour follows. Policy and limits were verified 11/11.

Known pre-existing failures, unrelated to this PR:

  • test_openai_in_supported_realtime_providers still expects speechmatics;
  • the ONNX tests.

Callouts

  • Secret: new optional WAAV_CLIENT_SECRET_KEYS (kid:base64(32 bytes) pairs, first seals). It comes from the chart's SOPS-managed Secret; unset means POST /v1/realtime/client_secrets answers 501.
  • IAM: Nova Sonic signs with the deployment's own AWS key pair (no instance role).
  • Dapr: none.
  • Unverified live: Gemini, Nova Sonic and xAI were tested against protocol mocks only (no keys). xAI's docs place voice/turn_detection at the top of session and call the language language_hint, while the relay sends GA placement.

🤖 Generated with Claude Code

dittops and others added 18 commits September 28, 2026 01:44
…ve-session reach checks

FRD-023 WP-RT2.3 and Q-7.

- voice_table config.realtime parses into RealtimeSettings {session_type, defaults, limits,
  policy}, leniently: a malformed block or field is dropped with a warning, never the endpoint.
  RealtimePolicy's accessors apply the secure defaults (no MCP tools, no stored prompts).
- VoicePricing.rates: a realtime token price is its per-modality rates; a token price without
  rates is refused (served unpriced) rather than priced at zero. Keys are the closed
  REALTIME_RATE_KEYS set.
- Principal.expires_at carries a JWT caller's verified exp (client-secret lifetime cap).
- BudPlane::hash_reaches / subject_reaches / subject_aliases: the revalidation a live realtime
  session and an ek_bud_ parent make, by snapshot hash or JWT subject.
- user_projects:{sub} events evict that subject's cached grants and project_models:* events
  clear them all, so JWT revocation reaches live sessions without waiting OIDC_AUTHZ_TTL_SECS.
- tests/fixtures/realtime_voice_entry.json is byte-identical to budapp's publisher fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…3 RT0)

Under the Bud control plane every vendor credential comes from the deployment (voice_table).
Four socket paths still reached for the PROCESS's keys, latent only because the Bud chart sets
none; the first operator to add one to "make realtime work" would have exposed it:

- X-1 /ws conversation_config: a client-chosen base_url with api_key omitted sent the
  platform's OPENAI_API_KEY to that host. base_url, api_key, reasoning_base_url and
  reasoning_api_key are now refused in Bud mode, and LlmClient never falls back to the
  environment (neither ${VAR} nor the default env var) while the process is in Bud mode.
- X-2 /ws DAG: an inline dag_config.definition is refused, and a DAG node's own credential
  (literal or ${VAR}) is refused in Bud mode.
- X-3 native /realtime: a config message is refused with deployment_required; Bud deployments
  are served by /v1/realtime?model=<deployment>.
- X-4 /ws STT/TTS legs: a provider-only leg no longer falls back to config.get_api_key.
- F-2: a second native /realtime config is refused instead of replacing the provider without
  disconnect().

process_in_bud_mode() is set once when BudMode starts, so a code path with no AppState in reach
cannot forget the rule. Two existing tests asserted the X-4 fallback; they now assert its refusal
(TC-SEC-06). Every guard (TC-SEC-01..06, 08) was seen failing with its check removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
try_acquire_connection held its DashMap entry (the shard's WRITE lock) while the
"Connection acquired" debug! evaluated ip_connection_count(), which takes the same shard's READ
lock. The field is evaluated only when DEBUG is enabled, so any deployment run with debug logging
froze the worker thread of the first WebSocket upgrade forever (FRD-022 connection slots). Found
by FRD-023's relay tests, whose span capture enables DEBUG.

The entry is dropped before logging, and the count comes from the fetch_add result. Regression
test acquires and releases a slot under a DEBUG subscriber with a 10 s deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…me GA (FRD-023 RT2, RT3, RT5)

wss://gateway/v1/realtime?model=<deployment> now speaks OpenAI Realtime GA, so the OpenAI SDKs,
the Agents SDKs, LiveKit and Pipecat connect by changing only the base URL and the key. WaaV's
native protocol stays on /realtime.

- Handshake (RT2.1): credentials from Authorization: Bearer, the api-key header, or the
  openai-insecure-api-key.<cred> subprotocol (the server selects `realtime`, never echoes the
  credential); ?token=, OpenAI-Beta and call_id refused; model required; no permessage-deflate.
  Pre-upgrade failures are HTTP in OpenAI's error envelope.
- Resolution and admission (RT2.2): the deployment resolves through the caller's allowlist as on
  REST; FRD-022 admission is taken once and its concurrency slot held for the session; an open
  breaker refuses before connecting.
- Upstream (RT2.4): URL, auth header and model only from voice_table — OpenAI
  wss://api.openai.com/v1/realtime (or api_base, SSRF-validated), Azure GA
  wss://<resource>/openai/v1/realtime with api-key and no api-version; transcription deployments
  connect with intent=transcription. Connect deadline 10 s.
- Relay and policy (RT2.5): two-stage parse, audio frames forwarded byte-for-byte; session.model
  and tracing stripped; MCP tools, stored prompts, image input, instruction overrides, session
  type changes and off-list transcription models refused per the deployment's policy with
  event_not_allowed (session continues); max_output_tokens clamped; vendor rate_limits.updated
  dropped; session.model rewritten to the deployment name. The deployment's defaults are sent
  first and client frames held until the vendor applies them (5 s). A client that stops reading
  is closed 1011 client_too_slow after 5 s — audio is never dropped silently.
- Lifecycle (RT2.6): pings both legs every 20 s (3 missed → 1011), idle and maximum-length
  limits with a 60 s warning, revalidation every 30 s and before each response.create (key
  revoked, JWT subject removed from the project, endpoint unpublished → session_revoked + 1008),
  drain → server_shutdown + 1012. JWT expiry alone does not end a started session.
- Metering (RT3.1): a voice.turn per response.done, per input transcription and per 60 s
  duration segment under a minute/second price — each the ROOT of its own trace with a link to the
  session's voice.session span (VoiceTurnFact coalesces on TraceId). Per-modality token cost with
  cached tokens subtracted from their class; a component without a rate is named in
  bud.voice.unpriced_components, never priced at zero. New attributes are declared in
  voice_turn_span!/voice_session_span! and match budmetrics' contract byte for byte.
- Client secrets (RT5.1): POST /v1/realtime/client_secrets mints a stateless ek_bud_ secret sealed
  with XChaCha20-Poly1305 (claims opaque to the holder), lifetime capped by a JWT parent's exp,
  parent = the full snapshot hash; connect revalidates the parent. Keys from
  WAAV_CLIENT_SECRET_KEYS (first seals, all open); a bad key fails startup; none → 501.
- Metrics (RT2.7): waav_realtime_sessions_active/_total, relay latency, policy refusals, unknown
  client events.

Tests: lib units per module and tests/openai_realtime_relay.rs (39 cases against a mock GA vendor:
TC-HS, TC-UP, TC-EVT, TC-LIFE, TC-MET, TC-EK, TC-SEC-07).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two let-else blocks become ? and to_vendor becomes send_to_vendor (a to_*
method taking &mut self trips wrong_self_convention under -D warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under the Bud control plane a /ws session's legs are deployments, resolved
through the caller's own allowlist, exactly as the REST routes resolve them.

- STT/TTS legs (WP-RT6.1): stt_config.model / tts_config.model name a
  transcription / text-to-speech deployment; vendor, model, credential,
  api_base, voice and settings come from voice_table (request > deployment).
  provider is optional and ignored. Refused by name: provider-only legs
  (deployment_required), unreachable names (model_not_found), upload-only STT
  (unsupported_deployment), deployments that cannot reach their vendor
  (deployment_misconfigured, e.g. AWS without its key pair), and a client's
  own api_key (client_key_not_accepted). Client extras are replaced by the
  deployment's: Groq and Azure Speech read a destination from them.
- One admission per leg deployment, held for the session; a capped leg
  closes 1013 and releases the other leg (MessageRoute::CloseWith).
- Voice agent LLM leg (WP-RT6.2): conversation_config.model names a Bud chat
  deployment reached through WAAV_LLM_BASE_URL with the caller's credential,
  read per call; an auth message on an authenticated session refreshes it
  (same API key or user only); an expired credential fails the turn with
  auth_expired and the session stays open. Conversation turns are attributed.
- DAG templates (WP-RT6.3): TTS nodes bind to deployments (admitted, metered,
  revalidated); LLM/translate nodes go to the Bud gateway as the caller;
  realtime nodes are refused until RT7.
- stt.streaming (WP-RT6.4) is modelled in bud-auth and applied on /ws only.
- Metering and revocation (WP-RT6.5): a voice.turn per vendor final (audio
  seconds since the previous one, the remainder at close) and per speak
  (characters), fully attributed; revalidation every 30 s closes 1008.

Tests: TC-WS-01..13 (lib + conversation_loop); every guard seen red with its
check removed. Live: 21/21 on pde-ditto against Deepgram, ElevenLabs and a
chat deployment through budgateway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrent defaults (FRD-023 F-1, F-3, F-4)

F-1: OpenAIRealtimeModel was a closed enum whose parser turned every id it
did not list into `gpt-realtime`, so gpt-realtime-1.5, -2.1 and -2.1-mini
silently became a model that shuts down on 2027-01-20. It is now a string
carried verbatim; only an empty id takes the default (TC-XL-08).

F-3: the native `/realtime` session update dropped tools and turn
detection, and the scaffold's update_session replaced the whole config, so
the key and the server-set endpoint override were lost and the next
reconnect dialled without a key. The update now carries both and merges.

F-4: Gemini Live defaulted to gemini-2.0-flash-live-001 (shut down
2025-12-09) and Nova Sonic to v1 (EOL 2026-09-14): now gemini-3.8-live and
amazon.nova-2-sonic-v1:0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…7.3)

`grok` joins the relay vendors: upstream wss://api.x.ai/v1/realtime?model=
with the deployment's key as Bearer; an api_base is SSRF-validated like
OpenAI's. xAI opens its session with `conversation.created` and never sends
`session.created`, so that event now starts the session (the deployment
defaults are applied once) and the client is given a GA `session.created`.
Cumulative `…input_audio_transcription.updated` events are forwarded
verbatim, and nothing waits on `rate_limits.updated` (TC-XL-06).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…velope

The per-IP and global connection limits answered a plain-text 429/503 with
no Retry-After. On /v1/realtime the client is an OpenAI SDK, which reads
error.code and backs off on the header, so those refusals now use the relay's
own envelope (rate_limit_exceeded / server_at_capacity, Retry-After: 1);
native routes keep their text body and gain the header. Found by the
TC-COMPAT run under concurrent load (FRD-023 §5.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on plan, sign Nova with deployment keys (FRD-023 RT7.0-RT7.2)

The S2S scaffold gains what the translate engine needs:

- S2sEvent::Usage (per-modality tokens or seconds; cumulative or delta),
  ItemAdded, ItemDone and GoAway, and a raw event tap
  (BaseRealtime::on_event) that sees every normalized event in wire order.
- Planned reconnects: a vendor goAway, or a connection cap (Nova Sonic's
  8 minutes, replaced 30 s early), replaces the connection at the next turn
  boundary with no backoff and without counting as a failure; the
  resumption handle is carried. The reconnect is announced only once the
  session is ready again.
- Per-protocol input sample rates (Gemini, Nova, ElevenLabs 16 kHz; Hume
  its configured rate).
- Gemini: usageMetadata becomes one cumulative Usage, first in its
  message's events; goAway carries its deadline.
- Nova 2 Sonic: usageEvent becomes one delta Usage (a totals-only report is
  billed as the difference, per connection); the assistant audio block is
  an item; history is replayed as text blocks on a new stream.
- The Bedrock factory can sign with a deployment's static key pair and
  nothing else (no environment, shared config or instance identity), and
  in Bud mode refuses to dial without one. The stream now opens in the
  background: the SDK's send() returns only at the first output event,
  which Nova sends only after input, so awaiting it in connect deadlocked
  until the dial timeout.
- bud-auth keeps provider_params.region for vendor nova_sonic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ova 2 Sonic and per-minute agents (FRD-023 RT7.0, RT7.1, RT7.2, RT7.4)

A deployment whose vendor has no OpenAI Realtime GA surface (CONTRACTS C7:
gemini, nova_sonic, deepgram_voice_agent, elevenlabs_convai, hume_evi) is
served by WaaV's native provider behind a GA facade
(handlers/openai_realtime/facade.rs). Everything around the session stays
the relay's: authentication and ek_bud_ secrets, resolution, the admission
held for the session, revalidation, idle and maximum-length limits,
pings, the 1012 drain, the session span and the client-event policy.

- The vendor leg comes from voice_table only: the credential (Nova: the
  key pair in credential_parts and provider_params.region, refused without
  either), the model, and an api_base that is converted to ws(s) and
  SSRF-validated. A provider that cannot be built from the entry is
  refused before the upgrade (502 deployment_misconfigured). The vendors
  serve speech-to-speech sessions only.
- The vendor setup is deferred to the client's first session.update, so
  the one opening message these vendors accept carries the client's voice,
  instructions, tools and turn detection over the deployment's defaults;
  afterwards a change to any of them is refused with event_not_allowed
  naming the field (the same value again is accepted).
- GA in: session.update, input_audio_buffer.append (24 kHz resampled to
  the vendor's rate)/commit/clear, conversation.item.create (user text,
  function_call_output), response.create/cancel. Refused by name:
  conversation.item.truncate/retrieve/delete, output_audio_buffer.clear,
  audio and image content, MCP tools, stored prompts, out-of-band
  responses, unknown events. GA out: response.created ... audio and
  transcript deltas (resampled to 24 kHz; WAV chunks unwrapped) ...
  function calls ... response.done with a usage the gateway computed.
- Metering: a vendor usage report is the usage of the response it
  belongs to and exactly one voice.turn; a report outside a response is
  billed alone; a response cut off by close keeps its usage. Per-minute
  vendors bill duration segments from the moment the vendor connection
  opens, through the relay's segment clock (now a shared SegmentClock).
- A vendor that reconnects (goAway, the Nova cap) is invisible to the
  client: vendor-bound work waits out the gap.

Tests (in-process mocks of each wire protocol, including a Bedrock
event-stream connector): TC-XL-01...05 and 07, plus xAI priced per minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the /v1/realtime connection-limit envelope fix (548618c) onto RT7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-01)

WaaV never set TCP_NODELAY on the sockets it accepts. A realtime client
streams 20 ms frames and reads small events back; with Nagle on, each relayed
write waited for the client's next frame to acknowledge the previous one,
adding a frame interval to every frame: TC-PERF-01 measured p50 19.5 ms /
p99 20.4 ms added against a 5 ms budget. The vendor sockets already set it.

server::nodelay_listener (axum::serve) and server::tls_server (axum-server's
NoDelayAcceptor under rustls) now serve every route; main.rs uses both.

Tests: server::accepted_sockets_set_tcp_nodelay; the TC-PERF-01 harness
(tests/openai_realtime_perf.rs, #[ignore]) now serves through the production
listener: p50 0.37 ms / p99 0.70 ms added (debug build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ration price

A Deepgram Voice Agent deployment published with no price held a 72 s session
on pde-ditto and left no usage record: the facade only segmented a per-minute
vendor when the deployment carried a minute/second price. The vendor bills by
time whatever Bud charges, so per-minute vendors always meter duration
segments; a segment with no minute/second price is recorded with its billed
seconds, no cost, and unpriced_components = "duration" (never $0).

Tests: realtime_cost an_unpriced_duration_segment_names_what_is_unpriced;
realtime_translate tc_xl_07_an_unpriced_per_minute_vendor_still_meters_its_time
(red with the old condition restored).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings TCP_NODELAY on accepted client sockets (6cd3850) onto RT7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver VAD

Every translate vendor detects turns itself unless told otherwise, but the
facade echoed an unset turn_detection as null, which a GA client reads as
push-to-talk: the playground showed a manual Send beside a Deepgram Voice
Agent session that answers on its own. Unset now reports {type: server_vad};
an explicit null still reports off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te finals

Deepgram's Voice Agent (ConversationText) and Hume's EVI (assistant_message)
send an answer as one finished sentence per message, and Nova Sonic as
separate blocks, with no whitespace between them. The facade forwarded each
verbatim as a response.output_audio_transcript.delta, so a GA client that
appends deltas showed "…culture.Iconic landmarks…", and the transcript in
response.output_audio_transcript.done read the same (seen live on a Deepgram
Voice Agent deployment).

A new segment after earlier text in the same response now gets one space,
unless either side is already whitespace or the script does not space
sentences (Chinese, Japanese). A streamed chunk continuing a segment stays
verbatim, so Gemini's word chunks are unchanged.

Also gives the ignored perf test the bedrock_http_client field the RT7 merge
added, so --all-targets compiles again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bud stores turn detection off as {"type":"none"}. The relay forwarded it as-is
in the defaults session.update, and OpenAI GA refuses the type ("Invalid
value: 'none'. Supported values are: 'server_vad' and 'semantic_vad'", seen
live on gpt-realtime-2.1-mini) -- which drops the whole update, so a
deployment set to "None" silently lost its voice, instructions and every other
default and ran on server VAD. GA turns detection off with null; send that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…out, openapi regenerated

CI on #12:
- bud_legs::bind_dag used crate::dag unconditionally, so every build without
  the dag-routing feature (the default matrix entry, server boot, VAD
  accuracy, openapi drift) failed to compile. It and its DAG-template tests
  are gated on dag-routing, as its caller already was.
- The /ws leg tests read bud-auth's git-ignored fixture key, which a clean
  checkout does not have (26 failures). test_support now generates a key pair
  once per test binary and encrypts the test credential to it the way budapp
  does (RSA-OAEP-SHA-256, hex).
- docs/openapi.yaml regenerated for RT6's /ws config (provider and base_url
  optional under the Bud control plane).

Default features: 6865 lib tests pass; production features: all but the four
ONNX tests that need the runtime library (green in CI); openapi drift passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dittops
dittops merged commit 7ca5496 into main Sep 28, 2026
16 checks passed
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.

1 participant