Skip to content

Commit fe0c6af

Browse files
authored
Add integration tests for Bedrock Runtime (#37)
1 parent 2d8f974 commit fe0c6af

6 files changed

Lines changed: 394 additions & 1 deletion

File tree

clients/aws-sdk-bedrock-runtime/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
### Enhancements
6+
* Add comprehensive integration tests for non-streaming, output streaming, and bidirectional streaming operations.
7+
58
## v0.2.0
69

710
### API Changes
@@ -26,7 +29,7 @@
2629
* New stop reason for Converse and ConverseStream
2730

2831
### Enhancements
29-
* Improvements to the underlying AWS CRT HTTP client result in a signifigant decrease in CPU usage. Addresses [aws-sdk-python#11](https://github.com/awslabs/aws-sdk-python/issues/11).
32+
* Improvements to the underlying AWS CRT HTTP client result in a significant decrease in CPU usage. Addresses [aws-sdk-python#11](https://github.com/awslabs/aws-sdk-python/issues/11).
3033

3134
### Dependencies
3235

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
5+
from pathlib import Path
6+
7+
from smithy_aws_core.identity import EnvironmentCredentialsResolver
8+
9+
from aws_sdk_bedrock_runtime.client import BedrockRuntimeClient
10+
from aws_sdk_bedrock_runtime.config import Config
11+
12+
MODEL_ID = "amazon.titan-text-express-v1"
13+
BIDIRECTIONAL_MODEL_ID = "amazon.nova-sonic-v1:0"
14+
MESSAGE = "Who created the Python programming language?"
15+
AUDIO_FILE = Path(__file__).parent / "assets" / "test.pcm"
16+
17+
18+
def create_bedrock_client(region: str) -> BedrockRuntimeClient:
19+
"""Helper to create a BedrockRuntimeClient for a given region."""
20+
return BedrockRuntimeClient(
21+
config=Config(
22+
endpoint_uri=f"https://bedrock-runtime.{region}.amazonaws.com",
23+
region=region,
24+
aws_credentials_identity_resolver=EnvironmentCredentialsResolver(),
25+
)
26+
)
Binary file not shown.
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Test bidirectional streaming duplex event stream handling."""
5+
6+
import asyncio
7+
import base64
8+
import json
9+
import uuid
10+
11+
from smithy_core.aio.eventstream import DuplexEventStream
12+
13+
from aws_sdk_bedrock_runtime.models import (
14+
BidirectionalInputPayloadPart,
15+
InvokeModelWithBidirectionalStreamInputChunk,
16+
InvokeModelWithBidirectionalStreamOperationInput,
17+
InvokeModelWithBidirectionalStreamInput,
18+
InvokeModelWithBidirectionalStreamOutput,
19+
InvokeModelWithBidirectionalStreamOperationOutput,
20+
InvokeModelWithBidirectionalStreamOutputChunk,
21+
)
22+
23+
from . import AUDIO_FILE, BIDIRECTIONAL_MODEL_ID, create_bedrock_client
24+
25+
CHUNK_SIZE = 512
26+
SILENCE_CHUNKS = 125
27+
RESPONSE_WAIT_TIME = 3
28+
29+
DEFAULT_SYSTEM_PROMPT = (
30+
"You are a friendly assistant. Keep your responses short, "
31+
"generally one or two sentences."
32+
)
33+
34+
START_SESSION_EVENT = """{
35+
"event": {
36+
"sessionStart": {
37+
"inferenceConfiguration": {
38+
"maxTokens": 1024,
39+
"topP": 0.9,
40+
"temperature": 0.7
41+
}
42+
}
43+
}
44+
}"""
45+
46+
START_PROMPT_EVENT = """{
47+
"event": {
48+
"promptStart": {
49+
"promptName": "%s",
50+
"textOutputConfiguration": {
51+
"mediaType": "text/plain"
52+
},
53+
"audioOutputConfiguration": {
54+
"mediaType": "audio/lpcm",
55+
"sampleRateHertz": 24000,
56+
"sampleSizeBits": 16,
57+
"channelCount": 1,
58+
"voiceId": "matthew",
59+
"encoding": "base64",
60+
"audioType": "SPEECH"
61+
}
62+
}
63+
}
64+
}"""
65+
66+
TEXT_CONTENT_START_EVENT = """{
67+
"event": {
68+
"contentStart": {
69+
"promptName": "%s",
70+
"contentName": "%s",
71+
"type": "TEXT",
72+
"interactive": true,
73+
"role": "%s",
74+
"textInputConfiguration": {
75+
"mediaType": "text/plain"
76+
}
77+
}
78+
}
79+
}"""
80+
81+
TEXT_INPUT_EVENT = """{
82+
"event": {
83+
"textInput": {
84+
"promptName": "%s",
85+
"contentName": "%s",
86+
"content": "%s"
87+
}
88+
}
89+
}"""
90+
91+
CONTENT_END_EVENT = """{
92+
"event": {
93+
"contentEnd": {
94+
"promptName": "%s",
95+
"contentName": "%s"
96+
}
97+
}
98+
}"""
99+
100+
AUDIO_CONTENT_START_EVENT = """{
101+
"event": {
102+
"contentStart": {
103+
"promptName": "%s",
104+
"contentName": "%s",
105+
"type": "AUDIO",
106+
"interactive": true,
107+
"role": "USER",
108+
"audioInputConfiguration": {
109+
"mediaType": "audio/lpcm",
110+
"sampleRateHertz": 16000,
111+
"sampleSizeBits": 16,
112+
"channelCount": 1,
113+
"audioType": "SPEECH",
114+
"encoding": "base64"
115+
}
116+
}
117+
}
118+
}"""
119+
120+
AUDIO_INPUT_EVENT = """{
121+
"event": {
122+
"audioInput": {
123+
"promptName": "%s",
124+
"contentName": "%s",
125+
"content": "%s"
126+
}
127+
}
128+
}"""
129+
130+
PROMPT_END_EVENT = """{
131+
"event": {
132+
"promptEnd": {
133+
"promptName": "%s"
134+
}
135+
}
136+
}"""
137+
138+
SESSION_END_EVENT = """{
139+
"event": {
140+
"sessionEnd": {}
141+
}
142+
}"""
143+
144+
145+
async def _send_event(
146+
stream: DuplexEventStream[
147+
InvokeModelWithBidirectionalStreamInput,
148+
InvokeModelWithBidirectionalStreamOutput,
149+
InvokeModelWithBidirectionalStreamOperationOutput,
150+
],
151+
event_json: str,
152+
) -> None:
153+
"""Send a raw event JSON string to the Bedrock stream."""
154+
event = InvokeModelWithBidirectionalStreamInputChunk(
155+
value=BidirectionalInputPayloadPart(bytes_=event_json.encode("utf-8"))
156+
)
157+
await stream.input_stream.send(event)
158+
159+
160+
async def _send_audio_chunks(
161+
stream: DuplexEventStream[
162+
InvokeModelWithBidirectionalStreamInput,
163+
InvokeModelWithBidirectionalStreamOutput,
164+
InvokeModelWithBidirectionalStreamOperationOutput,
165+
],
166+
prompt_name: str,
167+
audio_content_name: str,
168+
) -> None:
169+
"""Send audio chunks from file simulating real-time delay."""
170+
chunk_count = 0
171+
with AUDIO_FILE.open("rb") as f:
172+
while chunk := f.read(CHUNK_SIZE):
173+
chunk_count += 1
174+
encoded_chunk = base64.b64encode(chunk).decode("utf-8")
175+
await _send_event(
176+
stream,
177+
AUDIO_INPUT_EVENT % (prompt_name, audio_content_name, encoded_chunk),
178+
)
179+
# 512 bytes / (16000 Hz * 2 bytes/sample) = 0.016s per chunk
180+
await asyncio.sleep(0.016)
181+
182+
assert chunk_count > 0, f"No audio chunks were sent from {AUDIO_FILE}"
183+
184+
silence_chunk = bytes(CHUNK_SIZE)
185+
encoded_silence = base64.b64encode(silence_chunk).decode("utf-8")
186+
for _ in range(SILENCE_CHUNKS):
187+
await _send_event(
188+
stream,
189+
AUDIO_INPUT_EVENT % (prompt_name, audio_content_name, encoded_silence),
190+
)
191+
await asyncio.sleep(0.016)
192+
193+
await _send_event(stream, CONTENT_END_EVENT % (prompt_name, audio_content_name))
194+
await asyncio.sleep(RESPONSE_WAIT_TIME)
195+
await _send_event(stream, PROMPT_END_EVENT % prompt_name)
196+
await _send_event(stream, SESSION_END_EVENT)
197+
198+
199+
async def _receive_stream_output(
200+
stream: DuplexEventStream[
201+
InvokeModelWithBidirectionalStreamInput,
202+
InvokeModelWithBidirectionalStreamOutput,
203+
InvokeModelWithBidirectionalStreamOperationOutput,
204+
],
205+
) -> tuple[bool, bool, list[str]]:
206+
"""Receive and collect output from the bidirectional stream.
207+
208+
Returns:
209+
Tuple of (got_text, got_audio, all_text_output)
210+
"""
211+
got_text = False
212+
got_audio = False
213+
all_text_output: list[str] = []
214+
215+
await stream.await_output()
216+
output_stream = stream.output_stream
217+
if output_stream is None:
218+
return got_text, got_audio, all_text_output
219+
220+
async for out in output_stream:
221+
if not isinstance(out, InvokeModelWithBidirectionalStreamOutputChunk):
222+
raise RuntimeError(
223+
f"Received unexpected event type in stream: {type(out).__name__}"
224+
)
225+
226+
payload = out.value.bytes_
227+
if not payload:
228+
continue
229+
230+
msg = json.loads(payload.decode("utf-8"))
231+
event_data = msg.get("event", {})
232+
233+
if "textOutput" in event_data:
234+
got_text = True
235+
text_content = event_data["textOutput"].get("content", "")
236+
all_text_output.append(text_content)
237+
if "audioOutput" in event_data:
238+
got_audio = True
239+
if "completionEnd" in event_data:
240+
break
241+
242+
return got_text, got_audio, all_text_output
243+
244+
245+
async def test_invoke_model_with_bidirectional_stream() -> None:
246+
"""Test bidirectional streaming with audio input and text/audio output."""
247+
bedrock_client = create_bedrock_client("us-east-1")
248+
249+
stream = await bedrock_client.invoke_model_with_bidirectional_stream(
250+
InvokeModelWithBidirectionalStreamOperationInput(
251+
model_id=BIDIRECTIONAL_MODEL_ID
252+
)
253+
)
254+
255+
prompt_name = str(uuid.uuid4())
256+
content_name = str(uuid.uuid4())
257+
audio_content_name = str(uuid.uuid4())
258+
259+
init_events = [
260+
START_SESSION_EVENT,
261+
START_PROMPT_EVENT % prompt_name,
262+
TEXT_CONTENT_START_EVENT % (prompt_name, content_name, "SYSTEM"),
263+
TEXT_INPUT_EVENT % (prompt_name, content_name, DEFAULT_SYSTEM_PROMPT),
264+
CONTENT_END_EVENT % (prompt_name, content_name),
265+
]
266+
267+
for event in init_events:
268+
await _send_event(stream, event)
269+
270+
await _send_event(
271+
stream, AUDIO_CONTENT_START_EVENT % (prompt_name, audio_content_name)
272+
)
273+
274+
results = await asyncio.gather(
275+
_send_audio_chunks(stream, prompt_name, audio_content_name),
276+
_receive_stream_output(stream),
277+
)
278+
got_text, got_audio, all_text_output = results[1]
279+
280+
assert got_text, "Expected to receive text output"
281+
assert got_audio, "Expected to receive audio output"
282+
assert len(all_text_output) > 0, "Expected non-empty text output"
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Test non-streaming output type handling."""
5+
6+
from aws_sdk_bedrock_runtime.models import (
7+
ContentBlockText,
8+
ConverseInput,
9+
ConverseOperationOutput,
10+
ConverseOutputMessage,
11+
Message,
12+
)
13+
14+
from . import MESSAGE, MODEL_ID, create_bedrock_client
15+
16+
17+
async def test_converse() -> None:
18+
bedrock_client = create_bedrock_client("us-west-2")
19+
20+
input_message = Message(role="user", content=[ContentBlockText(value=MESSAGE)])
21+
response = await bedrock_client.converse(
22+
ConverseInput(model_id=MODEL_ID, messages=[input_message])
23+
)
24+
25+
assert isinstance(response, ConverseOperationOutput)
26+
assert isinstance(response.output, ConverseOutputMessage)
27+
28+
output_message = response.output.value
29+
assert output_message.role == "assistant"
30+
assert len(output_message.content) > 0
31+
32+
content_block = output_message.content[0]
33+
assert isinstance(content_block, ContentBlockText)
34+
assert isinstance(content_block.value, str) and content_block.value
35+
36+
assert response.usage.input_tokens > 0
37+
assert response.usage.output_tokens > 0

0 commit comments

Comments
 (0)