litellm/tests/integration/_support/bedrock_realtime_peer.py
Mateo Wang 3ef0b35255
fix(bedrock)!: block realtime replay after an input-first start and reject malformed Nova Sonic session settings (#46936)
* fix(bedrock): block realtime replay after input starts the session and reject non-string instructions and voice

* test(bedrock): name the malformed realtime setting test for its string cases

* fix(bedrock): replay the opening realtime message until Bedrock accepts the stream and refuse any fallback after commit

Also rejects a malformed modalities, input_sample_rate_hertz or tools setting with invalid_type, dropped under drop_params.

* fix(bedrock): replay a refused opening message on any fallback and reject OpenAI voices and non-string audio formats

* fix(realtime): read the client scope only when the websocket has one and reject any-case OpenAI and empty Bedrock voices

* test(bedrock): cover realtime replay, refusal and committed failure through a real proxy

Fix the scripted Bedrock peer's port import and the nested tool call cell's stale expected events, so the Nova Sonic integration file collects again
2026-10-17 00:29:50 +00:00

537 lines
20 KiB
Python

"""A scripted Bedrock Nova Sonic realtime peer.
The handler dials ``api_base`` with ``invoke_model_with_bidirectional_stream`` through
``AWSCRTHTTPClient``, which only speaks h2 over TLS ALPN, so this peer is a loopback TLS
server offering ``h2`` on ``async_server.LoopbackServer``'s thread-owned asyncio loop.
It answers ``POST /model/{id}/invoke-with-bidirectional-stream``, decodes the client's
signed event-stream chunks into the ``{"event": {...}}`` documents Bedrock would read,
and answers each consumed ``Turn`` with event-stream ``chunk`` frames carrying
``{"bytes": <base64 event json>}`` payloads, the reverse of what the SDK produces.
Turn triggers are inbound events. ``user_input`` fires on the ``contentEnd`` closing the
first inbound USER-role TEXT content block, the end of a forwarded conversation.item.create
text message (the injected trigger-audio block is USER/AUDIO, so it never trips it).
``tool_result`` fires on the Nth inbound ``toolResult`` event, which the proxy sends inside
a TOOL contentStart/toolResult/contentEnd triple whose ``toolResultInputConfiguration``
``toolUseId`` names the call being answered.
A ``Refusal`` peer withholds each stream's initial response until that stream's opening arrived
(its SYSTEM prompt block or its first USER text block closed), then answers the HTTP error Bedrock
sends for an unknown model id. A ``StreamFailure`` peer accepts the stream, plays its turns, and
then sends an event-stream exception frame and ends the stream, as Bedrock does for a mid-call
model error. ``streams()`` groups the inbound events per opened stream.
"""
from __future__ import annotations
import asyncio
import base64
import json
import struct
import uuid
from collections.abc import Iterator, Mapping
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from itertools import chain, count
from pathlib import Path
from queue import SimpleQueue
from typing import Final, Literal, TypeVar
import h2.config
import h2.connection
import h2.events
import h2.exceptions
from integration._support.async_server import Handler, LoopbackServer
from integration._support.ports import allocate_port
from integration._support.tls import server_context
from integration._support.upstream import aws_event_stream_frame
from pydantic import JsonValue, TypeAdapter
BedrockEvent = Mapping[str, JsonValue]
OpeningEnd = Literal["system_prompt", "user_input"]
Recorded = TypeVar("Recorded")
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
def _ids() -> tuple[str, str, str, str]:
return (uuid.uuid4().hex, uuid.uuid4().hex, uuid.uuid4().hex, uuid.uuid4().hex)
def _block(prompt: str, content: str, session: str, completion: str) -> dict[str, JsonValue]:
return {"promptName": prompt, "contentName": content, "sessionId": session, "completionId": completion}
def user_transcript(text: str) -> tuple[BedrockEvent, ...]:
"""The USER ASR transcript block Bedrock emits once it has consumed the user turn."""
prompt, content, session, completion = _ids()
return (
{
"event": {
"contentStart": {
**_block(prompt, content, session, completion),
"role": "USER",
"type": "TEXT",
"interactive": False,
"textOutputConfiguration": {"mediaType": "text/plain"},
"additionalModelFields": json.dumps({"generationStage": "FINAL"}),
}
}
},
{"event": {"textOutput": {**_block(prompt, content, session, completion), "role": "USER", "content": text}}},
{
"event": {
"contentEnd": {
**_block(prompt, content, session, completion),
"stopReason": "PARTIAL_TURN",
"type": "TEXT",
}
}
},
)
def tool_use(tool_use_id: str, name: str, content: str | None) -> tuple[BedrockEvent, ...]:
"""The role-TOOL contentStart/toolUse/contentEnd triple Bedrock emits per tool call."""
prompt, content_name, session, completion = _ids()
tool_use_body: Final[dict[str, JsonValue]] = {
**_block(prompt, content_name, session, completion),
"role": "TOOL",
"toolName": name,
"toolUseId": tool_use_id,
}
if content is not None:
tool_use_body["content"] = content
return (
{
"event": {
"contentStart": {
**_block(prompt, content_name, session, completion),
"role": "TOOL",
"type": "TOOL",
"toolUseOutputConfiguration": {"mediaType": "application/json"},
}
}
},
{"event": {"toolUse": tool_use_body}},
{
"event": {
"contentEnd": {
**_block(prompt, content_name, session, completion),
"stopReason": "TOOL_USE",
"type": "TOOL",
}
}
},
)
def assistant_text(text: str, stop_reason: str = "END_TURN") -> tuple[BedrockEvent, ...]:
"""An ASSISTANT text block (SPECULATIVE stage) closed with `stop_reason`."""
prompt, content, session, completion = _ids()
return (
{
"event": {
"contentStart": {
**_block(prompt, content, session, completion),
"role": "ASSISTANT",
"type": "TEXT",
"interactive": False,
"textOutputConfiguration": {"mediaType": "text/plain"},
"additionalModelFields": json.dumps({"generationStage": "SPECULATIVE"}),
}
}
},
{
"event": {
"textOutput": {**_block(prompt, content, session, completion), "role": "ASSISTANT", "content": text}
}
},
{
"event": {
"contentEnd": {
**_block(prompt, content, session, completion),
"stopReason": stop_reason,
"type": "TEXT",
}
}
},
)
def usage(input_tokens: int, output_tokens: int) -> BedrockEvent:
total: Final = {
"input": {"speechTokens": 0, "textTokens": input_tokens},
"output": {"speechTokens": 0, "textTokens": output_tokens},
}
return {
"event": {
"usageEvent": {
"promptName": uuid.uuid4().hex,
"sessionId": uuid.uuid4().hex,
"completionId": uuid.uuid4().hex,
"details": {"delta": total["output"], "total": total},
"totalInputTokens": input_tokens,
"totalOutputTokens": output_tokens,
"totalTokens": input_tokens + output_tokens,
}
}
}
@dataclass(frozen=True, slots=True)
class Turn:
after: Literal["user_input", "tool_result"]
events: tuple[BedrockEvent, ...]
@dataclass(frozen=True, slots=True)
class Refusal:
after: OpeningEnd
status: int = 400
error_type: str = "ValidationException"
message: str = "The provided model identifier is invalid."
@dataclass(frozen=True, slots=True)
class StreamFailure:
exception_type: str = "modelStreamErrorException"
message: str = "The system encountered an unexpected error during processing. Try your request again."
def _event_body(event: BedrockEvent) -> tuple[str, Mapping[str, JsonValue]]:
body: Final = event["event"]
assert isinstance(body, Mapping) and len(body) == 1, event
name: Final = next(iter(body))
fields: Final = body[name]
assert isinstance(fields, Mapping), event
return name, fields
def _outbound_chunk(event: BedrockEvent) -> bytes:
wrapped: Final = json.dumps({"bytes": base64.b64encode(json.dumps(event).encode()).decode()}).encode()
return aws_event_stream_frame(
{":event-type": "chunk", ":content-type": "application/json", ":message-type": "event"}, wrapped
)
def _parse_frames(buffer: bytes) -> tuple[tuple[bytes, ...], bytes]:
"""Slice complete event-stream frames out of `buffer`; return (payloads, remainder)."""
payloads: Final[list[bytes]] = []
view = memoryview(buffer)
while len(view) >= 12:
total, header_length = struct.unpack_from("!II", view, 0)
if total > len(view):
break
payloads.append(view[12 + header_length : total - 4].tobytes())
view = view[total:]
return tuple(payloads), view.tobytes()
def _frame_payload(raw: bytes) -> bytes:
"""The payload slice of one complete framed message (prelude + headers + payload + CRC)."""
if len(raw) < 16:
return b""
_total, header_length = struct.unpack_from("!II", raw, 0)
return raw[12 + header_length : -4]
def _exception_frame(failure: StreamFailure) -> bytes:
return aws_event_stream_frame(
{
":message-type": "exception",
":exception-type": failure.exception_type,
":content-type": "application/json",
},
json.dumps({"message": failure.message}).encode(),
)
def _decode_inbound(chunk_payload: bytes) -> BedrockEvent | None:
"""One signed chunk frame's payload -> the inner chunk's {"bytes": ...} event."""
inner_payload: Final = _frame_payload(chunk_payload)
try:
wrapped: Final = _JSON_OBJECT.validate_json(inner_payload)
except ValueError:
return None
encoded: Final = wrapped.get("bytes")
if not isinstance(encoded, str):
return None
try:
return _JSON_OBJECT.validate_json(base64.b64decode(encoded))
except ValueError:
return None
class _Inbound:
"""Inbound event bookkeeping for turn triggers and accessors."""
def __init__(self) -> None:
self.events: Final = SimpleQueue[BedrockEvent]()
self.open_blocks: Final[dict[str, tuple[str, str]]] = {}
self.tool_use_ids: Final[dict[str, str]] = {}
self.tool_results_seen = 0
self.user_text_closed = False
self.system_text_closed = False
def record(self, event: BedrockEvent) -> None:
self.events.put(event)
name, fields = _event_body(event)
content_name = fields.get("contentName")
if name == "contentStart" and isinstance(content_name, str):
self.open_blocks[content_name] = (str(fields.get("role")), str(fields.get("type")))
configuration = fields.get("toolResultInputConfiguration")
if isinstance(configuration, Mapping) and isinstance(configuration.get("toolUseId"), str):
self.tool_use_ids[content_name] = configuration["toolUseId"]
elif name == "contentEnd" and isinstance(content_name, str):
role_type: Final = self.open_blocks.pop(content_name, ("", ""))
if role_type == ("USER", "TEXT"):
self.user_text_closed = True
elif role_type == ("SYSTEM", "TEXT"):
self.system_text_closed = True
elif name == "toolResult":
self.tool_results_seen += 1
class _Script:
"""Consumes turns in order as their inbound triggers arrive."""
def __init__(self, turns: tuple[Turn, ...]) -> None:
self._turns: Final = turns
self._next = 0
@property
def exhausted(self) -> bool:
return self._next >= len(self._turns)
def ready_events(self, state: _Inbound, event: BedrockEvent) -> tuple[BedrockEvent, ...]:
if self._next >= len(self._turns):
return ()
turn: Final = self._turns[self._next]
if turn.after == "user_input":
if not state.user_text_closed:
return ()
else:
name, _fields = _event_body(event)
if name != "toolResult":
return ()
self._next += 1
return turn.events
def _opening_arrived(after: OpeningEnd, inbound: _Inbound) -> bool:
match after:
case "system_prompt":
return inbound.system_text_closed
case "user_input":
return inbound.user_text_closed
class _PeerState:
def __init__(self, turns: tuple[Turn, ...], refusal: Refusal | None, failure: StreamFailure | None) -> None:
self.inbound: Final = _Inbound()
self.script: Final = _Script(turns)
self.refusal: Final = refusal
self.failure: Final = failure
self.ordinals: Final = count()
self.opened: Final = SimpleQueue[int]()
self.stream_events: Final = SimpleQueue[tuple[int, BedrockEvent]]()
def _drain(queue: SimpleQueue[Recorded], log: list[Recorded]) -> tuple[Recorded, ...]:
while not queue.empty():
log.append(queue.get()) # drained log, not a built value
return tuple(log)
@dataclass(frozen=True, slots=True)
class _Stream:
ordinal: int
inbound: _Inbound
@dataclass(frozen=True, slots=True)
class BedrockRealtimePeer:
api_base: str
_state: _PeerState
_drained: list[BedrockEvent]
_opened: list[int]
_stream_events: list[tuple[int, BedrockEvent]]
def received(self) -> tuple[BedrockEvent, ...]:
return _drain(self._state.inbound.events, self._drained)
def streams(self) -> tuple[tuple[BedrockEvent, ...], ...]:
opened: Final = _drain(self._state.opened, self._opened)
recorded: Final = _drain(self._state.stream_events, self._stream_events)
return tuple(tuple(event for ordinal, event in recorded if ordinal == stream) for stream in opened)
def tool_specs(self) -> tuple[Mapping[str, JsonValue], ...]:
return tuple(chain.from_iterable(_tool_specs(event) for event in self.received()))
def tool_results(self) -> tuple[tuple[str, str], ...]:
"""(toolUseId, content) per inbound toolResult in arrival order."""
return tuple(result for event in self.received() if (result := self._tool_result(event)) is not None)
def _tool_result(self, event: BedrockEvent) -> tuple[str, str] | None:
name, fields = _event_body(event)
if name != "toolResult":
return None
content_name: Final = fields.get("contentName")
content: Final = fields.get("content")
if not isinstance(content_name, str) or not isinstance(content, str):
return None
return (self._state.inbound.tool_use_ids.get(content_name, ""), content)
def _tool_specs(event: BedrockEvent) -> tuple[Mapping[str, JsonValue], ...]:
name, fields = _event_body(event)
if name != "promptStart":
return ()
configuration: Final = fields.get("toolConfiguration")
if not isinstance(configuration, Mapping):
return ()
tools: Final = configuration.get("tools")
if not isinstance(tools, list):
return ()
return tuple(
entry["toolSpec"]
for entry in tools
if isinstance(entry, Mapping) and isinstance(entry.get("toolSpec"), Mapping)
)
class _H2Session:
"""One bidirectional-stream connection: decode inbound, answer scripted turns."""
def __init__(self, state: _PeerState, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
self._state: Final = state
self._reader: Final = reader
self._writer: Final = writer
self._h2: Final = h2.connection.H2Connection(config=h2.config.H2Configuration(client_side=False))
self._buffers: Final[dict[int, bytes]] = {}
self._streams: Final[dict[int, _Stream]] = {}
self._ended: set[int] = set()
self._closed = False
async def run(self) -> None:
self._h2.initiate_connection()
self._writer.write(self._h2.data_to_send())
await self._writer.drain()
while True:
incoming: Final = await self._reader.read(65536)
if not incoming:
return
for event in self._h2.receive_data(incoming):
self._apply(event)
pending: Final = self._h2.data_to_send()
if pending:
self._writer.write(pending)
await self._writer.drain()
if self._closed:
self._writer.close()
return
def _apply(self, event: h2.events.Event) -> None:
if isinstance(event, h2.events.RequestReceived):
ordinal: Final = next(self._state.ordinals)
self._streams[event.stream_id] = _Stream(ordinal, _Inbound())
self._state.opened.put(ordinal)
if self._state.refusal is None:
self._h2.send_headers(
event.stream_id,
[(":status", "200"), ("content-type", "application/vnd.amazon.eventstream")],
)
elif isinstance(event, h2.events.DataReceived):
self._h2.acknowledge_received_data(event.flow_controlled_length, event.stream_id)
self._buffers[event.stream_id] = self._buffers.get(event.stream_id, b"") + event.data
payloads, remainder = _parse_frames(self._buffers[event.stream_id])
self._buffers[event.stream_id] = remainder
for payload in payloads:
decoded: Final = _decode_inbound(payload)
if decoded is None:
continue
self._record(event.stream_id, decoded)
elif isinstance(event, (h2.events.StreamEnded, h2.events.StreamReset)):
self._close_stream(event.stream_id)
elif isinstance(event, h2.events.ConnectionTerminated):
self._writer.close()
def _record(self, stream_id: int, decoded: BedrockEvent) -> None:
stream: Final = self._streams[stream_id]
self._state.inbound.record(decoded)
stream.inbound.record(decoded)
self._state.stream_events.put((stream.ordinal, decoded))
if stream_id in self._ended:
return
refusal: Final = self._state.refusal
if refusal is not None:
if _opening_arrived(refusal.after, stream.inbound):
self._refuse(stream_id, refusal)
return
outbound_events: Final = self._state.script.ready_events(self._state.inbound, decoded)
for outbound in outbound_events:
self._h2.send_data(stream_id, _outbound_chunk(outbound))
failure: Final = self._state.failure
if outbound_events and failure is not None and self._state.script.exhausted:
self._h2.send_data(stream_id, _exception_frame(failure))
self._end_stream(stream_id)
def _refuse(self, stream_id: int, refusal: Refusal) -> None:
self._h2.send_headers(
stream_id,
[
(":status", str(refusal.status)),
("content-type", "application/json"),
("x-amzn-errortype", refusal.error_type),
],
)
self._h2.send_data(stream_id, json.dumps({"message": refusal.message}).encode())
self._end_stream(stream_id)
def _close_stream(self, stream_id: int) -> None:
self._end_stream(stream_id)
self._closed = True
self._h2.close_connection()
def _end_stream(self, stream_id: int) -> None:
if stream_id in self._ended:
return
self._ended.add(stream_id)
with suppress(h2.exceptions.ProtocolError):
self._h2.end_stream(stream_id)
def _session_handler(state: _PeerState) -> Handler:
async def serve(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
with suppress(ConnectionError, OSError, asyncio.IncompleteReadError, h2.exceptions.StreamClosedError):
await _H2Session(state, reader, writer).run()
return serve
@contextmanager
def bedrock_realtime_peer(
turns: tuple[Turn, ...],
cert: tuple[Path, Path],
*,
refusal: Refusal | None = None,
failure: StreamFailure | None = None,
) -> Iterator[BedrockRealtimePeer]:
"""Serve scripted `turns` on a loopback TLS h2 endpoint; yield the peer.
`cert` is the self-signed pair the owned proxy already trusts via SSL_CERT_FILE.
SigV4 headers are ignored; the stream closes cleanly when the client ends it.
"""
port: Final = allocate_port()
context: Final = server_context(cert[0], cert[1])
context.set_alpn_protocols(["h2"])
state: Final = _PeerState(turns, refusal, failure)
server: Final = LoopbackServer(port, _session_handler(state), ssl_context=context)
server.start()
try:
yield BedrockRealtimePeer(
api_base=f"https://localhost:{port}", _state=state, _drained=[], _opened=[], _stream_events=[]
)
finally:
server.stop()