1use aws_sdk_bedrockruntime::error::SdkError;
2use aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver;
3use aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError;
4use aws_sdk_bedrockruntime::types::{
5 ContentBlockDelta, ContentBlockStart, ConverseStreamOutput, StopReason as BedrockStopReason,
6 TokenUsage as BedrockTokenUsage,
7};
8use aws_smithy_types::event_stream::RawMessage;
9use futures::Stream;
10use std::collections::HashMap;
11use tracing::{debug, error, info, warn};
12
13use crate::{LlmError, LlmResponse, StopReason, TokenUsage, ToolCallRequest};
14
15impl From<&BedrockTokenUsage> for TokenUsage {
16 fn from(usage: &BedrockTokenUsage) -> Self {
17 TokenUsage {
18 input_tokens: u32::try_from(usage.input_tokens).unwrap_or(0),
19 output_tokens: u32::try_from(usage.output_tokens).unwrap_or(0),
20 cache_read_tokens: usage.cache_read_input_tokens().and_then(|v| u32::try_from(v).ok()),
21 cache_creation_tokens: usage.cache_write_input_tokens().and_then(|v| u32::try_from(v).ok()),
22 cache_reporting_exclusive: Some(true),
23 ..TokenUsage::default()
24 }
25 }
26}
27
28struct PendingToolCall {
29 id: String,
30 name: String,
31 args: String,
32}
33
34enum StreamEvent {
35 Emit(LlmResponse),
36 Stop(StopReason),
37 Skip,
38}
39
40pub fn process_bedrock_stream(
41 mut receiver: EventReceiver<ConverseStreamOutput, ConverseStreamOutputError>,
42) -> impl Stream<Item = crate::Result<LlmResponse>> + Send {
43 async_stream::stream! {
44 let message_id = uuid::Uuid::new_v4().to_string();
45 yield Ok(LlmResponse::Start { message_id });
46
47 let mut active_tool_calls: HashMap<i32, PendingToolCall> = HashMap::new();
48 let mut last_stop_reason: Option<StopReason> = None;
49
50 loop {
51 match receiver.recv().await {
52 Ok(Some(event)) => {
53 match process_stream_event(&event, &mut active_tool_calls) {
54 StreamEvent::Emit(resp) => yield Ok(resp),
55 StreamEvent::Stop(sr) => last_stop_reason = Some(sr),
56 StreamEvent::Skip => {}
57 }
58 }
59 Ok(None) => {
60 debug!("Bedrock stream ended (recv returned None)");
61 break;
62 }
63 Err(e) => {
64 error!("Bedrock stream recv error: {e}");
65 yield Err(LlmError::from(e));
66 break;
67 }
68 }
69 }
70
71 for (_index, tc) in active_tool_calls {
73 let tool_call = ToolCallRequest {
74 id: tc.id,
75 name: tc.name,
76 arguments: tc.args,
77 };
78 yield Ok(LlmResponse::ToolRequestComplete { tool_call });
79 }
80
81 yield Ok(LlmResponse::Done {
82 stop_reason: last_stop_reason,
83 });
84 }
85}
86
87fn process_stream_event(
88 event: &ConverseStreamOutput,
89 active_tool_calls: &mut HashMap<i32, PendingToolCall>,
90) -> StreamEvent {
91 match event {
92 ConverseStreamOutput::MessageStart(_) => {
93 info!("Bedrock message started");
94 StreamEvent::Skip
95 }
96 ConverseStreamOutput::ContentBlockStart(start_event) => {
97 handle_content_block_start(start_event, active_tool_calls)
98 }
99 ConverseStreamOutput::ContentBlockDelta(delta_event) => {
100 handle_content_block_delta(delta_event, active_tool_calls)
101 }
102 ConverseStreamOutput::ContentBlockStop(stop_event) => {
103 handle_content_block_stop(stop_event.content_block_index(), active_tool_calls)
104 }
105 ConverseStreamOutput::MessageStop(stop_event) => {
106 let stop_reason = map_bedrock_stop_reason(&stop_event.stop_reason);
107 info!("Bedrock message stopped: {stop_reason:?}");
108 StreamEvent::Stop(stop_reason)
109 }
110 ConverseStreamOutput::Metadata(metadata_event) => metadata_event
111 .usage()
112 .map_or(StreamEvent::Skip, |usage| StreamEvent::Emit(LlmResponse::Usage { tokens: usage.into() })),
113 other => {
114 warn!("Unhandled Bedrock stream event: {other:?}");
115 StreamEvent::Skip
116 }
117 }
118}
119
120fn handle_content_block_start(
121 event: &aws_sdk_bedrockruntime::types::ContentBlockStartEvent,
122 active_tool_calls: &mut HashMap<i32, PendingToolCall>,
123) -> StreamEvent {
124 let index = event.content_block_index();
125
126 if let Some(ContentBlockStart::ToolUse(tool_start)) = event.start() {
127 let id = tool_start.tool_use_id().to_string();
128 let name = tool_start.name().to_string();
129 debug!("Bedrock tool use started: {name} ({id})");
130 active_tool_calls.insert(index, PendingToolCall { id: id.clone(), name: name.clone(), args: String::new() });
131 StreamEvent::Emit(LlmResponse::ToolRequestStart { id, name })
132 } else {
133 debug!("Content block started at index {index}");
134 StreamEvent::Skip
135 }
136}
137
138fn handle_content_block_delta(
139 event: &aws_sdk_bedrockruntime::types::ContentBlockDeltaEvent,
140 active_tool_calls: &mut HashMap<i32, PendingToolCall>,
141) -> StreamEvent {
142 let index = event.content_block_index();
143
144 let Some(delta) = event.delta() else {
145 return StreamEvent::Skip;
146 };
147
148 match delta {
149 ContentBlockDelta::Text(text) if !text.is_empty() => {
150 StreamEvent::Emit(LlmResponse::Text { chunk: text.clone() })
151 }
152 ContentBlockDelta::ToolUse(tool_delta) => {
153 let input = tool_delta.input();
154 if input.is_empty() {
155 return StreamEvent::Skip;
156 }
157
158 if let Some(tc) = active_tool_calls.get_mut(&index) {
159 tc.args.push_str(input);
160 StreamEvent::Emit(LlmResponse::ToolRequestArg { id: tc.id.clone(), chunk: input.to_string() })
161 } else {
162 warn!("Received tool input delta for unknown content block index: {index}");
163 StreamEvent::Skip
164 }
165 }
166 ContentBlockDelta::ReasoningContent(reasoning) => {
167 if let Ok(text) = reasoning.as_text()
168 && !text.is_empty()
169 {
170 return StreamEvent::Emit(LlmResponse::Reasoning { chunk: text.clone() });
171 }
172 StreamEvent::Skip
173 }
174 _ => {
175 debug!("Unhandled content block delta type");
176 StreamEvent::Skip
177 }
178 }
179}
180
181fn handle_content_block_stop(index: i32, active_tool_calls: &mut HashMap<i32, PendingToolCall>) -> StreamEvent {
182 if let Some(tc) = active_tool_calls.remove(&index) {
183 let tool_call = ToolCallRequest { id: tc.id, name: tc.name, arguments: tc.args };
184 StreamEvent::Emit(LlmResponse::ToolRequestComplete { tool_call })
185 } else {
186 debug!("Content block stopped at index {index}");
187 StreamEvent::Skip
188 }
189}
190
191impl From<SdkError<ConverseStreamOutputError, RawMessage>> for LlmError {
192 fn from(e: SdkError<ConverseStreamOutputError, RawMessage>) -> Self {
193 let message = format!("Bedrock stream error: {e}");
194 match e {
195 SdkError::ServiceError(svc) => {
196 let inner = svc.err();
197 if inner.is_throttling_exception() {
198 LlmError::RateLimited(message)
199 } else if inner.is_service_unavailable_exception()
200 || inner.is_internal_server_exception()
201 || inner.is_model_stream_error_exception()
202 {
203 LlmError::StreamInterrupted(message)
204 } else {
205 LlmError::ApiError(message)
206 }
207 }
208 _ => LlmError::StreamInterrupted(message),
209 }
210 }
211}
212
213fn map_bedrock_stop_reason(reason: &BedrockStopReason) -> StopReason {
214 match reason {
215 BedrockStopReason::EndTurn | BedrockStopReason::StopSequence => StopReason::EndTurn,
216 BedrockStopReason::ToolUse => StopReason::ToolCalls,
217 BedrockStopReason::MaxTokens | BedrockStopReason::ModelContextWindowExceeded => StopReason::Length,
218 BedrockStopReason::ContentFiltered | BedrockStopReason::GuardrailIntervened => StopReason::ContentFilter,
219 other => StopReason::Unknown(format!("{other:?}")),
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn test_map_stop_reason_end_turn() {
229 assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::EndTurn), StopReason::EndTurn);
230 }
231
232 #[test]
233 fn test_map_stop_reason_stop_sequence() {
234 assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::StopSequence), StopReason::EndTurn);
235 }
236
237 #[test]
238 fn test_map_stop_reason_tool_use() {
239 assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::ToolUse), StopReason::ToolCalls);
240 }
241
242 #[test]
243 fn test_map_stop_reason_max_tokens() {
244 assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::MaxTokens), StopReason::Length);
245 }
246
247 #[test]
248 fn test_map_stop_reason_context_window_exceeded() {
249 assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::ModelContextWindowExceeded), StopReason::Length);
250 }
251
252 #[test]
253 fn test_map_stop_reason_content_filtered() {
254 assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::ContentFiltered), StopReason::ContentFilter);
255 }
256
257 #[test]
258 fn test_map_stop_reason_guardrail() {
259 assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::GuardrailIntervened), StopReason::ContentFilter);
260 }
261
262 #[test]
263 fn test_handle_content_block_start_tool_use() {
264 let mut active = HashMap::new();
265 let tool_start = aws_sdk_bedrockruntime::types::ToolUseBlockStart::builder()
266 .tool_use_id("tool_123")
267 .name("search")
268 .build()
269 .unwrap();
270
271 let event = aws_sdk_bedrockruntime::types::ContentBlockStartEvent::builder()
272 .content_block_index(0)
273 .start(ContentBlockStart::ToolUse(tool_start))
274 .build()
275 .unwrap();
276
277 let result = handle_content_block_start(&event, &mut active);
278 assert!(
279 matches!(&result, StreamEvent::Emit(LlmResponse::ToolRequestStart { id, name }) if id == "tool_123" && name == "search")
280 );
281 assert!(active.contains_key(&0));
282 }
283
284 #[test]
285 fn test_handle_content_block_delta_text() {
286 let mut active = HashMap::new();
287 let delta = aws_sdk_bedrockruntime::types::ContentBlockDeltaEvent::builder()
288 .content_block_index(0)
289 .delta(ContentBlockDelta::Text("Hello".to_string()))
290 .build()
291 .unwrap();
292
293 let result = handle_content_block_delta(&delta, &mut active);
294 assert!(matches!(&result, StreamEvent::Emit(LlmResponse::Text { chunk }) if chunk == "Hello"));
295 }
296
297 #[test]
298 fn test_handle_content_block_delta_tool_input() {
299 let mut active = HashMap::new();
300 active
301 .insert(0, PendingToolCall { id: "tool_123".to_string(), name: "search".to_string(), args: String::new() });
302
303 let tool_delta =
304 aws_sdk_bedrockruntime::types::ToolUseBlockDelta::builder().input(r#"{"query":"test"}"#).build().unwrap();
305
306 let delta = aws_sdk_bedrockruntime::types::ContentBlockDeltaEvent::builder()
307 .content_block_index(0)
308 .delta(ContentBlockDelta::ToolUse(tool_delta))
309 .build()
310 .unwrap();
311
312 let result = handle_content_block_delta(&delta, &mut active);
313 assert!(
314 matches!(&result, StreamEvent::Emit(LlmResponse::ToolRequestArg { id, chunk }) if id == "tool_123" && chunk == r#"{"query":"test"}"#)
315 );
316
317 assert_eq!(active.get(&0).unwrap().args, r#"{"query":"test"}"#);
319 }
320
321 #[test]
322 fn test_handle_content_block_stop_completes_tool() {
323 let mut active = HashMap::new();
324 active.insert(
325 0,
326 PendingToolCall {
327 id: "tool_123".to_string(),
328 name: "search".to_string(),
329 args: r#"{"query":"test"}"#.to_string(),
330 },
331 );
332
333 let result = handle_content_block_stop(0, &mut active);
334 assert!(matches!(&result, StreamEvent::Emit(LlmResponse::ToolRequestComplete { tool_call })
335 if tool_call.id == "tool_123"
336 && tool_call.name == "search"
337 && tool_call.arguments == r#"{"query":"test"}"#
338 ));
339 assert!(active.is_empty());
340 }
341
342 #[test]
343 fn test_handle_content_block_stop_no_tool() {
344 let mut active = HashMap::new();
345 let result = handle_content_block_stop(0, &mut active);
346 assert!(matches!(result, StreamEvent::Skip));
347 }
348
349 #[test]
350 fn test_metadata_event_emits_cache_read_and_creation() {
351 let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder()
352 .input_tokens(100)
353 .output_tokens(50)
354 .total_tokens(150)
355 .cache_read_input_tokens(40)
356 .cache_write_input_tokens(20)
357 .build()
358 .unwrap();
359
360 let metadata = aws_sdk_bedrockruntime::types::ConverseStreamMetadataEvent::builder().usage(usage).build();
361
362 let event = ConverseStreamOutput::Metadata(metadata);
363 let mut active = HashMap::new();
364 let result = process_stream_event(&event, &mut active);
365
366 match result {
367 StreamEvent::Emit(LlmResponse::Usage { tokens: sample }) => {
368 assert_eq!(sample.input_tokens, 100);
369 assert_eq!(sample.output_tokens, 50);
370 assert_eq!(sample.cache_read_tokens, Some(40));
371 assert_eq!(sample.cache_creation_tokens, Some(20));
372 assert_eq!(sample.cache_reporting_exclusive, Some(true));
373 }
374 _ => panic!("expected Emit(Usage{{..}})"),
375 }
376 }
377
378 #[test]
379 fn test_metadata_event_without_cache_fields() {
380 let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder()
381 .input_tokens(10)
382 .output_tokens(5)
383 .total_tokens(15)
384 .build()
385 .unwrap();
386
387 let metadata = aws_sdk_bedrockruntime::types::ConverseStreamMetadataEvent::builder().usage(usage).build();
388
389 let event = ConverseStreamOutput::Metadata(metadata);
390 let mut active = HashMap::new();
391 let result = process_stream_event(&event, &mut active);
392
393 match result {
394 StreamEvent::Emit(LlmResponse::Usage { tokens: sample }) => {
395 assert_eq!(sample.cache_read_tokens, None);
396 assert_eq!(sample.cache_creation_tokens, None);
397 }
398 _ => panic!("expected Emit(Usage{{..}})"),
399 }
400 }
401
402 #[test]
403 fn test_handle_content_block_delta_empty_text() {
404 let mut active = HashMap::new();
405 let delta = aws_sdk_bedrockruntime::types::ContentBlockDeltaEvent::builder()
406 .content_block_index(0)
407 .delta(ContentBlockDelta::Text(String::new()))
408 .build()
409 .unwrap();
410
411 let result = handle_content_block_delta(&delta, &mut active);
412 assert!(matches!(result, StreamEvent::Skip));
413 }
414}